Что такое проблема N+1 в Hibernate и как её решить?

Ответ

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

Проблемный пример (Lazy Loading):

@Entity
public class Author {
    @Id
    private Long id;
    private String name;
    @OneToMany(mappedBy = "author", fetch = FetchType.LAZY) // Ленивая загрузка по умолчанию
    private List<Book> books;
}

// В сервисе или DAO:
List<Author> authors = entityManager.createQuery("SELECT a FROM Author a", Author.class)
                                     .getResultList(); // 1-й запрос: получаем всех авторов

for (Author a : authors) {
    // Для каждого автора здесь выполняется отдельный запрос к БД для загрузки books!
    System.out.println(a.getBooks().size()); // N запросов
}
// ИТОГО: 1 + N запросов

Основные решения:

  1. Использовать JOIN FETCH в JPQL/HQL:
    List<Author> authors = entityManager.createQuery(
            "SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books", Author.class)
            .getResultList(); // ВСЕ данные загружаются ОДНИМ запросом
  2. Использовать @EntityGraph:
    @EntityGraph(attributePaths = {"books"})
    @Query("SELECT a FROM Author a")
    List<Author> findAllWithBooks();
  3. Настроить @BatchSize для ленивой загрузки:
    @OneToMany(mappedBy = "author")
    @BatchSize(size = 10)
    private List<Book> books;
    // Hibernate загрузит книги для авторов пачками (например, по 10), уменьшив N+1 до N/10 + 1

    Выбор стратегии зависит от конкретного сценария использования данных.

Ответ 18+ 🔞

Да ты посмотри, что эти ORM-фреймворки творят, а? Сидишь такой, думаешь — ну вот, автоматизация, красота, жизнь налаживается. А они тебе подсовывают эту хуйню под названием "Проблема N+1", сука! Это ж классика, блядь, как "Муму" у Тургенева, только про базы данных.

Представь картину, ёпта. У тебя есть автор, а у автора — книги. Всё просто, как три копейки.

@Entity
public class Author {
    @Id
    private Long id;
    private String name;
    @OneToMany(mappedBy = "author", fetch = FetchType.LAZY) // Вот она, родная, ленивая загрузка по дефолту
    private List<Book> books;
}

И вот ты, такой довольный, пишешь код:

List<Author> authors = entityManager.createQuery("SELECT a FROM Author a", Author.class)
                                     .getResultList(); // Раз! Один запрос, получаем всех авторов. Красота!

for (Author a : authors) {
    // А тут начинается пиздец, блядь.
    System.out.println(a.getBooks().size()); // Для КАЖДОГО автора — отдельный запрос в БД!
}
// ИТОГО: 1 (за авторов) + N (за книги каждого) запросов. Овердохуища обращений к базе!

Вот это и есть тот самый N+1! Получил 100 авторов — готовься, сука, к 101 запросу. База данных сдохнет, а приложение будет тормозить, как черепаха в сиропе. Эффективность — ноль ебать.

Но не всё так плохо, есть же способы эту мартышлюшку порешать!

1. JOIN FETCH — наш спаситель, блядь! Всё за один раз, как в хорошей драке. Не надо бегать туда-сюда.

List<Author> authors = entityManager.createQuery(
        "SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books", Author.class)
        .getResultList(); // ВСЕ данные — авторы И их книги — загружаются ОДНИМ здоровенным запросом!

Вжух — и всё готово. Главное DISTINCT не забыть, а то дубликаты авторов попрут.

2. @EntityGraph — модная штука для Spring Data JPA Типа декларативно говоришь: "Эй, чувак, когда будешь искать авторов, сразу тащи с собой книги, не ссы!"

@EntityGraph(attributePaths = {"books"})
@Query("SELECT a FROM Author a")
List<Author> findAllWithBooks();

Стильно, блядь, современно. Под капотом тот же JOIN FETCH генерирует.

3. @BatchSize — хитрый ленивый костыль А что если мы не можем или не хотим всё грузить одним махом? Вот для этого есть пакетная загрузка.

@OneToMany(mappedBy = "author")
@BatchSize(size = 10)
private List<Book> books;
// Hibernate не будет дёргать базу для каждой книги каждого автора.
// Он подождёт, пока наберётся, условно, 10 авторов, у которых нужно загрузить книги,
// и сходит за ними ОДНИМ запросом. Вместо N+1 получится что-то вроде N/10 + 1.

Какой способ выбрать? А вот это, друг мой, вопрос на миллион. Смотри по ситуации:

  • Если тебе всегда и сразу нужны и автор, и все его книги — JOIN FETCH или @EntityGraph, нихуя не мудри.
  • Если книги нужны иногда, и авторов может быть дохуища — @BatchSize может спасти твою производительность.
  • Если начнёшь всё подряд JOIN FETCH'ить в сложных связях, можно получить обратную проблему — картезианский продукт, где тебе приедет тонна дублированных данных. Тут уже надо думать головой, а не жопой.

Короче, следи за запросами в логах (spring.jpa.show-sql=true), не доверяй ленивой загрузке слепо, и всё будет пучком. А то так и до перформанс-ревью недалеко, где тебе объяснят, на каком тонком льду ты ходил.