Как балансировать между количеством запросов и сложностью join при решении проблемы N+1?
Что проверяют
Умеете ли вы решать N+1 не одним заученным JOIN FETCH, а выбирать инструмент по форме данных и видеть цену каждого. Следом обычно спрашивают про пагинацию вместе с загрузкой коллекции и про то, как вы вообще заметили N+1.
Как отвечать
В Java-стеке разговор идёт про JPA и Hibernate. Инструменты такие:
JOIN FETCHили@EntityGraph: один запрос. Для связей «ко-одному» (@ManyToOne,@OneToOne) почти всегда лучший выбор, строки не размножаются;- fetch одной коллекции: тоже один запрос, но строка родителя повторяется столько раз, сколько у него детей. Hibernate 6 сам убирает дубли корневых сущностей,
DISTINCTдля этого больше не нужен; - batch fetching:
@BatchSize(size = 50)или глобальныйhibernate.default_batch_fetch_size. Вместо N запросов получается 1 + N/50 запросов сIN, и никакого декартова произведения; - DTO-проекция: если данные нужны только для чтения, выбрать нужные колонки одним запросом и не тащить сущности в контекст вовсе.
Правило баланса: связи «ко-одному» подтягивайте через join, одну коллекцию тоже можно, а две и больше коллекций или пагинация означают batch fetching или два шага. Сначала страница идентификаторов с LIMIT, затем второй запрос с JOIN FETCH по этим id. Несколько коротких запросов по индексу часто дешевле одного широкого join, который гонит по сети мегабайты повторяющихся колонок.
Проверяют это на реальном объёме: EXPLAIN ANALYZE, статистика Hibernate через hibernate.generate_statistics, а в тестах подсчёт запросов через datasource-proxy, чтобы регрессию N+1 ловил CI.
Где спотыкаются
Делают JOIN FETCH на две коллекции типа List. Hibernate бросает MultipleBagFetchException, а если заменить их на Set, ошибка уйдёт, но результат станет декартовым произведением.
Совмещают fetch коллекции с setMaxResults. Hibernate выгружает всё и режет страницу в памяти, оставляя в логе предупреждение HHH000104.
Лечат N+1 переходом на FetchType.EAGER. На JPQL-запросах это не помогает: связи всё равно грузятся отдельными запросами, только теперь всегда. При этом @ManyToOne по умолчанию как раз EAGER, и его стоит явно делать LAZY.