Обещание объектно-реляционного отображения красиво: таблицы превращаются в объекты, связи ходят между собой как обычные ссылки, запросы пишутся на языке сущностей. Реальность первого же продакшена выглядит скромнее. Страница со списком из ста заказов открывается три секунды, а журнал базы показывает сотню одинаковых SELECT, отличающихся только значением ключа. Это классическая проблема N+1, и с ней встречался каждый, кто вынес JPA за пределы учебного проекта. Ниже разбор механики явления, способов поймать его до продакшена, четырёх рабочих лекарств и честного ответа, когда ORM стоит оставить в покое.

Hibernate живёт с 2001 года и остаётся самым распространённым воплощением JPA. Версия 6.0 добралась до финального выпуска 6.0.0.Final 31 марта 2022 года с переработанным ядром преобразования запросов, ветка 6.6 в сентябре 2024 года получила статус долгосрочной и до сих пор получает исправления, выпуск 6.6.58 вышел в сентябре 2026 года. Версия 7.0 в марте 2025 года принесла JPA 3.2 и требование Java 17, актуальной осенью 2026 года остаётся линия 7.4 с выпуском 7.4.13, а восьмая версия уже проходит бета-тестирование. Проблема N+1 пережила все смены мажорных номеров: она живёт не в библиотеке, а в модели загрузки, которую выбирает разработчик.

Механика проблемы N+1 запросов на ленивых заместителях и коллекциях

Спецификация JPA задаёт два разных правила по умолчанию. Ссылка @ManyToOne, например заказ на покупателя, инициализируется жадно и грузится вместе с владельцем. Коллекция @OneToMany, например строки заказа, ведёт себя лениво: вместо списка в поле сущности лежит объект-заместитель, который при первом обращении сам ходит в базу. Сами сущности выглядят невинно:

@Entity
public class Order {
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Customer customer;

    @OneToMany(mappedBy = "order")
    private List<OrderLine> lines = new ArrayList<>();
}

Ленивое поле устроено как подстава: вместо настоящей коллекции Hibernate кладёт в поле класс-наследник с перехватчиком, при первом вызове метода он и отправляется в базу. Со стороны код выглядит обычным циклом по обычным объектам, и в этом главный подвох механики. Ленивая настройка на ссылках стоит правильно, но N+1 всё равно приходит, через сам список:

List<Order> orders = orderRepository.findAll();
long total = 0;
for (Order order : orders) {
    total += order.getLines().size();
}

Первый SELECT забирает сто заказов. Дальше цикл, и каждое обращение к getLines() для неинициализированного заместителя это отдельный SELECT строк конкретного заказа. Сто заказов дают сто один запрос. Если внутри строки трогается ссылка на покупателя, серия продолжается: к сотне добавляются запросы по клиентам. При пяти строках в заказе экран стоит шестьсот один заход в базу.

Коварство в том, что каждый запрос по отдельности невинен и укладывается в миллисекунду. Но миллисекунда, умноженная на шестьсот, плюс шестьсот сетевых коммуникаций, плюс часы пиковой нагрузки, складываются в три секунды отклика и забитый пул соединений. Отдельная засада прячется в мелочах: вызов toString() на сущности в журнале трогает коллекции и инициализирует их, а обращение к ленивому полю после закрытия транзакции заканчивается LazyInitializationException, классикой отладки на стыке слоёв.

Как поймать лишние запросы до продакшена через счётчики и статистику

Hibernate умеет считать свои запросы сам, достаточно включить статистику:

spring.jpa.properties.hibernate.generate_statistics=true

Счётчик подготовленных выражений превращается в жёсткую проверку внутри теста:

long before = sessionFactory.getStatistics()
        .getPrepareStatementCount();
orderListService.render();
long executed = sessionFactory.getStatistics()
        .getPrepareStatementCount() - before;
assertEquals(1, executed);

Слой драйвера-обёртки вроде p6spy даёт вторую линию контроля: в журнал печатается каждый реальный SQL с подставленными параметрами и временем выполнения, а соседний инструмент статистики запросов ведёт счётчики по категориям запросов и умеет падать в тестах при превышении лимита. Серии N+1 в таких журналах видны без лупы: одна и та же форма SELECT, повторённая сотни раз с разными ключами.

Порядок действий при подозрении на N+1 в работающем сервисе выглядит так:

  1. включить статистику Hibernate на тестовом стенде и снять счётчик подготовленных выражений до и после проблемного сценария;
  2. прогнать сценарий через p6spy или соседний инструмент статистики и получить таблицу реальных запросов с параметрами и временем каждого;
  3. сгруппировать запросы по форме и найти серии, отличающиеся только значением ключа, это и есть след N+1;
  4. восстановить сценарий в автотесте с жёсткой проверкой допустимого числа запросов;
  5. выбрать лечение для каждой связи по месту: явный fetch, entity graph, батчи или проекция DTO;
  6. оставить проверку числа запросов в непрерывной интеграции, чтобы регрессия не вернулась с первым же рефакторингом.

На продакшене картину дополняет медленный журнал самой базы и метрики по перцентилям времени запросов. Серия из сотни быстрых SELECT часто не попадает в медленный журнал поодиночке, зато отлично видна агрегатами: количество подготовленных выражений на один HTTP-запрос растёт на порядок против нормального экрана.

Отдельно стоит сказать про паттерн открытой сессии во время рендеринга, который в Spring Boot по умолчанию включён флагом spring.jpa.open-in-view=true. Сессия живёт до конца HTTP-запроса, ленивые коллекции инициализируются прямо во время сборки ответа, и LazyInitializationException исчезает из кода. Целостность достигнута, но сама проблема никуда не делась, она переехала в фазу сериализации и замаскировалась. Выключение флага дисциплинирует: ошибка вылезает на этапе разработки, а число запросов честно видно в тестах. Для проверки сценариев на реальной СУБД тесты поднимают контейнер базы через Testcontainers, и счётчик запросов считает то же, что будет считать продакшен.

Явная загрузка через join fetch и entity graph в повседневном коде

Первое лекарство прямое: сказать языку запросов, какие связи грузить сейчас.

@Query("select o from Order o join fetch o.lines where o.status = :status")
List<Order> findByStatus(@Param("status") Status status);

Один SELECT с соединением, строки приезжают вместе с заказами, счётчик запросов успокаивается. У лекарства есть границы, о которых пишут не в каждом руководстве. Соединение размножает родительскую строку: у заказа пять строк, значит в результате пять одинаковых записей самого заказа, лечится ключевым словом DISTINCT или коллекцией Set вместо List. Две коллекции одним join fetch не берут: пять строк и три платежа дают пятнадцать строк на заказ, декартово произведение съедает память. Пагинация вместе с join fetch по коллекции упирается в известное предупреждение HHH000104: нумерация страниц применяется в памяти после выборки, и на больших списках это тихая деградация.

Декларативный брат join fetch:

@EntityGraph(attributePaths = {"lines", "customer"})
List<Order> findByStatus(Status status);

Аннотация над методом репозитория генерирует тот же SQL с соединениями, не требуя писать запрос руками. Для заранее известных сценариев чтения это самый короткий путь: один метод, один проход в базу, никаких заместителей в цикле.

Когда соединять коллекции в один запрос нельзя, а грузить по одной лень, выручает старый приём из двух запросов. Сначала выборка родительских записей с пагинацией и сортировкой, затем один запрос дочерних строк по списку ключей через IN и ручная сборка графа в памяти. Два захода в базу вместо сотни, пагинация остаётся честной, а количество перелитого трафика растёт линейно от числа страниц. На экранах с большой страницей и глубокой вложенностью это часто единственная схема без компромиссов.

Пакетная загрузка батчами как компромисс для недогруженных связей

Когда сценарий чтения неизвестен заранее, join fetch в лоб не напишешь. Здесь работают батчи. Глобальная настройка:

spring.jpa.properties.hibernate.default_batch_fetch_size=16

Механика простая. При первом обращении к неинициализированной коллекции Hibernate собирает другие неинициализированные заместители того же типа, накопившиеся в сессии, и грузит их одним SELECT с перечислением ключей через IN. Сто заказов вместо ста одного запроса дают один плюс семь: серия из запросов по шестнадцать ключей. Точечная настройка @BatchSize(size=50) на конкретной коллекции или сущности даёт то же самое адресно, без глобального эффекта.

Батчи не сокращают число запросов до одного и не спасают от декартовых произведений, зато они работают автоматически везде, включая код, который никто не хотел трогать: чужие сервисы, устаревшие экраны, отчёты, собранные до нынешней команды. Для системы, где N+1 расползлась по десяткам мест, батчи это разумный первый шаг, а точечный join fetch подтягивают следом за счётчиком. Единственная граница метода техническая: у ряда СУБД перечисление IN ограничено сверху, у Oracle это тысяча элементов, поэтому размер батча держат в разумных пределах и не задирают до четырёхзначных значений.

Соседний инструмент, расширение байткода подключаемым плагином сборки, решает смежную задачу: ленивая загрузка отдельных полей сущности, включая тяжёлые колонки вроде больших текстов, без которых живёт большинство запросов.

Жадная загрузка и кэш второго уровня в роли ложных друзей

Соблазн решить проблему радикально, перевесив все связи на жадную загрузку, оборачивается переносом проблемы вместо решения. Жадная связь грузится везде: в автоматически сгенерированных методах findAll, в соседних запросах, в служебных экранах, где коллекция не нужна. Ленивую связь можно догрузить точечным join fetch, а жадную разгрузить для конкретного запроса штатными средствами не выйдет, направление работает только в одну сторону. В результате вместо N+1 по одному экрану приложение получает раздутые выборки по всем экранам сразу.

Кэш второго уровня выглядит вторым спасением и тоже требует аккуратности:

@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
@Entity
public class Country {

Для справочных таблиц, стран, категорий, редко меняющихся сущностей он сокращает трафик к базе заметно и безопасно. Для изменчивых заказов выигрыш съедается инвалидацией: каждая правка должна выкинуть устаревшие копии из всех узлов, кластеризация добавляет протокол обмена, а отладка расхождений кэша входит в список самых мрачных задач. Кэш запросов соблазняет ещё сильнее и подводит ещё незаметнее: инвалидация идёт по таблицам, одна правка в order_lines выбивает из кэша результаты всех запросов, трогающих таблицу.

Где JPA остаётся удобным и какие экраны уводить на проекции и SQL

После разбора ловушек легко забыть, за что JPA любят. Транзакционная доменная логика на сущностях идёт на редкость удобно: изменённое поле фиксируется в базе при коммите без единой строки UPDATE, оптимистичные блокировки через @Version держат конкуренцию изменений, кэш первого уровня естественно играет роль рабочей единицы внутри транзакции. Экраны редактирования одной записи, мастер-детейл формы, служебные операции с одной сущностью остаются территорией ORM без оговорок.

Массовое чтение и отчёты честнее уводить в проекции:

@Query("select new com.shop.OrderSummary(o.id, o.number, o.total) from Order o")
List<OrderSummary> summaries();

Конструкторное выражение собирает плоские объекты одним SELECT без сущностей и заместителей, а сложная аналитика спокойно пишется нативным SQL. Границу между мирами проводит вопрос, в каком виде нужен результат: рабочая сущность с поведением или плоский набор значений для экрана.

N+1 не приговор инструменту, а сигнал, что схема загрузки отстала от задачи чтения. Счётчик запросов в тестах стоит недорого, ловит проблему до продакшена и окупается в первую же неделю. Hibernate в обмен на дисциплину даёт то, ради чего его и выбирают, привычную модель домена и спокойные транзакции. А лавину SELECT, как показывает практика, всегда вызывает не библиотека, а один цикл по списку, написанный между обедом и демо.