Перейти к содержанию

Ошибки 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 page = em.createQuery( "select c from Child c where c.parent.id = :pid order by c.id", Child.class) .setParameter("pid", parentId) .setFirstResult(offset) .setMaxResults(limit) .getResultList();

long total = em.createQuery( "select count(c) from Child c where c.parent.id = :pid", Long.class) .setParameter("pid", parentId) .getSingleResult();

Пример для детей группы родителей текущей страницы (без пагинации детей):

List all = em.createQuery( "select c from Child c where c.parent.id in :ids order by c.parent.id, c.id", Child.class) .setParameter("ids", parentIds) .getResultList(); // потом группируем в Map>

Вариант 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’им коллекции: используем двухшаговую загрузку + отдельные запросы на детей/батчи.