Проблема 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 и загрузкой пула.

Как обнаружить

  1. Включить SQL-логирование только в контролируемой среде.
  2. Смотреть число запросов на один use case, а не только длительность отдельного SQL.
  3. Использовать Hibernate statistics, datasource-proxy или метрики драйвера.
  4. Добавить интеграционный тест с реалистичным количеством строк и ограничением числа запросов.

Тест на двух сущностях может пройти незаметно. Нужен набор, при котором зависимость количества запросов от 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.title

DTO-проекция часто является самым точным решением для чтения.

На собеседовании

Поможет ли 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.