Проблема N+1 в Hibernate на собеседовании
Назвать проблему мало — дальше спрашивают, чем её чинить и что сломается от выбранного способа. Разбираем join fetch, entity graph и batch fetching.
Про N+1 спрашивают, когда на собеседовании заходит речь про ORM, и определение назвать легко: база получает N плюс один запрос там, где хватило бы одного. Проблема в том, что после определения разговор может продолжиться. Дальше могут спросить, откуда лишние запросы берутся, как вы их заметите в работающем приложении и, самое неприятное, что сломается от того способа лечения, который вы предложили. Ниже все три уточняющих.
Всё, что сказано о поведении, относится к Hibernate 6.6 и Jakarta Persistence 3.2.
Откуда берутся лишние запросы §
Лишние запросы — не ошибка в джойнах, а прямое следствие того, как устроена ленивая загрузка. Связи по умолчанию ленивые, и Hibernate кладёт в поле прокси: заглушку на месте реального объекта, которая знает, как себя догрузить, но пока пуста. Пока к прокси не обращаются, он ничего не делает. Как только код читает поле, прокси идёт в базу.
Отсюда описание, которое точнее определения: N+1 — симптом того, что объекты инициализируются по требованию, а требование возникает в цикле. Загрузили список из ста заказов — один запрос. Прошлись по списку и у каждого спросили покупателя — сто запросов, по одному на итерацию.
Из этого механизма выводится наблюдение: запрос уходит при обращении к любому полю связанной сущности, кроме идентификатора. Причина в том, где лежит внешний ключ. Значение идентификатора уже есть в строке родительской таблицы, и прокси может отдать его, не ходя в базу; имя, дата и всё остальное живут в другой таблице, и за ними приходится идти.
Оговорка: это верно, когда внешний ключ хранится в родительской таблице. На связи «многие ко многим» через таблицу-связку идентификатор так же бесплатно взять неоткуда.
Как увидеть проблему §
Обнаружить N+1 сложнее, чем обычное узкое место: каждый отдельный запрос быстрый. Мониторинг медленных запросов такую картину не покажет: сто запросов по три миллисекунды не попадут ни в один порог, а в сумме дадут треть секунды на ровном месте.
Работает подход, который считает запросы. Автор одного из разборов этой проблемы описывает его так: для каждого метода задаётся ожидаемое число обращений к базе, и всё, что сверх него, считается дефектом. Проверка при этом становится обычным тестом: она не измеряет время и потому не зависит ни от машины, ни от объёма данных.
Второй способ — включить показ SQL и посмотреть на лог. Он грубее, зато не требует ничего, кроме одной настройки.
Полезно назвать и признак, по которому N+1 отличается от просто медленного запроса: количество запросов растёт вместе с количеством строк в выборке. Это проверяется одним экспериментом: загрузить десять записей вместо ста и посмотреть, изменилось ли число обращений в десять раз.
Четыре способа лечения §
Способов починки в Hibernate четыре, и они решают задачу по-разному.
Join fetch. Явное указание в запросе подтянуть связь тем же джойном. Один запрос, и всё приезжает сразу. Самый прямой способ, и именно его называют решением почти все материалы по теме. У него есть цена, о ней следующая секция.
Entity graph. Декларативное описание того, какие связи должны быть загружены; задаётся отдельно от запроса и применяется к нему. Результат тот же, что у join fetch, но стратегия выборки перестаёт быть частью текста запроса: один и тот же запрос можно выполнить с разными графами. EntityGraph входит в контракт Jakarta Persistence.
Batch fetching. Hibernate догружает связи пачками заданного размера. Запросов остаётся больше одного, но сто превращаются, скажем, в десять. Настраивается размером пачки на связи или сущности.
Subselect fetching. Связи для всех загруженных родителей догружаются одним отдельным запросом, использующим подзапрос исходной выборки. Итого два запроса: один за родителями, один за всеми их коллекциями сразу.
Критерий выбора формулируется коротко: join fetch и entity graph убирают запросы, объединяя данные в один результат; batch и subselect оставляют отдельный запрос, но перестают умножать его на число строк. Первая пара выигрывает по числу обращений, вторая — по форме результата.
Чем платит join fetch §
Присоединение коллекции джойном меняет форму результата. Одна строка родителя, у которого пять элементов в коллекции, превращается в пять строк результата: база возвращает декартово произведение. Сущность потом соберётся правильно, но строк в наборе будет больше, чем записей.
Из этого следует конфликт, о котором документация Hibernate предупреждает прямо: join fetch коллекции плохо совместим с постраничной выборкой. Ограничение «взять двадцать штук начиная с сорок первой» применяется к строкам результата, а строк больше, чем сущностей, и страница получается не той, которую ожидали. Для этого случая документация рекомендует batch или subselect.
Отсюда практический вывод: если в выборке есть пагинация, join fetch коллекции не подходит. Ограничение касается именно коллекций: присоединение связи «многие к одному» строки не размножает и с пагинацией уживается.
Обратная сторона той же ленивости стоит того, чтобы её назвать: если недогрузить связь и обратиться к ней после закрытия контекста персистентности, вы получите LazyInitializationException. N+1 и это исключение — две крайности одной настройки: в первом случае данные догружаются слишком часто, во втором попытка догрузить приходит слишком поздно.
Проверьте себя
Сначала проговорите ответ, потом сверьтесь с текстом.
Что такое проблема N+1?
Что проверяют: объясняете вы механизм или воспроизводите определение. Определение описывает симптом (сколько запросов ушло), механизм описывает причину (почему они ушли именно тогда).
Частая ошибка: сказать «Hibernate делает много запросов вместо одного» и остановиться. Из такой формулировки не следует ни то, как проблему найти, ни то, почему она вообще возникает.
Как вы обнаружите N+1 в работающем приложении?
Что проверяют: понимаете ли вы, что проблема не в длительности отдельного запроса, а в их количестве.
Частая ошибка: предложить мониторинг медленных запросов или профилирование базы. Такую картину они не показывают: с точки зрения базы приходят сто корректных быстрых запросов, и жаловаться ей не на что.
Чем чинят N+1?
Что проверяют: знаете ли вы больше одного способа и можете ли объяснить, чем они отличаются между собой.
Частая ошибка: назвать join fetch единственным решением. Это самый известный способ, но не всегда применимый; об этом следующий вопрос.
Что произойдёт, если добавить к join fetch постраничную выборку?
Что проверяют: понимаете ли вы, что джойн коллекции меняет число строк в результате. Без этого ответ про пагинацию не выводится.
Частая ошибка: ответить «ничего, всё будет работать». Работать будет, но страница окажется другой, и такой дефект тихо отдаёт неверные данные без всякой ошибки.
Проверьте эти темы вслух
В тренажёре вы отвечаете голосом, а разбор показывает, где ответ просел.