Проблема декартова произведения 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-строк. Надёжная схема:
- запросить страницу ids без to-many join;
- вторым или несколькими запросами загрузить нужные связи по этим ids;
- восстановить порядок страницы в приложении.
Как проверить
- посмотреть фактический 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.