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

Ошибки JPA/Hibernate

Типичные ошибки со связями и загрузкой данных: симптом, причина, фикс.


Ошибки связей

Лишняя join-таблица у @OneToMany

@Entity
class Parent {
    @OneToMany   // нет mappedBy
    private List<Child> children = new ArrayList<>();
}

@Entity
class Child { @Id @GeneratedValue Long id; }

Симптом: в схеме появилась промежуточная таблица parent_children, хотя связь «один ко многим».

Причина: однонаправленный @OneToMany без mappedBy и без @JoinColumn. Провайдер не знает, где FK, и хранит связь в join-таблице.

Фикс: - двусторонняя связь: FK в Child (@ManyToOne + @JoinColumn), у Parent - mappedBy = "parent". Это нормальная схема для one-to-many - или @JoinColumn(name = "parent_id") на самом @OneToMany - тогда FK ляжет в таблицу child. Нюанс: Hibernate сначала вставит ребёнка, а FK проставит отдельным UPDATE

Note

В исходной версии @JoinColumn на однонаправленном @OneToMany назван «vendor-specific» возможностью Hibernate. Это стандарт JPA 2.0, работает с любым провайдером.


Стороны двусторонней связи рассинхронизированы

Симптом: лишние UPDATE, INSERT или DELETE, ошибки целостности, объект в коллекции родителя есть, а в БД связи нет.

Причина: двусторонняя связь - это две Java-стороны, но один FK в БД. В БД пишет только владелец - сторона с FK (@ManyToOne или @OneToOne с @JoinColumn). Обратная сторона (mappedBy) в БД не пишет. Добавил ребёнка в коллекцию, но не проставил ему parent - FK останется пустым.

Фикс: инвариант и хелперы. Связь меняется только через addChild и removeChild или строго симметричные сеттеры на обеих сторонах.

public void addChild(Child c) {
    if (c == null || children.contains(c)) return;   // защита от null и повторного добавления
    children.add(c);
    c.setParent(this);                               // синхронизировать владельца
}

public void removeChild(Child c) {
    if (c == null || !children.remove(c)) return;
    c.setParent(null);                               // корректно снять FK
}

См. @ManyToOne и @OneToMany.


Дети не сохраняются вместе с родителем

Симптом: persist(parent) сохранил родителя, а новых детей в БД нет, или TransientObjectException.

Причина: нет каскадов PERSIST и MERGE на коллекции.

Фикс: cascade = {PERSIST, MERGE} или ALL, если дети действительно принадлежат родителю. Детей добавляй только через addChild.


Заменил всю коллекцию - потерял детей

Симптом: после parent.setChildren(new ArrayList<>(...)) старые дети удалены из БД или Hibernate бросает A collection with cascade="all-delete-orphan" was no longer referenced by the owning entity instance.

Причина: при orphanRemoval = true ребёнок, на которого больше не ссылается коллекция, - сирота, и его удаляют DELETE. А подмену самого объекта коллекции Hibernate не умеет отследить и падает.

Фикс: не подменять коллекцию, а делать дифф: removeChild(...) для ушедших детей и addChild(...) для новых. Если нужно заменить всё - children.clear() и addAll в ту же коллекцию.

Note

В исходной версии было только «Hibernate решил удалить старых сирот». При замене объекта коллекции Hibernate 5 и 6 обычно падают с исключением выше, а удаление сирот происходит при clear() и remove().


equals, hashCode и toString ломают коллекции

Симптом: странные дубликаты или исчезновения элементов в Set и List, бесконечная рекурсия, тонны SQL в логах.

Причина: - нестабильный equals: по полям, которые меняются, или по id, который появляется только после сохранения. Объект положили в Set до persist, hashCode изменился - Set его больше не находит - equals, hashCode или toString идут по связи: рекурсия между родителем и детьми и загрузка ленивых коллекций

Фикс: - equals и hashCode по устойчивому бизнес-ключу или по id - только после присвоения - ленивые коллекции и связи в toString, equals, hashCode не включать - для @OneToMany проще начинать с List: Set с неустойчивым equals до присвоения id даёт загадочные дубликаты


Каскады «сносят полмира»

Симптом: удаляешь ребёнка - удалились родитель или соседние записи.

Причина: cascade = REMOVE на @ManyToOne (удаление идёт вверх по графу) или на связи, которая не означает владение.

Фикс: на @ManyToOne обычно без REMOVE. На коллекции в агрегате - PERSIST, MERGE, а REMOVE и orphanRemoval = true - только если это действительно дети-владение.


N+1 и «жирные» запросы

Симптом: один запрос за списком и по запросу на каждый элемент, или один огромный запрос с размноженными строками.

Фикс: - для to-one связей - EntityGraph или JOIN FETCH - для to-many - отдельные запросы или батчи, а не JOIN (см. Коллекции на списочных экранах)

См. N+1 и планы загрузки.


Коллекции на списочных экранах

Проблема: загрузить страницу родителей вместе с их большими коллекциями детей в одном запросе - через EntityGraph с коллекцией или JOIN FETCH. Для to-many это опасно по трём причинам.

Размножение строк

SQL возвращает по строке на каждую пару Parent × Child. Если у родителя 1 000 детей, одна строка родителя превращается в 1 000 строк результата. Для 100 родителей это уже 100 000 строк.

Даже если ORM соберёт их обратно в 100 объектов Parent, память и сеть уже потрачены на 100 000 строк. Каждый дополнительный JOIN коллекции - риск декартова взрыва.


Пагинация ломается

LIMIT и OFFSET в SQL применяются к сырым строкам, а не к уникальным родителям: первые 20 строк после перемножения - это не первые 20 родителей. Поэтому Hibernate с JOIN FETCH коллекции не ставит LIMIT в SQL: - выбирает все строки и режет страницу в памяти, с предупреждением HHH90003004 в логе - или бросает исключение, если включено hibernate.query.fail_on_pagination_over_collection_fetch

С EntityGraph, если провайдер загружает коллекцию через JOIN, проблемы те же.

Итого: либо исключение, либо вся таблица в памяти, либо дубликаты и перерасход памяти.

Note

В исходной версии было «Hibernate либо запрещает, либо отдаёт неверные страницы». Неверных страниц Hibernate не отдаёт: он делает пагинацию в памяти, страница правильная, но загружены все строки.


Почему EntityGraph не спасает

EntityGraph - план загрузки, а не умная пагинация детей. Он не умеет «подгрузи только 50 детей у каждого родителя». Если коллекция есть в графе, провайдер загружает её полностью для каждого выбранного родителя. У кого-то миллион детей - граф вытащит миллион строк.


Как делать правильно

Вариант A - двухшаговая загрузка (рекомендуется, так и сделано в лабе): 1) Запрос 1 - только id родителей по фильтрам и сортировке, с offset и limit. 2) Запрос 2 - по пачке id родители с to-one связями через маленький EntityGraph или JOIN, без коллекций. 3) Дети - отдельными запросами: по требованию, когда пользователь раскрывает строку, или пачкой по IN (:parentIds), если нужны все дети родителей этой страницы и объём точно подъёмный.

// 1) страница id родителей
List<Long> ids = em.createQuery("select p.id from Parent p order by p.createdAt desc", Long.class)
        .setFirstResult(offset)
        .setMaxResults(limit)
        .getResultList();

// 2) родители с to-one связями - порядок из IN не сохраняется, восстанавливают его по ids
List<Parent> parents = em.createQuery("select p from Parent p join fetch p.owner where p.id in :ids", Parent.class)
        .setParameter("ids", ids)
        .getResultList();
// дети одного родителя с пагинацией
List<Child> 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<Child> 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<parentId, List<Child>>

Вариант B - тюнинг ленивой загрузки, если не хочется писать отдельные запросы: - @BatchSize(size = 50) на коллекции или классе Child - Hibernate забирает детей пачками, N+1 резко сокращается - @Fetch(FetchMode.SUBSELECT) на коллекции - при первом обращении к parent.getChildren() Hibernate одним запросом загрузит детей всех уже загруженных родителей

Проблему «миллиона детей» это не решает: запросов меньше, а объём данных тот же. См. Загрузка связей.

Вариант C - DTO-проекции вместо сущностей. Для таблицы на экране часто нужны не сущности, а плоский набор колонок через select new .... Один аккуратный JOIN ровно с нужными полями, без графов и коллекций. Самый лёгкий по памяти вариант.


Итого: - на списочных экранах коллекции в EntityGraph и JOIN FETCH не включай - родителей грузи постранично, to-one - через граф или JOIN, детей - отдельными запросами с пагинацией - для производительности - @BatchSize, @Fetch(SUBSELECT), DTO-проекции, индексы на FK - если всё же нужны сразу все дети - осознанно, вторым запросом по IN (:ids), убедившись, что объём подъёмный - нормальная схема one-to-many - FK в child.parent_id и двусторонняя связь с mappedBy