← Назад к списку
ТехническаяJava и KotlinMiddle

Что такое проблема N+1 в Hibernate/JPA и как её решать? Как связана ленивая загрузка?

Короткий ответ

  • N+1: один запрос за списком и по запросу на каждую связь
  • Причина — ленивые ассоциации, инициализируемые в цикле
  • LAZY — правильный дефолт, проблема в способе выборки, а не в лени
  • Решения: JOIN FETCH, @EntityGraph, batch fetching
  • LazyInitializationException — обращение к прокси вне открытой сессии
  • Open Session in View маскирует проблему, его лучше выключать
  • Диагностика: лог SQL, p6spy, счётчик запросов в тестах

N+1 возникает при ленивой инициализации связей в цикле и лечится явной стратегией выборки: fetch join, entity graph или батчингом.

Как сказать вслух

пример ответа

N+1 — это когда я загружаю, скажем, сто заказов одним запросом, а потом в цикле обращаюсь к их позициям, и Hibernate делает ещё сто запросов — по одному на заказ. Причина в том, что связи по умолчанию ленивые и инициализируются при первом обращении. Лечится это явной выборкой: джойн-фетч в JPQL, entity graph или батч-загрузкой, когда Hibernate подтягивает связи пачками. Главное — увидеть проблему, поэтому в тестах полезно следить за числом запросов.

Подробный ответ

Основной ответ

При fetch = LAZY (дефолт для @OneToMany и рекомендуемый для @ManyToOne) Hibernate кладёт в поле прокси/ленивую коллекцию. Если выбрать N сущностей и в цикле обратиться к связи каждой, выполнится 1 + N запросов — на больших N это убивает БД. Решения: JOIN FETCH в JPQL (одним запросом; с коллекцией + пагинацией осторожно — Hibernate 6 предупреждает о выборке в память), @EntityGraph для декларативного указания связей, @BatchSize или default_batch_fetch_size (загрузка связей пачками IN-запросами, хорошо как глобальная страховка), DTO-проекции, когда сущности вообще не нужны. LazyInitializationException возникает при обращении к неинициализированному прокси вне транзакции/сессии — правильное лечение не EAGER и не Open Session in View, а выборка нужных данных внутри транзакции.

Ключевые моменты

  • Механика. Ленивыми связями управляет сессия; каждое первое обращение — отдельный SELECT.
  • JOIN FETCH / EntityGraph. Явно говорим, что грузить; разные запросы — разные графы, не один EAGER на все случаи.
  • Batch fetching. default_batch_fetch_size=N превращает N запросов в N/размер пачки — дешёвая глобальная защита.
  • Диагностика. Логирование SQL в dev, p6spy/datasource-proxy, ассерты на количество запросов в интеграционных тестах.

Практический контекст

Самая частая причина «почему страница тормозит» в приложениях на JPA. Интервьюер проверяет: отличает ли кандидат ленивость (хорошо) от N+1 (симптом неправильной выборки), знает ли минусы EAGER (грузится всегда и всем) и Open Session in View (запросы из слоя представления, долгие сессии). Сильный ответ — «стратегию выборки выбирает запрос под конкретный экран, а не маппинг».

Пример кода

// Проблема: 1 запрос за заказами + N за позициями
List<Order> orders = em.createQuery(
        "select o from Order o", Order.class).getResultList();
orders.forEach(o -> o.getItems().size()); // N запросов

// Решение 1: fetch join
List<Order> fixed = em.createQuery(
        "select distinct o from Order o join fetch o.items",
        Order.class).getResultList();

// Решение 2: @EntityGraph в Spring Data
// @EntityGraph(attributePaths = "items")
// List<Order> findAllByStatus(Status s);

Частые ошибки

  • Лечат N+1 переводом связи в EAGER, получая лишние джойны во всех запросах
  • Ставят JOIN FETCH коллекции вместе с пагинацией и получают выборку всей таблицы в память
  • Считают LazyInitializationException поводом включить Open Session in View, а не починить выборку

ИП Кочкин Алексей Сергеевич · ИНН 390509026279 · ОГРНИП 325390000030973 · jiniys2005@yandex.ru