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.
Почему это происходит именно с ленивой загрузкой
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— они считают запросы в тестах и падают, если лимит превышен.
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, а не только «чинить руками, когда заметили».