Кейс: N+1 при обходе иерархии

Ситуация

Код собирал данные по иерархии сущностей. Для каждой записи требовалась связь с родителем. Функционально результат был правильным, но с ростом списка Hibernate выполнял всё больше SQL-запросов.

Что показала диагностика

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

1 запрос на основной список
+ N запросов на связи отдельных элементов

Принудительная инициализация не устраняла N+1. Она лишь делала момент загрузки явным.

Мой вклад

Я изменил порядок работы:

  1. получил идентификаторы всех элементов, которые действительно нужны;
  2. одним запросом загрузил набор вместе с родительскими связями;
  3. построил нужные соответствия в памяти;
  4. убрал инициализацию ленивой связи из цикла.

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

Результат

Количество запросов перестало расти линейно вместе с размером обрабатываемого списка. Обход иерархии стал обычной операцией над уже загруженными объектами.

Что я теперь проверяю

  • нет ли обращения к ленивой связи внутри for, stream.map или mapper;
  • можно ли сначала собрать все ключи и загрузить данные пачкой;
  • не создаёт ли сериализация DTO скрытые дополнительные запросы;
  • измеряется ли реальное число SQL-запросов в тесте или логах.

Связанные заметки