💾 Раздел 16 · Вопрос #1

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

В результате вместо одного быстрого SQL-запроса с соединением таблиц (JOIN) приложение лавинообразно отправляет в базу данных десятки или сотни мелких запросов:


🟢 Junior Level

Что такое проблема N+1

Проблема N+1 (N+1 Query Problem) — это классическая архитектурная ловушка объектно-реляционного отображения (ORM), при которой для загрузки $N$ родительских сущностей и связанных с ними дочерних объектов Hibernate выполняет 1 начальный запрос для выборки списка родителей и затем $N$ дополнительных SQL-запросов (по одному на каждого родителя) для подгрузки дочерних записей.

В результате вместо одного быстрого SQL-запроса с соединением таблиц (JOIN) приложение лавинообразно отправляет в базу данных десятки или сотни мелких запросов:

\[\text{Общее число запросов} = 1 + N\]
Без оптимизации (Проблема N+1):
1. SELECT * FROM orders;                                      -- (1 запрос, вернул 100 заказов)
2. SELECT * FROM order_items WHERE order_id = 1;              -- (Запрос для заказа 1)
3. SELECT * FROM order_items WHERE order_id = 2;              -- (Запрос для заказа 2)
...
101. SELECT * FROM order_items WHERE order_id = 100;          -- (Запрос для заказа 100)
ИТОГО: 101 сетевой round-trip к базе данных!

С правильным решением (JOIN FETCH):
1. SELECT o.*, i.* FROM orders o 
   LEFT JOIN order_items i ON o.id = i.order_id;              -- ИТОГО: РОВНО 1 ЗАПРОС!

Как это выглядит в Java-коде

@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // По умолчанию для связей *ToMany fetch-стратегия является LAZY
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items = new ArrayList<>();
    
    // геттеры, сеттеры
}

// Код сервиса:
List<Order> orders = entityManager.createQuery("SELECT o FROM Order o", Order.class)
        .getResultList(); // Выполняется 1 запрос: SELECT * FROM orders

for (Order order : orders) {
    // Каждое первое обращение к коллекции order.getItems() инициирует отдельный SQL-запрос!
    System.out.println("Заказ " + order.getId() + " содержит позиций: " + order.getItems().size());
}

Симптомы проблемы в приложении

  • Резкое падение производительности и рост времени ответа API (Latency) при росте количества записей в таблице.
  • Логи пула соединений (HikariCP) показывают истощение свободных коннектов (ConnectionTimeoutException).
  • В логах SQL-запросов (spring.jpa.show-sql=true) идут бесконечные простыни одинаковых SELECT ... WHERE parent_id = ?.

🟡 Middle Level

Способы решения проблемы N+1

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

1. Использование JOIN FETCH в JPQL / HQL

Самый простой и популярный способ — явное указание инструкции JOIN FETCH, которая инструктирует Hibernate загрузить связанные сущности в том же самом SQL-запросе:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o LEFT JOIN FETCH o.items")
    List<Order> findAllWithItems();
}

Hibernate сформирует единый запрос:

SELECT o.id, o.created_at, i.id, i.order_id, i.price, i.product_name
FROM orders o
LEFT JOIN order_items i ON o.id = i.order_id;

Нюанс Hibernate 5 vs 6: В Hibernate 5 при JOIN FETCH коллекций требовалось писать SELECT DISTINCT o, иначе в результирующем списке List<Order> содержались дубликаты одного и того же объекта Order (по числу дочерних OrderItem). Начиная с Hibernate 6, дедупликация корневых сущностей в памяти приложения выполняется автоматически.

2. Динамические графы сущностей (@EntityGraph)

Позволяет переопределить стратегию FetchType.LAZY на уровне конкретного метода репозитория без дублирования JPQL-запросов:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = {"items"})
    @Query("SELECT o FROM Order o")
    List<Order> findAllWithGraph();
}

Свойства графа:

  • jakarta.persistence.fetchgraph — все атрибуты, указанные в графе, загружаются как EAGER, а все остальные принудительно становятся LAZY.
  • jakarta.persistence.loadgraph — атрибуты графа загружаются как EAGER, а не указанные атрибуты сохраняют свои дефолтные аннотации в Entity.

3. Пакетная загрузка через @BatchSize

Если связанные данные нужны не всегда, а только при определенных условиях, JOIN FETCH избыточен. Аннотация @BatchSize указывает Hibernate объединять ленивые запросы в группы с использованием оператора IN:

@Entity
public class Order {
    @Id
    private Long id;

    @BatchSize(size = 30)
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items = new ArrayList<>();
}

Вместо 100 отдельных запросов Hibernate выполнит всего 4 запроса (батчами по 30 и остаток 10):

SELECT * FROM order_items WHERE order_id IN (?, ?, ?, ... 30 параметров);

Глобально для всего приложения можно включить параметр в application.yml:

spring:
  jpa:
    properties:
      hibernate.default_batch_fetch_size: 30

4. Подзапросы @Fetch(FetchMode.SUBSELECT)

@Entity
public class Order {
    @Id
    private Long id;

    @org.hibernate.annotations.Fetch(FetchMode.SUBSELECT)
    @OneToMany(mappedBy = "order")
    private List<OrderItem> items;
}

При первом обращении к коллекции любого заказа Hibernate выполнит ровно один дополнительный запрос, повторив исходный критерий выборки родителей:

SELECT * FROM order_items WHERE order_id IN (SELECT id FROM orders);

5. DTO-проекции (Best Practice для чтения)

Для сценариев Read-Only (вывод списков, отчетов, UI таблиц) загружать полные управляемые сущности (Managed Entities) не нужно. Прямая проекция в Java Record или DTO через конструктор работает быстрее всего:

public record OrderSummaryDto(Long orderId, String customerName, Long totalItems) {}

@Query("""
    SELECT new com.example.dto.OrderSummaryDto(o.id, o.customerName, COUNT(i.id))
    FROM Order o 
    LEFT JOIN o.items i 
    GROUP BY o.id, o.customerName
""")
List<OrderSummaryDto> findSummaries();

🔴 Senior Level

Внутреннее устройство: Механика Proxies и PersistenceContext

Под капотом проблема N+1 обусловлена архитектурой отложенной инициализации (Lazy Loading) через динамические прокси (ByteBuddy в современном Hibernate):

1. Вызов entityManager.createQuery("SELECT o FROM Order o").getResultList():
   - Hibernate наполняет PersistenceContext (L1 Cache) экземплярами Entity Order.
   - Поле items инициализируется не ArrayList, а PersistentBag (имплементация PersistentCollection).
   - Внутри PersistentBag флаг initialized = false, а ссылка на Session сохранена.

2. При вызове order.getItems().size() (или .get(0), или итерации for-each):
   - PersistentBag перехватывает вызов метода.
   - Проверяет: if (!initialized).
   - Извлекает текущую Session и генерирует динамический SQL-запрос для текущего идентификатора owner.id.
   - Заполняет внутреннюю коллекцию элементами и выставляет initialized = true.
   - Поскольку у каждого Order свой собственный экземпляр PersistentBag, каждый из них выполняет этот цикл независимо!

Критическая ловушка: Пагинация и JOIN FETCH (HHH000104)

Самая опасная ошибка сеньор-уровня — попытка решить N+1 с помощью JOIN FETCH при наличии постраничного вывода (Pageable / setFirstResult / setMaxResults):

// ОПАСНО ДЛЯ PRODUCTION!
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items")
Page<Order> findOrders(Pageable pageable);

Что происходит в базе данных и памяти JVM:

  1. Реляционная СУБД при выполнении JOIN возвращает декартово произведение строк: если заказ имеет 5 позиций, база вернет 5 строк для одного заказа.
  2. Если применить SQL-лимит LIMIT 10 OFFSET 0, база данных отсечет первые 10 строк результатов джойна, то есть вернет 2 полных заказа и половину позиций третьего заказа! Hibernate не сможет корректно собрать сущность.
  3. Поэтому Hibernate вынужден удалить LIMIT и OFFSET из SQL-запроса и скачать ВСЮ таблицу целиком в память JVM!
  4. В логах появляется фатальное предупреждение: WARN: HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
  5. Если в таблице 1 000 000 заказов, приложение скачает сотни мегабайт по сети, вызовет гигантскую паузу GC и упадет по OutOfMemoryError.

Правильное решение пагинации без N+1:

Использовать двухэтапный запрос или @BatchSize / default_batch_fetch_size:

// 1. Выбираем только ID заказов с пагинацией (LIMIT/OFFSET выполняется на уровне БД):
@Query("SELECT o.id FROM Order o")
Page<Long> findOrderIds(Pageable pageable);

// 2. Достаем полные заказы по списку ID с JOIN FETCH (без пагинации):
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id IN :ids")
List<Order> findOrdersWithItemsByIds(@Param("ids") List<Long> ids);

Либо выполнять обычный SELECT o FROM Order o с Pageable, настроив hibernate.default_batch_fetch_size: 50.

Проблема декартова произведения (MultipleBagFetchException)

Попытка сделать одновременный JOIN FETCH для двух и более коллекций типа List (Bag):

@Query("SELECT o FROM Order o JOIN FETCH o.items JOIN FETCH o.payments")
List<Order> findBroken();

Приводит к выбросу исключения: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags.

  • Если у заказа 10 позиций (items) и 10 платежей (payments), результат джойна вернет $10 \times 10 = 100$ строк на один заказ.
  • Передача избыточных дубликатов забивает пропускную способность сети и память.
  • Решение: Загружать одну коллекцию через JOIN FETCH, а вторую — через @BatchSize, либо разделять на два последовательных запроса в рамках одной транзакции (Hibernate свяжет сущности в PersistenceContext по ссылкам).

N+1 в @OneToOne связях

Распространенное заблуждение: «FetchType.LAZY всегда защищает от N+1». Для связи @OneToOne со стороны, не являющейся владельцем внешнего ключа (non-owning side, mappedBy), ленивая загрузка не работает по умолчанию:

@Entity
public class User {
    @Id
    private Long id;

    // В таблице users нет колонки profile_id! Внешний ключ user_id лежит в таблице profiles
    @OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
    private UserProfile profile;
}

Чтобы создать прокси-объект UserProfile, Hibernate обязан знать: существует профиль или там null. Но в строке таблицы users нет информации об этом! Поэтому Hibernate вынужден немедленно выполнить SELECT * FROM profiles WHERE user_id = ?. При выборке 100 пользователей Hibernate выполнит 100 запросов к таблице profiles, несмотря на явное указание FetchType.LAZY!


4 Tricky Questions

1. Почему FetchType.EAGER не только не решает проблему N+1, но и гарантированно её усугубляет?

Ответ:

  1. Если связь помечена как FetchType.EAGER, аннотация сообщает Hibernate: «Эти связанные данные должны быть загружены всегда при чтении сущности».
  2. Когда сущность загружается через entityManager.find(Order.class, 1L), Hibernate может сгенерировать LEFT OUTER JOIN сразу.
  3. Но когда выполняется запрос на JPQL/HQL (SELECT o FROM Order o), парсер JPQL буквально исполняет написанный разработчиком запрос: «Выбрать сущности Order».
  4. После того как начальный запрос вернул $N$ заказов, Hibernate видит аннотацию EAGER и понимает, что обязан подгрузить зависимости для каждого из них.
  5. Так как в исходном JPQL не было инструкции JOIN FETCH, Hibernate выполняет $N$ дополнительных SELECT-запросов для каждой строки.
  6. В отличие от LAZY, где разработчик может избежать дополнительных запросов, просто не обращаясь к геттеру, при EAGER эти $N$ запросов будут выполнены всегда и безусловно, даже если дочерние данные не нужны вызывающему коду.

2. Как задетектировать проблему N+1 в автоматических тестах (CI/CD pipeline) до попадания кода в production?

Ответ: Полагаться на ручной просмотр логов SQL ненадежно. В индустрии применяются три подхода к автоматизации:

  1. Библиотека QuickPerf: Позволяет вешать аннотации поверх JUnit-тестов с лимитом запросов:
    @Test
    @ExpectSelect(1) // Тест упадет, если выполнится 2 или более SELECT
    void shouldFindAllOrdersWithoutNPlusOne() {
        orderService.findAllOrders();
    }
    
  2. Перехватчик DataSource (datasource-proxy): Оборачивает реальный DataSource тестового профиля и считает вызовы executeQuery. В конце теста assertions проверяют, что число запросов не превышает ожидаемую константу.
  3. Hypersistence Optimizer (автор — Vlad Mihalcea): Анализирует Hibernate Event Listeners и конфигурации JPA во время тестов и выбрасывает ассерты при обнаружении типичных антипаттернов (N+1, EAGER-связи, отсутствие batching).

3. Почему замена List на Set для коллекций устраняет MultipleBagFetchException, но создает скрытую угрозу производительности?

Ответ: Hibernate позволяет загружать несколько связей через JOIN FETCH, если коллекции объявлены как Set вместо List:

@OneToMany(mappedBy = "order")
private Set<OrderItem> items;

@OneToMany(mappedBy = "order")
private Set<Payment> payments;
  • С формальной точки зрения Hibernate компилирует запрос с несколькими JOIN FETCH, так как Set устраняет неопределенность порядка элементов (Bag).
  • Скрытая катастрофа (Cartesian Product): Если у заказа 20 позиций и 20 платежей, база вернет $20 \times 20 = 400$ строк. Если коллекций три по 20 элементов — вернется $20 \times 20 \times 20 = 8000$ строк на один заказ!
  • Декартово умножение раздувает сетевой трафик между БД и бэкендом до сотен мегабайт и заставляет процессор тратить секунды на вычисление hashCode() и equals() для заполнения HashSet.
  • Вывод: Использование Set для обхода MultipleBagFetchException — грубый антипаттерн; коллекции следует разделять на независимые запросы.

4. В чем разница в поведении jakarta.persistence.fetchgraph и jakarta.persistence.loadgraph?

Ответ: Оба хинта передаются при выполнении запроса через EntityGraph, но они по-разному трактуют атрибуты сущности, не включенные в граф:

  • fetchgraph (Строгий режим): Атрибуты, явно упомянутые в графе, загружаются как EAGER (через JOIN). Все остальные атрибуты сущности принудительно трактуются как LAZY, даже если в аннотации над полем жестко прописано fetch = FetchType.EAGER.
  • loadgraph (Мягкий режим): Атрибуты, явно упомянутые в графе, загружаются как EAGER. Все остальные атрибуты сохраняют тот режим загрузки, который указан у них в Entity (то есть поля с EAGER все равно загрузятся жадно, а поля с LAZY останутся ленивыми).

🎯 Шпаргалка для интервью

30-секундный ответ (Elevator Pitch)

«Проблема N+1 возникает в ORM, когда для выборки $N$ родительских сущностей выполняется 1 исходный запрос, а затем еще $N$ дополнительных запросов для вычитки связанных данных при обращении к неинициализированным прокси-объектам. Главные способы решения: использование JOIN FETCH в JPQL или @EntityGraph для объединения в один SQL-запрос, либо включение @BatchSize / default_batch_fetch_size для группировки запросов через IN (?, ?, ...). Для задач только на чтение лучшим паттерном является использование DTO-проекций. Критическая ловушка: использование JOIN FETCH с пагинацией приводит к выгрузке всей таблицы в память JVM (HHH000104), поэтому пагинация коллекций должна выполняться в два шага либо через пакетную выборку».

Ключевые метрики для Production-мониторинга

  • hibernate.stat.query_execution_count: общее количество выполненных SQL-запросов на один HTTP-запрос (норма в REST API: 1–5 запросов; если сотни — N+1).
  • HikariCP: PendingConnections: очередь ожидания соединений из-за их быстрой утилизации лавиной N+1 запросов.
  • Метрики p6spy / datasource-proxy по длине сессий и количеству обращений к базе.

Красные флаги (Чего говорить НЕЛЬЗЯ)

  • ❌ «Чтобы не было N+1, я всегда ставлю fetch = FetchType.EAGER» — Это фундаментальная ошибка: EAGER не убирает N+1 в JPQL, но гарантирует, что запросы будут выполняться всегда, даже когда данные не нужны.
  • ❌ «JOIN FETCH можно безопасно использовать везде, даже с Pageable» — Приводит к выкачиванию всей базы в память приложения (HHH000104) и OutOfMemoryError.
  • ❌ «Чтобы загрузить несколько коллекций сразу, я просто заменяю List на Set» — Это приводит к взрывному декартовому произведению (Cartesian Product) и деградации производительности.
  • ❌ «Проблему N+1 можно полностью решить только индексами в базе данных» — Индексы ускоряют единичный запрос, но не уменьшают количество сетевых round-trips от ORM к БД.

Связанные темы