← назад к разделу

Вы запрашиваете 50 заказов и замечаете, что в логах — 51 SQL-запрос. Это и есть проблема N+1: один запрос на список плюс по одному на каждую связанную сущность.

слева — приложение, справа — база; стрелка = один поход в базу 1. ленивая коллекция: сначала список, потом позиции каждого заказа список заказов заказ 1 заказ 2 заказ 3 база orders order_items SELECT … FROM orders SELECT … WHERE order_id = 1 SELECT … WHERE order_id = 2 SELECT … WHERE order_id = 3 запросов: 1 запросов: 2 запросов: 3 запросов: 4 2. JOIN FETCH или @BatchSize: те же позиции — одной пачкой3 заказаSELECT … WHERE order_id IN (1,2,3)базазапросов: 2

Данных в обоих случаях едет одинаково — разное только число обращений к базе. На трёх заказах ленивая коллекция даёт четыре запроса, на пятидесяти — пятьдесят один. 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

Для пагинации нужны другие подходы (см. ниже).

строка 1 заказ 41, позиция A строка 2 заказ 41, позиция B строка 3 заказ 41, позиция C строка 4 заказ 42, позиция D

После соединения строк больше, чем заказов: 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, и поддержать пагинацию по корневой сущности, используют двухшаговый подход:

  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();
  1. Вторым — загружаем полные сущности с 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 и без загрузки всего в память.

SELECT o.id страница по LIMIT первый запрос список id 41, 42, 43 второй запрос JOIN FETCH IN :ids без LIMIT заказы с позициями всего два запроса

Двухшаговая пагинация: первый запрос отбирает идентификаторы страницы, второй забирает по ним заказы вместе с позициями.

Та же грабля касается и @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.

Что почитать дальше