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

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

  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 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-стиле