В чём заключается проблема N+1 в Hibernate и как её решить?

Ответ

Проблема N+1 — это антипаттерн производительности, когда для загрузки одной сущности и связанной с ней коллекции выполняется 1 запрос для родительских записей и N отдельных запросов для дочерних (по одному на каждую родительскую запись).

Пример возникновения (с ленивой загрузкой FetchType.LAZY):

@Entity
public class Author {
    @Id
    @GeneratedValue
    private Long id;
    private String name;

    @OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
    private List<Book> books; // Ленивая коллекция
}

@Entity
public class Book {
    @Id
    @GeneratedValue
    private Long id;
    private String title;

    @ManyToOne
    private Author author;
}

Проблемный код, вызывающий N+1 запросов:

// 1-й запрос: SELECT * FROM Author
List<Author> authors = entityManager
    .createQuery("SELECT a FROM Author a", Author.class)
    .getResultList();

// Для каждого автора (N раз) выполняется отдельный запрос:
// SELECT * FROM Book WHERE author_id = ?
for (Author author : authors) {
    // Обращение к коллекции инициирует её загрузку
    System.out.println(author.getBooks().size());
}

Решение: Использовать JOIN FETCH в JPQL.

// Один запрос с JOIN, который сразу загружает и авторов, и их книги
List<Author> authors = entityManager
    .createQuery(
        "SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books",
        Author.class
    )
    .getResultList();
// Дальнейший доступ к author.getBooks() не вызывает дополнительных запросов к БД

Альтернативные решения:

  • Entity Graphs: Более типобезопасный и декларативный способ.
  • @BatchSize: Загружает связанные коллекции пачками, а не по одной.

Ответ 18+ 🔞

А, ну это же классика, ёпта! Сидишь такой, думаешь, что всё оптимизировал, а потом бац — и приложение ползёт, как улитка по стеклу, намазавшись маслом. А виной всему этот, блядь, N+1 запрос, хитрая жопа такая.

Представь себе, сука, ты пишешь код, который вроде бы логичный. Хочешь получить список авторов и посмотреть, сколько у каждого книг. Ну, типа, SELECT * FROM Author. Один запрос, всё окей, волнение ебать.

А потом ты в цикле начинаешь к каждой книжной коллекции обращаться. И вот тут-то, блядь, и начинается пиздец. Твоя ORM, эта мартышлюшка, для каждого автора по отдельности лезет в базу: SELECT * FROM Book WHERE author_id = ?. Получается, если авторов 100 (N), то запросов будет 1 + 100. Вот тебе и N+1, овердохуища лишней работы!

Код, который это вызовет, выглядит вроде бы невинно, а на деле — диверсия:

// Запрос 1: всех авторов взяли
List<Author> authors = entityManager
    .createQuery("SELECT a FROM Author a", Author.class)
    .getResultList();

// А тут для каждого — свой отдельный запрос в БД. Пизда рулю.
for (Author author : authors) {
    System.out.println(author.getBooks().size()); // Ой, бля, а книг-то нет, надо сходить за ними!
}

Решение, блядь? Да не городить же сто хуёв, а сразу всё нужное за раз выгрести! Спасает JOIN FETCH, который как добрый самаритянин, одним махом всё приносит.

// Всё! Один здоровенный запрос, и авторы, и книги у них в кармане.
List<Author> authors = entityManager
    .createQuery(
        "SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books", // FETCH — это ключевое слово, ёпта!
        Author.class
    )
    .getResultList();
// Теперь author.getBooks() уже загружены, можно хоть обосраться от счастья, новых запросов не будет.

Ну а если JOIN FETCH не по душе, есть и другие варианты, конечно:

  • Entity Graphs — чтоб не писать JOIN'ы руками, типа как для грамотных.
  • @BatchSize — эта штука не одним махом грузит, а пачками. Типа, не по одной книге таскать, а сразу по 10-20. Тоже норм, но JOIN FETCH обычно быстрее, если тебе прямо все данные сразу нужны.

Короче, суть в чём: не заставляй базу дергаться на каждый чих. Бери всё, что нужно для задачи, одним здоровенным куском. И будет тебе счастье, а не тормоза.