Вы запрашиваете 50 заказов и замечаете, что в логах — 51 SQL-запрос. Это и есть проблема N+1: один запрос на список плюс по одному на каждую связанную сущность.
Откуда берётся N+1
Hibernate по умолчанию загружает связанные коллекции лениво (FetchType.LAZY). Когда вы итерируете по результатам и обращаетесь к полю-коллекции, Hibernate идёт в базу за каждым элементом отдельно.
List<Order> orders = em.createQuery("SELECT o FROM Order o", Order.class)
.getResultList(); // 1 запрос: SELECT * FROM orders
for (Order order : orders) {
// здесь Hibernate делает SELECT * FROM order_items WHERE order_id = ?
// для каждого order — итого N запросов
System.out.println(order.getItems().size());
}
Итого: 1 + N запросов вместо одного.
Похожая ситуация возникает и с @ManyToOne-полями, если к ним обращаются внутри цикла.
Как увидеть проблему
Первый шаг — включить логирование SQL. В application.yml:
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
После этого в консоли будут видны все запросы. Если их число растёт пропорционально числу строк в выборке — перед вами N+1.
Для точного подсчёта в тестах удобен инструмент datasource-proxy или библиотека p6spy — они перехватывают JDBC и считают запросы.
Решение 1: JOIN FETCH в JPQL
Самый прямолинейный способ — загрузить связь одним запросом через JOIN FETCH:
List<Order> orders = em.createQuery(
"SELECT DISTINCT o FROM Order o JOIN FETCH o.items",
Order.class
).getResultList();
JOIN FETCH говорит Hibernate: «подгрузи коллекцию items тем же запросом». В SQL это превращается в INNER JOIN с полными данными обеих таблиц.
DISTINCT здесь нужен, чтобы убрать дубли на уровне Java-объектов: SQL возвращает строку на каждую пару (order, item), и без DISTINCT в списке будет несколько копий одного Order.
Ограничение: JOIN FETCH нельзя использовать с пагинацией (setFirstResult/setMaxResults) на коллекциях @OneToMany/@ManyToMany. Hibernate вынужден загружать всё в память и нарезать там — при больших данных это серьёзная проблема. В лог при этом попадёт предупреждение:
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
Для пагинации нужны другие подходы (см. ниже).
Решение 2: @EntityGraph
@EntityGraph — декларативный способ задать, что нужно загрузить вместе с сущностью. Удобен в Spring Data репозиториях:
@EntityGraph(attributePaths = {"items", "items.product"})
List<Order> findByStatus(OrderStatus status);
В SQL это тоже JOIN FETCH, но логика указывается на уровне метода репозитория, а не в JPQL-строке — удобнее переиспользовать и читать.
Подробнее о репозиториях Spring Data — в статье Spring Data JPA.
Решение 3: пакетная загрузка (batch fetching)
Если JOIN FETCH неудобен (или нужна пагинация), можно попросить Hibernate загружать ленивые коллекции пачками. Тогда вместо N отдельных запросов он выдаст SELECT ... WHERE order_id IN (?, ?, ?, ...) на группу.
Глобальная настройка — в application.yml:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 25
Аннотация на конкретной коллекции:
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@BatchSize(size = 25)
private List<OrderItem> items;
При размере выборки 50 и batch_fetch_size = 25 Hibernate сделает 1 + 2 запроса вместо 1 + 50. Для большинства случаев — хороший баланс между простотой кода и нагрузкой на базу.
@BatchSize хорошо сочетается с пагинацией: страница запрашивается через LIMIT/OFFSET, коллекции подгружаются пакетами.
Решение 4: DTO-проекция
Когда данные нужны только для чтения (экран, API-ответ), можно вообще не загружать сущности — запросить нужные поля сразу в DTO через JPQL:
record OrderSummary(Long id, String customerName, int itemCount) {}
List<OrderSummary> summaries = em.createQuery(
"""
SELECT new com.example.dto.OrderSummary(
o.id,
o.customer.name,
COUNT(i)
)
FROM Order o
LEFT JOIN o.items i
GROUP BY o.id, o.customer.name
""",
OrderSummary.class
).getResultList();
Hibernate выполняет один запрос, Persistence Context не задействован — объекты не отслеживаются, flush не нужен. Хороший выбор для read-heavy эндпоинтов.
Про JPQL и Criteria API подробнее — в статье JPQL и Criteria API.
JOIN FETCH и пагинация: как правильно
Если нужно и избежать N+1, и поддержать пагинацию по корневой сущности, используют двухшаговый подход:
- Первым запросом получаем идентификаторы страницы:
List<Long> ids = em.createQuery(
"SELECT o.id FROM Order o WHERE o.status = :status ORDER BY o.createdAt DESC",
Long.class
).setParameter("status", status)
.setFirstResult(offset)
.setMaxResults(pageSize)
.getResultList();
- Вторым — загружаем полные сущности с
JOIN FETCHпо этим идентификаторам:
List<Order> orders = em.createQuery(
"SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.id IN :ids ORDER BY o.createdAt DESC",
Order.class
).setParameter("ids", ids)
.getResultList();
Два запроса вместо N+1 и без загрузки всего в память.
Когда какой инструмент
| Ситуация | Инструмент |
|---|---|
| Нужна вся сущность, один уровень связи, без пагинации | JOIN FETCH |
| Репозиторий Spring Data, читабельный код | @EntityGraph |
| Пагинация + сущности (несколько связей) | @BatchSize / default_batch_fetch_size |
| Read-only, экономия памяти | DTO-проекция (JPQL new) |
Пагинация + JOIN FETCH вместе | Двухшаговый запрос (id → IN) |
Коротко
- Проблема N+1: один запрос на список + по одному запросу на каждую ленивую связь при итерации.
- Диагностика:
spring.jpa.show-sql=trueилиdatasource-proxyв тестах. JOIN FETCH— самый простой способ; не работает с пагинацией на@OneToMany.@EntityGraph— то же самое, но декларативно на уровне метода репозитория.@BatchSize/default_batch_fetch_size— сокращает N запросов до N/batch, работает с пагинацией.- DTO-проекция — полностью обходит проблему для read-only запросов.
- Пагинация +
JOIN FETCH— двухшаговый запрос по идентификаторам.
Что почитать дальше
- Ленивая и жадная загрузка — как Hibernate решает, когда идти в базу
- JPQL и Criteria API — синтаксис запросов, подзапросы, проекции
- Кэширование в Hibernate — второй уровень кэша как дополнительный инструмент снижения нагрузки
- Spring Data JPA — репозитории,
@EntityGraphи проекции в Spring-стиле