Ошибки JPA/Hibernate¶
@Entity
class Parent {
@OneToMany // <-- НЕТ mappedBy
private List<Child> children = new ArrayList<>();
}
@Entity class Child { @Id @GeneratedValue Long id; }
тут hibernate создаст промежтучную таблицу
если добавить @JoinColumn(name = "parent_id") -- то Hibernate положит FK в таблицу child
1 причины проблем двустороннеих связей
Двусторонняя связь = две Java-стороны, но один FK в БД. Владелец = сторона с FK (@ManyToOne или @OneToOne с @JoinColumn). Обратная = mappedBy. Если держать стороны несинхронно, Hibernate будет делать лишние UPDATE/INSERT/DELETE или ловить ошибки целостности.
Решение — инвариант + хелперы:
@Entity
class Parent {
@OneToMany(mappedBy = "parent", cascade = ALL, orphanRemoval = true)
private List<Child> children = new ArrayList<>();
public void addChild(Child c) {
if (c == null || children.contains(c)) return;
children.add(c);
c.setParent(this); // ← синхронизация владельца
}
public void removeChild(Child c) {
if (c == null || !children.remove(c)) return;
c.setParent(null); // ← снимем FK корректно
}
}
@Entity
class Child {
@ManyToOne(fetch = LAZY, optional = false)
@JoinColumn(name = "parent_id", nullable = false)
private Parent parent;
public void setParent(Parent p) { this.parent = p; }
}
Правило: меняем связь только через addChild/removeChild (или строго симметричные сеттеры на обеих сторонах).
2 orphanRemoval N+1 и жирные запросы Фикс: для to-one — EntityGraph/join для to-many — отдельные запросы/батчи
3 «Заменил всю коллекцию» → «потерял детей» Симптом: parent.setChildren(new ArrayList<>(...)) → Hibernate решил удалить «старых сирот». Причина: при orphanRemoval=true снятая ссылка = DELETE в БД. Фикс: делай дифф: аккуратно removeChild(...) для ушедших и addChild(...) для новых.
4 equals/hashCode ломают коллекции Симптом: странные дубликаты/исчезновения в Set/List. Причина: нестабильный equals (по полям, которые меняются) или рекурсия через связь. Фикс: делай equals/hashCode по устойчивому бизнес-ключу или по id (после присвоения).
5 Каскады «сносят полмира» Симптом: удаляешь ребёнка — удалился родитель/соседи. Причина: cascade=REMOVE на @ManyToOne (вверх по графу) или на связи, где это не владение. Фикс: на @ManyToOne обычно без REMOVE. На коллекции в агрегате — PERSIST, MERGE (+ REMOVE и orphanRemoval=true только если это действительно «дети-владение»).
Почему to-many «опасно» в списках: развёрнуто
Речь о том, чтобы в одном запросе попытаться загрузить страницу родителей вместе с их большими коллекциями children — через EntityGraph (включив коллекцию) или JOIN FETCH.
3.1. «Размножение» строк (дубликаты родителя)
SQL-уровень выдаёт по строке на каждую комбинацию Parent × Child. Если у Parent 1 000 детей, то одна строка родителя превратится в 1 000 строк результата. Для 100 родителей это уже 100 000 строк. Даже если ORM «соберёт» это обратно в 100 объектов Parent, память и сеть уже съедены этими 100 000 строками. Любой дополнительный JOIN → риск картезианского взрыва.
3.2. Пагинация ломается
JPA/Hibernate не допускают корректной пагинации запросов с fetch join коллекций:
Лимит/офсет применяются к сырым SQL-строкам, а не к уникальным родителям.
В Hibernate это либо запрещено (исключение), либо приводит к неверным страницам (первые N строк после перемножения, а не первые N родителей).
С EntityGraph, если провайдер решит делать внутренний join fetch коллекции — ровно те же проблемы.
Итог: ты либо получаешь исключение, либо неправильную страницу, либо утопаешь в дубликатах и памяти.
3.3. Почему EntityGraph не спасает
EntityGraph — это план загрузки, а не «умная пагинация детей». Он не умеет «подгрузи только 50 детей у каждого родителя». Если коллекция включена в граф, провайдер старается загрузить её полностью (для каждого выбранного родителя). Если у кого-то детей миллион — граф действительно попытается вытащить миллион строк.
3.4. Что делать «правильно» на списочных экранах Вариант A — двухшаговая загрузка (рекомендуется; у тебя уже так сделано)
Запрос №1: выбираем только id родителей по фильтрам/сортировкам с offset/limit.
Запрос №2: по пачке id грузим родителей с to-one ссылками (через маленький EntityGraph или join), без коллекций.
Коллекции (детей) — подгружаем отдельными запросами:
либо «по требованию» (когда пользователь раскрывает строку),
либо пачкой по IN (:parentIds) (если нужно «все дети всех родителей этой страницы», но будь уверен в размере выборки).
Пример для детей конкретного родителя с пагинацией:
List
long total = em.createQuery( "select count(c) from Child c where c.parent.id = :pid", Long.class) .setParameter("pid", parentId) .getSingleResult();
Пример для детей группы родителей текущей страницы (без пагинации детей):
List
Вариант B — тюнинг ленивой догрузки (когда не хочется писать отдельные запросы)
@BatchSize(size = 50) на коллекции/классе Child — Hibernate будет забирать детей партиями, резко снижая N+1.
@Fetch(SUBSELECT) на коллекции — при первом обращении к parent.getChildren() у набора уже загруженных родителей Hibernate выполнит один дополнительный запрос на всех.
Но это всё равно не решит проблему «миллиона детей» — лишь снизит количество запросов. Объём данных останется большим.
Вариант C — DTO-проекции вместо сущностей
Для «таблички» часто достаточно не сущностей, а плоского набора колонок (select new ...). Так можно сделать один аккуратный JOIN с ровно теми полями, которые нужны для экрана, без графов/коллекций. Это самый «лёгкий» по памяти вариант.
3.5. TL;DR по спискам
Не включай коллекции в EntityGraph/join fetch на списочных экранах.
Грузи родителей постранично, to-one — через граф/джойны, детей — отдельными запросами (пагинируемо).
Для производительности — @BatchSize/@Fetch(SUBSELECT)/DTO, индексы на FK.
Если всё же нужно «сразу всех детей» — делай это осознанно вторым запросом по IN (:ids) и будь уверен, что объём подъёмный.
Итоговая памятка
Для One-to-Many нормальная схема — FK в child.parent_id (двусторонняя связь с mappedBy).
Односторонняя @OneToMany без mappedBy создаёт лишнюю join-таблицу (портируемо, но тяжело).
Hibernate позволяет одностороннюю @OneToMany без join-таблицы через @JoinColumn — но это vendor-specific и с нюансами.
В списках никогда не fetch’им коллекции: используем двухшаговую загрузку + отдельные запросы на детей/батчи.