Почему запросы в цикле — плохая практика
Что проверяют
Знаете ли вы цену обращения к базе и узнаёте ли N+1 в чужом коде. Вопрос почти всегда про Hibernate: в цикле по сущностям никто не пишет select руками, его порождает обращение к ленивой связи.
Как отвечать
Главная причина не в самой базе, а в обходе по сети. Один запрос это отправка, разбор на сервере, выполнение, обратный путь и разбор результата. Выполнение может занимать доли миллисекунды, а обход стоит столько же или больше, и сотня итераций превращается в сотню обходов, которые не распараллеливаются: соединение синхронное, следующий запрос ждёт предыдущего. Плюс каждый запрос отдельно проходит планирование и занимает соединение из пула.
Как выглядит в Java:
for (Order o : orders) {
o.getCustomer().getName(); // по одному select на заказ
}Лечится загрузкой пачкой: join fetch в JPQL, @EntityGraph на методе репозитория, @BatchSize или hibernate.default_batch_fetch_size, чтобы связь добиралась через in (...). Для записи то же самое: addBatch и executeBatch у JDBC, hibernate.jdbc.batch_size вместе с сортировкой вставок, а для MySQL ещё и rewriteBatchedStatements=true, иначе драйвер всё равно отправит запросы по одному.
Хорошая оговорка: цикл допустим, когда итераций единицы или когда пачка не влезает в один запрос.
Где спотыкаются
Заменяют цикл запросов одним in на весь список и упираются в другое: у ряда СУБД есть предел на число элементов, а план кэшируется отдельно под каждую длину списка. Поэтому пачки режут на куски фиксированного размера.
Второе: join fetch сразу по двум коллекциям даёт декартово произведение, а вместе с постраничностью заставляет Hibernate вытянуть всё в память и резать уже там, о чём он честно предупреждает в логе.
Третье: отвечают «включу кэш». Кэш второго уровня не спасает от N+1 на первом проходе и добавляет свои сложности с инвалидацией. Сначала правится количество запросов, кэш это следующий шаг.