ГлавнаяБлог › N+1 в Hibernate: как заметить и как убрать

N+1 в Hibernate: как заметить и как убрать

Что такое N+1

N+1 — это ситуация, когда вместо одного запроса с JOIN Hibernate делает один запрос за родительскими сущностями и потом N дополнительных запросов за связанными данными — по одному на каждую строку. Итого 1 + N запросов вместо одного.

Классика: достаём список заказов, а у каждого заказа лениво подгружается клиент.

List<Order> orders = orderRepository.findAll();
for (Order o : orders) {
    System.out.println(o.getCustomer().getName()); // тут летит отдельный SELECT
}

Если customer помечен как FetchType.LAZY (а это дефолт для @ManyToOne в новых версиях можно переопределить, но для коллекций LAZY — стандарт), Hibernate при первом обращении к getCustomer() шлёт отдельный SELECT в базу. На 100 заказов получаем 101 запрос вместо одного JOIN.

Это как ходить в магазин отдельно за каждым ингредиентом супа, хотя список покупок был у тебя в руках сразу. Один поход с полным списком — и ты сэкономил 10 поездок.

Почему это происходит именно с ленивой загрузкой

LAZY-ассоциация — это proxy-объект. Hibernate не знает заранее, обратятся ли к полю. Он откладывает загрузку до первого вызова геттера. Если обращение происходит в цикле по коллекции — получаем ровно столько запросов, сколько элементов.

С EAGER проблема не решается, а маскируется: Hibernate всё равно может сходить за каждой связью отдельным запросом, если не используется JOIN явно — просто это произойдёт сразу при загрузке родителя, а не при обращении из кода.

Как заметить

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

  • Включить логирование SQL: spring.jpa.show-sql=true и logging.level.org.hibernate.SQL=debug — и посчитать количество запросов на один HTTP-вызов.
  • Использовать Hibernate Statistics (SessionFactory.getStatistics()) — там есть счётчик количества запросов за сессию.
  • Подключить библиотеку типа db-util с QueryCountHolder или расширение datasource-proxy — они считают запросы в тестах и падают, если лимит превышен.
Частая ошибка — искать проблему в коде сервиса, хотя она в маппинге сущностей. Если в DTO-проекции или в цикле логов внезапно тысячи одинаковых по структуре запросов, отличающихся только id — это почти всегда N+1, а не «медленная база».

JOIN FETCH в JPQL

@Query("select o from Order o join fetch o.customer")
List<Order> findAllWithCustomer();

Один запрос вместо N+1. Минус: если джойнить несколько коллекций одновременно, получим декартово произведение строк (умножение результата), поэтому для нескольких @OneToMany джойнить одновременно не стоит — лучше разделить на отдельные запросы или использовать батчинг.

EntityGraph

@EntityGraph(attributePaths = {"customer"})
@Query("select o from Order o")
List<Order> findAllWithGraph();

Декларативный способ сказать Hibernate, что нужно подтянуть сразу. Удобно, когда джойнов много и хочется избежать нагромождения JPQL.

Батчинг вместо джойна

Если джойнить неудобно (например, коллекции), можно включить пакетную подгрузку:

hibernate.default_batch_fetch_size=50

Тогда вместо N отдельных запросов Hibernate сделает несколько запросов вида WHERE id IN (?, ?, ..., ?) пачками по 50. Это не убирает лишние запросы полностью, но превращает N в N/50 — часто этого достаточно.

DTO-проекции

Если сущность нужна только для чтения и без ленивых полей, проще сразу написать проекцию с нужным JOIN и вернуть DTO — тогда вопрос ленивой загрузки не возникает вовсе, потому что нет управляемых сущностей и proxy.

Что говорить на собеседовании

Важно не просто назвать причину, а показать понимание триггера: N+1 возникает не из-за самого LAZY, а из-за обращения к ленивому полю внутри цикла без предзагрузки. Стоит упомянуть конкретные инструменты — JOIN FETCH, EntityGraph, batch fetching — и предупредить про декартово произведение при джойне нескольких коллекций. Плюс — что проблему нужно уметь замечать через логи SQL и Hibernate Statistics, а не только «чинить руками, когда заметили».

Почему EAGER не решает проблему N+1?
EAGER просто переносит момент подгрузки на время загрузки родителя — если Hibernate не использует JOIN, он всё равно делает отдельный запрос на каждую связь, просто сразу, а не лениво по требованию.
Почему нельзя джойнить сразу несколько коллекций через JOIN FETCH?
Это даёт декартово произведение строк — количество строк результата умножается на размеры коллекций, что раздувает выборку и может дать дублирующиеся данные в родительской сущности.

Ещё разборы