Вы запрашиваете 50 заказов и замечаете, что в логах — 51 SQL-запрос. Это и есть проблема N+1: один запрос на список плюс по одному на каждую связанную сущность.
Данных в обоих случаях едет одинаково — разное только число обращений к базе. На трёх заказах ленивая коллекция даёт четыре запроса, на пятидесяти — пятьдесят один. JOIN FETCH и @BatchSize забирают те же позиции одним запросом по списку идентификаторов.
Откуда берётся 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 o FROM Order o LEFT JOIN FETCH o.items",
Order.class
).getResultList();
JOIN FETCH говорит Hibernate: «подгрузи коллекцию items тем же запросом». В SQL это превращается в одно соединение таблиц, откуда приезжают данные и заказа, и его позиций.
Слово LEFT тут не для красоты. Без него соединение будет внутренним, и заказы, у которых ещё нет ни одной позиции, из выборки просто пропадут — молча, без ошибки. Внутреннее соединение здесь — осознанный выбор для случая «пустые мне и не нужны», а не значение по умолчанию.
DISTINCT в старом коде на этом месте — исторический хвост: SQL возвращает строку на каждую пару (order, item), и в Hibernate 5 без него в списке появлялись копии одного и того же Order. Начиная с Hibernate 6 (это Spring Boot 3) дубли убираются сами, а написанный DISTINCT уходит прямо в SQL и заставляет базу делать лишнюю сортировку.
Ограничение: JOIN FETCH нельзя использовать с пагинацией (setFirstResult/setMaxResults) на коллекциях @OneToMany/@ManyToMany. Hibernate вынужден загружать всё в память и нарезать там — при больших данных это серьёзная проблема. В лог при этом попадёт предупреждение:
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
Для пагинации нужны другие подходы (см. ниже).
После соединения строк больше, чем заказов: LIMIT 2 отрезал бы по второй строке и вернул один заказ вместо двух.
Второе ограничение: две коллекции-List в одном запросе Hibernate не потянет. Попросите JOIN FETCH сразу по items и по payments — приложение упадёт с MultipleBagFetchException ещё на разборе запроса. Обходов три: сменить тип коллекций на Set, разбить на два запроса или оставить JOIN FETCH для одной связи, а вторую отдать @BatchSize.
Третье ограничение: там, где две коллекции фетчатся законно, база вернёт их произведение. Десять позиций и пять платежей — это не пятнадцать строк, а пятьдесят: каждая позиция повторится с каждым платежом. Заказ Hibernate из них соберёт правильно, но по проводам проедет в разы больше данных, чем нужно.
Решение 2: @EntityGraph
@EntityGraph — декларативный способ задать, что нужно загрузить вместе с сущностью. Удобен в Spring Data репозиториях:
@EntityGraph(attributePaths = {"items", "items.product"})
List<Order> findByStatus(OrderStatus status);
В SQL это тоже JOIN FETCH, но логика указывается на уровне метода репозитория, а не в JPQL-строке — удобнее переиспользовать и читать.
Решение 3: пакетная загрузка (batch fetching)
Если JOIN FETCH неудобен (или нужна пагинация), можно попросить Hibernate загружать ленивые коллекции пачками. Тогда вместо N отдельных запросов он выдаст на группу один запрос со списком идентификаторов:
SELECT id, order_id, product_id, quantity, unit_price
FROM order_items
WHERE order_id IN (1, 2, 3, 4, 5)
Глобальная настройка — в 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.
Две вещи про эту настройку удивляют при первом взгляде в лог. Первая: IN-список всегда одной ширины — запрос готовится заранее под полный размер пачки, и незанятые места добиваются null. Три идентификатора в списке из двадцати пяти вопросительных знаков — это норма, а не ошибка. Вторая: настройка касается не только коллекций. Точно так же пачками подтягиваются и ToOne-прокси, так что сотня заказов со ссылкой на клиента даст не сотню запросов, а четыре. Арифметика видна на счётчике походов в базу — сначала по одной коллекции, потом пачками:
живой пример
import java.util.ArrayList;
import java.util.List;
public class NPlusOneDemo {
static int queries = 0;
static List<Long> loadOrderIds(int count) {
queries++;
List<Long> ids = new ArrayList<>();
for (long id = 1; id <= count; id++) {
ids.add(id);
}
return ids;
}
static void loadItems(List<Long> orderIds) {
queries++; // один SELECT ... WHERE order_id IN (...)
}
public static void main(String[] args) {
List<Long> ids = loadOrderIds(50);
for (Long id : ids) {
loadItems(List.of(id));
}
System.out.println("по одной коллекции — запросов: " + queries);
queries = 0;
ids = loadOrderIds(50);
for (int from = 0; from < ids.size(); from += 25) {
loadItems(ids.subList(from, Math.min(from + 25, ids.size())));
}
System.out.println("пачками по 25 — запросов: " + queries);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
@BatchSize хорошо сочетается с пагинацией: страница запрашивается через LIMIT/OFFSET, коллекции подгружаются пакетами.
Решение 3б: один подзапрос на все коллекции (SUBSELECT)
Рядом с пакетной загрузкой живёт её родственник, о котором знают меньше. @Fetch(FetchMode.SUBSELECT) заставляет Hibernate загрузить коллекции одним запросом, повторив внутри него условие исходной выборки как подзапрос:
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderLine> lines = new ArrayList<>();
Вместо «запрос на заказы плюс по запросу на каждый» получается «запрос на заказы плюс один запрос на позиции всех этих заказов»: ... where order_id in (select id from orders where ...).
Когда это лучше пакетной загрузки: когда исходная выборка большая и заранее неизвестна по размеру — пакеты по десять дадут десятки запросов, а подзапрос всегда один. Когда хуже: исходный запрос тяжёлый, и его придётся выполнить второй раз внутри подзапроса; а при LIMIT в исходном запросе подзапрос его не повторяет — и подтянутся лишние строки.
Сводка на три механизма выглядит так. JOIN FETCH — когда нужна одна коллекция и выборка небольшая. Пакетная загрузка (@BatchSize) — универсальный вариант по умолчанию, ставится один раз и работает везде. SUBSELECT — когда коллекция нужна для всей большой выборки целиком и она без пагинации.
Решение 4: DTO-проекция
Когда данные нужны только для чтения (экран, API-ответ), можно вообще не загружать сущности — запросить нужные поля сразу в DTO через JPQL. Плоский случай: номер заказа и имя покупателя, без агрегатов:
@Query("SELECT new com.example.OrderRow(o.id, o.customer.name) FROM Order o")
List<OrderRow> rows();
Один запрос с соединением, ни одной сущности в контексте. Сложнее, когда нужна ещё и сумма по строкам заказа:
record OrderSummary(Long id, String customerName, long itemCount) {} // COUNT возвращает long
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 не нужен. Хороший выбор для страниц, которые только читают данные.
И четвёртый механизм, о котором вспоминают реже: кэш второго уровня. Для связи, которая ведёт в маленький редко меняющийся справочник (валюта, статус, категория), N+1 лечится не запросом, а тем, что после первого прогрева запросов не будет вовсе — Hibernate возьмёт объекты из кэша по идентификатору. Работает это только для доступа по идентификатору и только для действительно редко меняющихся данных; подробно — в статье про кэширование. Для остальных случаев кэш не лечит N+1, а маскирует его: под нагрузкой с холодным кэшем всё возвращается.
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 o FROM Order o LEFT JOIN FETCH o.items WHERE o.id IN :ids ORDER BY o.createdAt DESC",
Order.class
).setParameter("ids", ids)
.getResultList();
Два запроса вместо N+1 и без загрузки всего в память.
Двухшаговая пагинация: первый запрос отбирает идентификаторы страницы, второй забирает по ним заказы вместе с позициями.
Та же грабля касается и @EntityGraph: он не отдельный механизм, а тот же жадный join под другим именем, поэтому метод репозитория с графом и Pageable наступает ровно на то же — Hibernate загрузит всё и разложит по страницам в памяти. В таблице «когда какой инструмент» это стоит держать в голове: пагинация плюс коллекция — всегда два шага или пакетная загрузка, независимо от того, чем описана жадность.
И про порядок во втором шаге. Когда идентификаторы страницы уже выбраны, второй запрос (where id in (:ids)) возвращает строки в порядке, который выбрала база, — не в порядке списка. Сортировка по тому же полю, что в первом запросе, спасает только если оно уникально: на равных значениях created_at порядок разъедется, и страница окажется перемешанной. Надёжных вариантов два: досортировать в памяти по позиции идентификатора в списке или добавить в ORDER BY уникальный ключ (order by o.createdAt desc, o.id desc) в оба запроса.
Когда какой инструмент
| Ситуация | Инструмент |
|---|---|
| Нужна вся сущность, один уровень связи, без пагинации | JOIN FETCH |
| Репозиторий Spring Data, читабельный код | @EntityGraph |
| Пагинация + сущности (несколько связей) | @BatchSize / default_batch_fetch_size |
| Read-only, экономия памяти | DTO-проекция (JPQL new) |
Пагинация + JOIN FETCH вместе | Двухшаговый запрос (id → IN) |
Глубже: тест как страж: счётчик запросов и @DataJpaTestрасширенное
N+1 возвращается с каждым новым полем, и ловить его глазами в логах ненадёжно. Надёжно ловит тест, который считает запросы. Hibernate умеет считать сам, если включить статистику:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(properties = "spring.jpa.properties.hibernate.generate_statistics=true")
class OrderRepositoryTest {
@Container @ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");
@Autowired TestEntityManager em;
@Autowired OrderRepository orders;
@Test
void ordersWithItemsLoadInOneQuery() {
Statistics stats = em.getEntityManager().unwrap(Session.class).getSessionFactory().getStatistics();
stats.clear();
em.clear(); // иначе сущности придут из контекста, а не из базы
List<Order> loaded = orders.findAllWithItems();
loaded.forEach(o -> o.getItems().size()); // трогаем связь, как это сделает сервис
assertThat(stats.getPrepareStatementCount()).isEqualTo(1);
}
}
@DataJpaTest поднимает только слой данных, по умолчанию на встроенной базе; Replace.NONE с Testcontainers заставляет тест идти в настоящий PostgreSQL, и это важно: H2 не знает половины диалекта, и запрос, который прошёл в тесте, падает в проде. em.clear() перед проверкой обязателен: без него первый уровень кэша отдаст сущности без единого запроса, и счётчик соврёт в удобную сторону.
Тем же способом проверяют dirty checking: изменить поле, вызвать em.flush(), проверить, что getEntityUpdateCount() равен единице, и что без изменений он ноль. Счётчик запросов держат в тестах на каждый метод репозитория, который возвращает сущности со связями; когда кто-то добавит @OneToMany и забудет JOIN FETCH, упадёт тест, а не прод.
Коротко
- Проблема N+1: один запрос на список + по одному запросу на каждую ленивую связь при итерации.
- Диагностика:
spring.jpa.show-sql=true, а в тестах — счётчик запросов черезdatasource-proxy. LEFT JOIN FETCH— самый простой способ; безLEFTиз выборки пропадут заказы без позиций. Не работает с пагинацией на@OneToMany(в лог падаетHHH90003004) и не берёт две коллекции-Listсразу — этоMultipleBagFetchException.@EntityGraph— то же самое, но декларативно на уровне метода репозитория.@BatchSize/default_batch_fetch_size— сокращает N запросов до N/batch и дружит с пагинацией.- DTO-проекция обходит проблему для чтения, а пагинация с
JOIN FETCHделается двухшаговым запросом по идентификаторам. - N+1 ловит тест со статистикой Hibernate:
@DataJpaTestна Testcontainers,em.clear(), вызов метода, проверкаgetPrepareStatementCount(). - Третий штатный механизм —
@Fetch(FetchMode.SUBSELECT): одна загрузка коллекций на всю выборку, но исходный запрос выполняется второй раз иLIMITне учитывается. @EntityGraphсPageableнаступает на ту же грабл. с пагинацией, чтоJOIN FETCH; во втором шаге порядок восстанавливают по списку идентификаторов или уникальным ключом вORDER BY.
Что почитать дальше
- Ленивая и жадная загрузка — как Hibernate решает, когда идти в базу.
- JPQL и Criteria API — синтаксис запросов, подзапросы, проекции.
- Кэширование в Hibernate — второй уровень кэша как дополнительный инструмент снижения нагрузки.
- Spring Data JPA — репозитории,
@EntityGraphи проекции в Spring-стиле.