💾 Розділ 16 · Питання #20

Як працюють каскадні операції (Cascade)

Каскадування (Cascading) у JPA та Hibernate — це механізм автоматичного поширення операцій життєвого циклу (PERSIST, MERGE, REMOVE, REFRESH, DETACH) від батьківської сутності до...

Каскадування (Cascading) у JPA та Hibernate — це механізм автоматичного поширення операцій життєвого циклу (PERSIST, MERGE, REMOVE, REFRESH, DETACH) від батьківської сутності до пов’язаних із нею дочірніх сутностей. Каскадні операції дозволяють працювати з цілісними агрегатами та графами об’єктів через єдину точку входу (Aggregate Root), позбавляючи розробника необхідності ручного збереження кожного залежного об’єкта.


🟢 Junior Level

Що таке каскадні операції та навіщо вони потрібні

Без каскадування збереження сутності Order разом зі списком позицій OrderItem вимагало б ручного виклику persist() для кожного окремого об’єкта:

// БЕЗ КАСКАДУВАННЯ (Громіздко і загрожує помилками):
entityManager.persist(item1);
entityManager.persist(item2);
entityManager.persist(order);

З увімкненим каскадуванням достатньо викликати метод лише для батьківської сутності:

@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Усі операції над Order автоматично передаються на items
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> items = new ArrayList<>();

    public void addItem(OrderItem item) {
        items.add(item);
        item.setOrder(this); // Синхронізація зворотного боку зв'язку
    }
}
// З КАСКАДУВАННЯМ:
Order order = new Order();
order.addItem(new OrderItem("Ноутбук"));
order.addItem(new OrderItem("Миша"));

// Достатньо зберегти ТІЛЬКИ батьківську сутність:
entityManager.persist(order); 
// Hibernate сам виконає 3 операції INSERT: спочатку order, потім обидва items

Основні типи каскадування (CascadeType)

Специфікація JPA визначає такі типи в переліку jakarta.persistence.CascadeType:

Тип каскаду Метод EntityManager, що поширюється Опис
CascadeType.PERSIST persist() При збереженні батька нові дочірні об’єкти переходять у стан Managed.
CascadeType.MERGE merge() При злитті від’єднаного батька дочірні об’єкти також оновлюються в сесії.
CascadeType.REMOVE remove() При видаленні батька всі пов’язані дочірні рядки видаляються з БД.
CascadeType.REFRESH refresh() Перезавантаження батька з БД примусово перезавантажує всіх дітей.
CascadeType.DETACH detach() Від’єднання батька від сесії від’єднує і всі пов’язані сутності.
CascadeType.ALL Усі вищеперелічені Об’єднує в собі абсолютно всі типи каскадування.

🟡 Middle Level

JPA Cascading vs Базовий СУБД-каскад (ON DELETE CASCADE)

Часта плутанина серед розробників: каскади JPA та каскади на рівні зовнішніх ключів у базі даних — це два принципово різні механізми:

┌──────────────────────────────────────┬──────────────────────────────────────┐
│       JPA Каскад (Hibernate Core)    │      DB Каскад (ON DELETE CASCADE)   │
├──────────────────────────────────────┼──────────────────────────────────────┤
│ • Виконується в пам'яті JVM          │ • Виконується рушієм СУБД            │
│ • Hibernate зобов'язаний завантажити │ • Діти видаляються миттєво на рівні  │
│   дітей у L1 кеш перед видаленням    │   файлів бази даних без читання в JVM│
│ • Викликаються колбеки (@PreRemove)  │ • Колбеки JPA та Hibernate НЕ знають │
│   та Dirty Checking                  │   про видалення                      │
│ • Генерує N окремих SQL DELETE       │ • 1 швидкий SQL DELETE для батька    │
└──────────────────────────────────────┴──────────────────────────────────────┘

Для інтеграції JPA з каскадом бази даних Hibernate надає анотацію:

@OneToMany(mappedBy = "order")
@org.hibernate.annotations.OnDelete(action = OnDeleteAction.CASCADE)
private List<OrderItem> items;

При використанні @OnDelete Hibernate відправляє лише DELETE FROM orders WHERE id = ?, а дочірні рядки видаляє сам рушій PostgreSQL/MySQL.

Фатальна помилка: CascadeType.ALL на зв’язку @ManyToOne

Найнебезпечніша помилка початківців — бездумне копіювання cascade = CascadeType.ALL на сторону «багато до одного»:

@Entity
public class Order {
    @Id
    private Long id;

    // ❌ КАТАСТРОФА: Каскадне видалення спрямоване на спільного батька!
    @ManyToOne(cascade = CascadeType.ALL)
    private User user;
}

Що станеться в продакшені: При видаленні будь-якого звичайного замовлення (entityManager.remove(order)) спрацює каскад REMOVE, який видалить із бази даних самого користувача User, а якщо в користувача були інші замовлення — база викине помилку зовнішнього ключа або видалить взагалі всі дані клієнта!

Золоте правило: На зв’язках @ManyToOne та @ManyToMany каскад REMOVE заборонений.


🔴 Senior Level

Проблема продуктивності: N+1 при каскадному JPA-видаленні

Уявімо сутність Category, що містить 10 000 товарів Product із налаштуванням cascade = CascadeType.REMOVE:

Category category = entityManager.find(Category.class, categoryId);
entityManager.remove(category);

Що відбувається всередині Hibernate:

  1. Специфікація JPA вимагає, щоб перед видаленням кожного дочірнього об’єкта для нього були виконані колбеки життєвого циклу (@PreRemove, @PostRemove) та очищений L2 кеш.
  2. Hibernate змушений виконати SQL SELECT і завантажити всі 10 000 об’єктів Product в оперативну пам’ять (викликаючи просідання пам’яті та навантаження на збирач сміття GC).
  3. Потім у фазі flush() Hibernate виконує 10 000 окремих SQL-запитів DELETE FROM products WHERE id = ?!
  4. Senior-рішення: Для об’ємних колекцій відключати CascadeType.REMOVE у JPA і використовувати або рівень СУБД (ON DELETE CASCADE), або прямий пакетний JPQL:
    productRepository.deleteByCategoryId(categoryId);
    categoryRepository.deleteById(categoryId);
    

Циклічне каскадування (Circular Cascades)

У двонапрямлених зв’язках «один до одного» або графах зі складними циклічними залежностями:

@Entity
public class Account {
    @OneToOne(cascade = CascadeType.ALL)
    private Profile profile;
}

@Entity
public class Profile {
    @OneToOne(cascade = CascadeType.ALL) // ❌ Небезпека циклу!
    private Account account;
}

При виклику entityManager.persist(account) процесор каскадів Cascade.cascade() в Hibernate починає рекурсивний обхід графа об’єктів. Hibernate має внутрішній механізм захисту від зациклення на основі карти відвіданих сутностей (IdentityMap), проте циклічний каскад часто призводить до непередбачуваного порядку формування ActionQueue та помилок TransientPropertyValueException.


4 Tricky Questions

1. Якщо у батьківської сутності оголошено cascade = CascadeType.REMOVE, чи виконає Hibernate каскадне видалення дочірніх записів при виконанні масового запиту entityManager.createQuery("DELETE FROM Order o WHERE o.id = 1").executeUpdate()?

Відповідь:

  1. Категорично ні!
  2. Масові запити на видалення через HQL/JPQL (DELETE FROM ...) або @Modifying у Spring Data JPA транслюються безпосередньо в один SQL DELETE на рівні бази даних.
  3. Контекст персистентності PersistenceContext, сутності та анотації каскадування JPA у цьому процесі не беруть участі взагалі.
  4. Дочірні записи не будуть видалені через JPA. Якщо в самій БД на рівні зовнішнього ключа не налаштовано ON DELETE CASCADE, СУБД негайно викине помилку порушення зовнішнього ключа: DataIntegrityViolationException: foreign key constraint violation.

2. У чому різниця між cascade = CascadeType.REMOVE та параметром зв’язку orphanRemoval = true?

Відповідь:

  1. CascadeType.REMOVE спрацьовує тільки при видаленні батьківської сутності: entityManager.remove(order) призводить до видалення всіх пов’язаних order.getItems(). Але якщо просто видалити елемент із колекції в пам’яті (order.getItems().remove(0)), рядок у БД не видалиться, а лише втратить зв’язок (стане «сиротою»).
  2. orphanRemoval = true відстежує саме видалення з колекції: виклик order.getItems().remove(0) при черговому flush() призведе до фізичного видалення рядка OrderItem із бази даних.
  3. Крім того, orphanRemoval = true автоматично включає в себе поведінку CascadeType.REMOVE при видаленні батька.

3. Припустимо, на колекції оголошено cascade = CascadeType.PERSIST. Ми створили батька, додали дочірній елемент order.getItems().add(item), але забули проставити зворотне посилання item.setOrder(order). Що станеться при виклику entityManager.persist(order)?

Відповідь:

  1. Каскад PERSIST спрацює: Hibernate спробує зберегти обидва об’єкти.
  2. Спочатку виконається INSERT INTO orders ... і згенерується ID замовлення.
  3. Потім виконається INSERT INTO order_items (order_id, name) VALUES (null, 'Item 1').
  4. У колонку зовнішнього ключа order_id піде значення NULL, тому що зв’язок з боку OrderItem (власника зв’язку) не був заповнений у пам’яті JVM!
  5. Якщо в таблиці order_items колонка order_id оголошена як NOT NULL, СУБД викине помилку: null value in column "order_id" violates not-null constraint.

4. Ми викликали entityManager.detach(order). Колекція items була налаштована з лінивим завантаженням (LAZY) і не була ініціалізована до цього. Чи перейде колекція в стан Detached, і чи можна буде прочитати її розмір order.getItems().size() після цього?

Відповідь:

  1. Метод detach() при каскаді CascadeType.DETACH від’єднує батька та всі вже завантажені в пам’ять пов’язані об’єкти.
  2. Непроініціалізована колекція залишиться спеціальним проксі-об’єктом Hibernate — PersistentBag у неініціалізованому стані.
  3. Оскільки батьківський об’єкт order перейшов у стан Detached, його зв’язок з активною сесією розірвано.
  4. Спроба викликати order.getItems().size() за межами відкритої сесії негайно викине виняток: org.hibernate.LazyInitializationException: could not initialize proxy - no Session.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь (Elevator Pitch)

«Каскадні операції (CascadeType) у JPA автоматично переносять зміни життєвого циклу батьківської сутності на пов’язані дочірні об’єкти. Для композиційних зв’язків @OneToMany (наприклад, Order -> OrderItem), де дочірній об’єкт не має сенсу без батька, стандартом є зв’язка cascade = CascadeType.ALL, orphanRemoval = true. На зв’язках @ManyToOne та @ManyToMany каскад REMOVE суворо заборонений, оскільки він призведе до випадкового видалення сутностей спільного використання. При каскадному JPA-видаленні великих колекцій виникає просідання по пам’яті та N+1 видалень — для таких кейсів слід використовувати @OnDelete(action = OnDeleteAction.CASCADE) на рівні СУБД».

Ключові метрики для Production-моніторингу

  • Кількість SQL DELETE на один запит: сплеск вказує на неефективне JPA-каскадування замість пакетного видалення або засобів бази даних.
  • Обсяг пам’яті JVM під час операцій видалення: масова матеріалізація дочірніх сутностей при каскадах створює стрибки в купі (GC pauses).

Червоні прапорці (Чого говорити НЕ МОЖНА)

  • ❌ «Я завжди ставлю cascade = CascadeType.ALL на всі зв’язки @ManyToOne для зручності» — Це гарантовано видалить спільних користувачів або довідники при видаленні дочірніх записів.
  • ❌ «Каскадне видалення в JPA само перетворюється на ON DELETE CASCADE у базі» — Це два різні механізми; JPA-каскад завантажує сутності в пам’ять і робить N окремих DELETE.
  • ❌ «Масовий запит deleteBy… у репозиторії активує JPA-каскади» — Пакетні HQL/SQL запити працюють безпосередньо в СУБД та ігнорують JPA-каскадування.
  • ❌ «CascadeType.REMOVE видаляє запис із бази, якщо просто викликати list.remove(item)» — Це робить orphanRemoval = true, а не CascadeType.REMOVE.

Пов’язані теми