Проблема N plus 1 Select Hibernate
N+1 возникает, когда один запрос получает список из N сущностей, а доступ к связи запускает отдельный запрос для каждой сущности. Вместо одной согласованной операции приложение делает серию сетевых обращений к БД.
Модель
Сохраним знакомый пример: темы и комментарии.
@Entity
class Topic {
@Id
private Long id;
private String title;
@OneToMany(mappedBy = "topic", fetch = FetchType.LAZY)
private List<Comment> comments = new ArrayList<>();
}
@Entity
class Comment {
@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Topic topic;
private String text;
}List<Topic> topics = entityManager.createQuery(
"select t from Topic t order by t.id", Topic.class)
.getResultList(); // 1 SELECT
for (Topic topic : topics) {
System.out.println(topic.getComments().size()); // до N SELECT
}LAZY не создаёт проблему само по себе. Оно откладывает загрузку, а N+1 появляется из-за шаблона доступа к данным. Для many-to-one число дополнительных запросов иногда меньше N благодаря persistence context, если несколько строк ссылаются на один объект, но проблема серии запросов остаётся.
Почему это дорого
Даже маленький SQL требует:
- взять соединение из пула;
- отправить команду по сети;
- распарсить и выполнить запрос;
- передать результат;
- собрать сущности.
Сто быстрых запросов часто медленнее одного или нескольких заранее спланированных. При росте данных дефект проявляется скачком latency и загрузкой пула.
Как обнаружить
- Включить SQL-логирование только в контролируемой среде.
- Смотреть число запросов на один use case, а не только длительность отдельного SQL.
- Использовать Hibernate statistics, datasource-proxy или метрики драйвера.
- Добавить интеграционный тест с реалистичным количеством строк и ограничением числа запросов.
Тест на двух сущностях может пройти незаметно. Нужен набор, при котором зависимость количества запросов от N видна.
join fetch
List<Topic> topics = entityManager.createQuery("""
select distinct t
from Topic t
left join fetch t.comments
order by t.id
""", Topic.class)
.getResultList();Связь загружается в том же запросе. distinct помогает убрать повторяющиеся root-сущности в объектном результате. Он не отменяет того, что SQL возвращает строку на каждую пару topic-comment.
Fetch join коллекции нельзя бездумно сочетать с пагинацией: лимит применяется к строкам join, а не к уникальным темам.
Entity Graph
@EntityGraph(attributePaths = "comments")
@Query("select t from Topic t where t.id in :ids")
List<Topic> findWithComments(Collection<Long> ids);Entity Graph удобен как переиспользуемый план загрузки, но не обещает один SQL. См. Entity Graph.
Batch и subselect
Batch fetching загружает несколько ленивых связей одним IN (...), уменьшая N запросов до нескольких пачек. FetchMode.SUBSELECT может загрузить коллекции для уже выбранных parent-сущностей одним дополнительным запросом.
Это хорошие варианты, когда join слишком раздувает результат. Размер batch проверяют на данных: слишком большой IN тоже имеет цену.
Проекция
Если экрану нужны только название темы и количество комментариев, сущности с коллекциями не нужны:
select new TopicSummary(t.id, t.title, count(c.id))
from Topic t
left join t.comments c
group by t.id, t.titleDTO-проекция часто является самым точным решением для чтения.
На собеседовании
Поможет ли FetchType.EAGER? Не гарантированно. Провайдер может выполнить дополнительные запросы, а глобальный eager заставит загружать связь даже там, где она не нужна.
Как выбрать решение? По форме результата: join fetch для ограниченного графа, batch/subselect для больших коллекций, projection для read model.
Почему Open Session in View маскирует проблему? Lazy-запросы продолжают выполняться во view/controller, поэтому код работает, но количество SQL становится неявным.
Связанные заметки: Hibernate, Fetch type Hibernate, SQL оценка производительности, Проблема декартова произведения Cartesian product problem в Hibernate.