Проблема декартова произведения Cartesian product problem в Hibernate

После исправления N+1 легко получить противоположную проблему: один SQL с несколькими to-many join возвращает слишком много строк. Hibernate собирает из них правильный объектный граф, но БД, сеть и JVM уже обработали лишнее умножение.

Знакомая модель

У публикации есть изображения и теги:

@Entity
class Post {
    @Id
    private Long id;
 
    @OneToMany(mappedBy = "post")
    private List<PostImage> images;
 
    @ManyToMany
    private Set<Tag> tags;
}

Попытка загрузить обе коллекции:

select distinct p
from Post p
left join fetch p.images
left join fetch p.tags
where p.id = :id

Если у поста 8 изображений и 12 тегов, SQL возвращает до 8 × 12 = 96 строк для одного поста. Колонки поста и связей повторяются. Для нескольких постов число строк равно сумме произведений размеров коллекций.

distinct может убрать дубликаты Post из Java-результата, но не обязан уменьшить объём переданных строк: комбинации image-tag различаются.

MultipleBagFetchException

Bag — неупорядоченная коллекция с возможными дубликатами, которую Hibernate часто представляет как List без индексной колонки. Одновременный fetch join двух bag-коллекций неоднозначен, поэтому Hibernate может выбросить MultipleBagFetchException.

Замена List на Set иногда убирает само ограничение, но не математическое умножение строк. Это не самостоятельное исправление производительности.

Основное решение: разделить загрузку

Post post = entityManager.createQuery("""
        select distinct p
        from Post p
        left join fetch p.images
        where p.id = :id
        """, Post.class)
    .setParameter("id", id)
    .getSingleResult();
 
entityManager.createQuery("""
        select distinct p
        from Post p
        left join fetch p.tags
        where p.id = :id
        """, Post.class)
    .setParameter("id", id)
    .getSingleResult();

В одном persistence context Hibernate узнаёт тот же Post по идентификатору и дополняет его второй коллекцией. Получается два компактных результата: 8 + 12 строк вместо 8 × 12.

Для списка ids применяют те же два запроса с where p.id in :ids.

Другие стратегии

  • batch fetching — загрузить коллекции для группы parent ids пачками;
  • FetchMode.SUBSELECT — одна дополнительная выборка на коллекцию для ранее найденных родителей;
  • DTO/projection — сразу вернуть плоскую read model;
  • отдельные запросы агрегатов, если нужны только counts;
  • ограничить граф данных конкретным use case.

Выбор «один SQL всегда лучше» неверен. Важны число round trips, количество строк, ширина строки, память и сложность сборки результата.

Пагинация

Пагинировать запрос с fetch join коллекции опасно: одна root-сущность занимает несколько SQL-строк. Надёжная схема:

  1. запросить страницу ids без to-many join;
  2. вторым или несколькими запросами загрузить нужные связи по этим ids;
  3. восстановить порядок страницы в приложении.

Как проверить

  • посмотреть фактический SQL и execution plan;
  • сравнить число root-сущностей и SQL-строк;
  • измерить переданный объём и время materialization;
  • прогнать данные с большими коллекциями, а не только happy path;
  • проверить число запросов, чтобы исправление не вернуло N+1.

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

Почему ORM не может просто убрать повторения раньше? Повторения возникают в реляционном результате join; провайдер получает строки уже после выполнения SQL и затем дедуплицирует объекты.

Поможет ли Set? Может изменить поведение mapping и убрать дубликаты в коллекции, но не устраняет декартово умножение в SQL.

Когда два запроса лучше одного? Когда сумма размеров коллекций значительно меньше их произведения и дополнительный round trip дешевле передачи и сборки раздутого результата.

Связанные заметки: Проблема N plus 1 Select Hibernate, Entity Graph, Анализ производительности ORM систем, Fetch type Hibernate.