JavaRush /Курси /Hibernate deep-dive /Небезпечний merge

Небезпечний merge ( ) у графі сутностей

Hibernate deep-dive
Рівень 5 , Лекція 2
Відкрита

1. Граф сутностей і PurchaseOrder

Якщо до цього merge() виглядав як «один рядок коду», то лише тому, що ми подумки працювали з однією сутністю. У промисловому застосунку так майже не буває: користувач змінює замовлення, а замовлення — це не один стовпець в одній таблиці. Це ціла пов’язана конструкція, і Hibernate змушений синхронізувати саме її.

У Commerce Persistence Lab добрим прикладом справжнього графа є замовлення. У нас є PurchaseOrder як корінь (aggregate root), є OrderItem як позиції, і майже завжди десь поруч присутні Customer і Product. Навіть якщо ви змінюєте лише статус замовлення, поруч в об’єкті можуть лежати колекція позицій, адреса доставки, totalAmount та інші поля, які ви випадково притягнули — або, навпаки, не притягнули.

Ось спрощена схема уявного графа: зараз нам важлива саме його форма, без занурення в деталі fetch-плану.

flowchart TD
    PO["PurchaseOrder (id=100)"] --> C["Клієнт (id=7)"]
    PO --> I["позиції: List<OrderItem>"]
    I --> OI1["OrderItem (id=501)"] --> P1["Товар (id=10)"]
    I --> OI2["OrderItem (id=502)"] --> P2["Товар (id=11)"]

У detached-сценарії такий граф часто приходить ззовні — у вигляді DTO, JSON, форми або навіть уже зібраного в пам’яті об’єкта. Наприклад, ви десь зберегли посилання на замовлення, вийшли з транзакції, а потім намагаєтеся зберегти зміни. І ось тут починається головна небезпека: merge() не думає як людина. Він працює як механізм синхронізації стану. Якщо ви дали йому граф, він спробує привести базу й managed-граф до того стану, який ви принесли.

Щоб показати це на коді, уявімо спрощений вхідний об’єкт, який умовно прийшов у сервіс «адмінки замовлень» після редагування:

import java.util.List;

// Команда редагування замовлення ззовні (зазвичай — часткове представлення)
public record OrderEditCommand(
        long orderId,
        String newStatus,
        List<ItemEditCommand> items
) {}

// Окрема команда на зміну позиції (зазвичай тут далеко не всі поля OrderItem)
public record ItemEditCommand(long itemId, int qty) {}

Ззовні до нас приходить часткове представлення замовлення. І якщо ви наївно перетворюєте таку команду на detached-entity graph і викликаєте merge(), ви фактично просите Hibernate: «Зроби в базі ось так».

2. Що робить merge() з графом

Базовий контракт не змінюється: merge() і далі копіює стан у managed-екземпляр, а вихідний об’єкт не стає managed. Але на графі операція вже не обмежується одним root-об’єктом — вона проходить по пов’язаних вузлах, до яких її пропускає cascade.

Тут важливо не потонути в анотаціях, але одну річ ми зобов’язані зафіксувати вже зараз: каскад MERGE — це дозвіл Hibernate застосувати merge і до пов’язаних сутностей. Якщо у вас на зв’язку стоїть CascadeType.MERGE або CascadeType.ALL, то merge() у батька потягне merge далі, до дочірніх сутностей.

На прикладі замовлення це може виглядати так. Покажу лише важливий фрагмент, без усіх полів:

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

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

    // Важливо: каскад MERGE дозволяє Hibernate «зайти всередину» колекції під час merge батька
    @OneToMany(mappedBy = "order", cascade = CascadeType.MERGE)
    private List<OrderItem> items = new ArrayList<>(); // Ініціалізація, щоб уникнути NPE в доменній логіці
}

У цей момент виникає дуже практичне питання: «А що, коли я хотів оновити лише статус замовлення, а items просто приїхали поруч?» Hibernate не вміє читати ваші думки. Він бачить items і за наявності каскаду теж починає їх мерджити.

На окремій сутності це й досі виглядає пристойно. Небезпека з’являється, коли detachedOrder — це не «голе замовлення», а замовлення з позиціями, а позиції — з продуктами, а продукти… ну ви зрозуміли. І ось тут merge() перетворюється з «оновлення» на «синхронізацію стану графа».

Корисна побутова аналогія: merge() — це не «повернути об’єкт до життя», а зробити ксерокопію вашої detached-версії в managed-простір. Коли ви копіюєте один аркуш, усе добре. Коли копіюєте теку документів, де половина аркушів дублюється, а половина застаріла, виходить офісна версія фільму жахів — тільки замість музики у вас звук SQL-логів.

3. Приховані SELECT перед записом

На окремій сутності SELECT перед UPDATE уже не має дивувати. На графі важливо інше: читання легко розповзається з кореня на колекції та пов’язані сутності, тому що Hibernate потрібно зрозуміти поточний стан не лише замовлення, а й графа навколо нього.

Механіка зазвичай виглядає так: щоб створити managed-представників для сутностей з id, Hibernate шукає їх у поточному контексті, а якщо їх там немає — читає з БД. На графі це дуже швидко означає читання і кореня, і колекцій, і пов’язаних об’єктів, до яких дійшов cascade.

Уявімо типовий trace — дуже схематично — який ви можете побачити, коли мерджите замовлення з позиціями:

-- Hibernate хоче зрозуміти поточний стан замовлення
select po.* from purchase_order po where po.id = 100;

-- А далі (часто) йому треба зрозуміти поточний стан колекції
select oi.* from order_item oi where oi.order_id = 100;

-- І лише потім з’являються оновлення
update purchase_order set status='PAID' where id=100;
update order_item set quantity=3 where id=501;

Зверніть увагу: це не «Hibernate гальмує». Це Hibernate намагається синхронізувати граф без втрати даних. Якби він сліпо виконав UPDATE для всього, що ви принесли, то:

1) він міг би оновити те, чого в базі вже немає або взагалі не існує,
2) він міг би «стерти» поля, які ви не надіслали, наприклад deliveryAddress опинився null у detached-об’єкті, тому що фронтенд його не відправив,
3) він міг би не помітити зміни в колекції на кшталт «видалили позицію / додали позицію» і залишити в базі сміття.

Щоб не бути голослівним, ось типовий фрагмент коду з наївним мапінгом із команди редагування в detached-order. Саме такі речі й провокують приховані читання та весь цей жанр:

import java.util.ArrayList;

public class OrderMapper {
    public PurchaseOrder toDetached(OrderEditCommand cmd) {
        PurchaseOrder o = new PurchaseOrder();
        o.setId(cmd.orderId());
        o.setStatus(cmd.newStatus());

        // Важливо: новий список «кожного разу» виглядає як повна заміна складу items
        o.setItems(new ArrayList<>());

        return o;
    }
}

Код виглядає невинно, але він уже каже Hibernate: «У замовлення тепер інший список items». А Hibernate, як дисциплінований бухгалтер, відповідає: «Гаразд, але спершу я подивлюся, який список був раніше».

І ще один важливий нюанс: merge() може робити SELECT не лише по кореню. Якщо у вас увімкнено каскад на дітей, він може читати і їх. Тобто ви думали «зміню статус замовлення», а отримали «прочитаю замовлення, прочитаю позиції, можливо, прочитаю ще кілька пов’язаних сутностей, а потім ще й оновлю».

4. Конфлікт копій: duplicates і entity copies

Тепер перейдемо до ситуації, яка ламає мозок навіть досвідченим розробникам: коли в пам’яті у вас опиняються дві різні Java-сутності, але обидві представляють один і той самий рядок у таблиці — один і той самий id. Базове правило identity map нікуди не поділося: всередині одного контексту одному id має відповідати один managed-об’єкт. На графі порушити це правило набагато легше, бо копії тієї самої сутності можуть приїхати в різних гілках.

На окремій сутності ви рідко створюєте таке вручну. На графі — легко. Наприклад, ви збираєте detached-замовлення з вхідних даних і випадково додаєте в items одну й ту саму item двічі: дублікати в масиві, баг фронтенду, баг мапера — що завгодно. У результаті у вас виходять два об’єкти OrderItem, обидва з id=501.

import java.util.List;

OrderItem a = new OrderItem();
a.setId(501L);
a.setQuantity(2);

OrderItem b = new OrderItem();
b.setId(501L);
b.setQuantity(5);

PurchaseOrder detached = new PurchaseOrder();
detached.setId(100L);

// Важливо: два різні Java-об’єкти представляють один і той самий рядок (id збігається)
detached.setItems(List.of(a, b)); // два представлення одного рядка

Якщо ви такий граф віддаєте в merge(), Hibernate опиняється в ситуації: «У мене є дві версії однієї й тієї самої сутності. Кому вірити?» Залежно від налаштувань і конкретного сценарію він може кинути виняток на кшталт «multiple representations of the same entity are being merged» — і це навіть добре, бо принаймні не мовчки, — або почати копіювати значення у непередбачуваному порядку. Для вас це виглядатиме як «я нічого такого не робив, просто мерджнув».

Є й більш прихований варіант duplicates, який частіше трапляється в замовленнях: позиції замовлення посилаються на Product, і ви мапите DTO так, що кожна позиція створює свій об’єкт Product, але з тим самим id. У результаті у вас у графі два Product{id=10}, але це два різні об’єкти.

OrderItem i1 = new OrderItem();
i1.setId(501L);
i1.setProduct(new Product(10L)); // умовний конструктор "лише id"

OrderItem i2 = new OrderItem();
i2.setId(502L);

// Важливо: це НЕ "одне посилання на один об’єкт", а два різні об’єкти з однаковим id
i2.setProduct(new Product(10L)); // знову продукт з тим самим id

З погляду людини: «Обидва item’и вказують на один і той самий товар». З погляду Hibernate: «У графі два об’єкти Product з однаковим id, і тепер мені треба якось їх з’єднати». Якщо каскад MERGE випадково ввімкнено на OrderItem.product, ви ще й отримаєте спробу мерджу продукту, а далі — раптові SELECT по product і ризик перезаписати поля продукту тими значеннями, які взагалі не збиралися чіпати.

І ось тут проблема вже не в різних посиланнях заради цікавості, а в тому, що Hibernate має вибрати одну версію стану для одного рядка. Чим більший граф і чим вільніше ви його пересобираєте з DTO, тим легше непомітно привезти такий конфлікт.

5. Колекції: заміна списку items

Коли ми працюємо із замовленням, найболючіша частина графа — майже завжди items. Бо це to-many зв’язок, тобто колекція. І якщо ви в detached-об’єкті «просто поставили новий список», Hibernate має вирішити: це часткове оновлення чи повна заміна? На жаль, з одного рядка setItems(...) він не зчитує вашого наміру. Він бачить факт: колекція тепер інша.

Наївне мапування команд «редагування замовлення» зазвичай виглядає так: «Прийшов список items, отже я повністю перескладу items»:

import java.util.List;

PurchaseOrder detached = new PurchaseOrder();
detached.setId(100L);

List<OrderItem> newItems = cmd.items().stream()
        // Важливо: створюємо об’єкти заново -> для Hibernate це нові екземпляри (навіть якщо id ті самі)
        .map(i -> new OrderItem(i.itemId(), i.qty()))
        .toList();

// Важливо: setItems(newList) виглядає як повна заміна складу колекції
detached.setItems(newItems);

Код короткий, виглядає функціонально — і саме тому небезпечний. Якщо в старого замовлення було 10 позицій, а ви прислали 1, бо змінювали qty лише в одній, Hibernate може інтерпретувати це так: «Тепер у замовленні лишилася лише одна позиція». Далі все залежить від мапінгу: десь це перетвориться на UPDATE order_item set order_id=null, десь — на DELETE, а десь — на «нічого не зробив, бо каскаду немає, але зате ви тепер думаєте, що все оновилося».

Навіть якщо бізнес-логіка справді передбачає «повністю замінити позиції», merge() не безкоштовний. Щоб зрозуміти, що саме замінювати, Hibernate часто змушений завантажити поточну колекцію з БД, порівняти її з тим, що ви принесли, і привести все у відповідність. А це вже перетворюється на серію SQL-операцій, які за кількістю і формою легко не збігаються з вашим очікуванням «ну там один update».

А ще є людський фактор: коли ви робите повний replace колекції, ви майже неминуче створюєте нові Java-об’єкти для item’ів. Навіть якщо id збігається, це все одно нові екземпляри. І ми знову повертаємося до теми попереднього розділу: duplicates і entity copies. Тобто один і той самий рядок order_item(id=501) може бути представлений старим managed-екземпляром, якщо він уже десь є в контексті, і вашим новим detached-екземпляром із DTO.

Найнеприємніший ефект тут психологічний: ви дивитеся на код і бачите 10 рядків. Потім дивитеся на SQL-лог і питаєте: «Чому це зробило стільки запитів і навіщо торкнулося стільки рядків?» Відчуття таке, ніби Hibernate сам вигадав бізнес-логіку. Насправді він просто дуже старанно виконав те, що ви йому передали: «Ось новий склад колекції».

6. Прихована ціна merge() на графі

У розмовах про merge() найчастіше сперечаються на рівні «подобається / не подобається». Давайте замість смакових уподобань подивимося на це інженерно: merge() на графі небезпечний не тому, що він «поганий», а тому, що його ціна і побічні ефекти слабо читаються з коду. Чим більший граф, тим більше ви платите за один зручний рядок.

Щоб зафіксувати думку, корисно дивитися не на «merge як метод», а на «merge як сценарій». Нижче — невелика таблиця-шпаргалка, яка допомагає швидко оцінювати, що може статися. Це не вичерпна специфікація, а радше інженерний здоровий глузд.

Що ви мерджите Що Hibernate може зробити додатково Чому це стається
PurchaseOrder{id} із парою полів SELECT замовлення перед UPDATE Потрібно отримати managed-екземпляр/поточний стан
PurchaseOrder + items (колекція) SELECT замовлення + SELECT items + серія UPDATE/DELETE/INSERT Потрібно синхронізувати колекцію і зрозуміти відмінності
PurchaseOrder + items + product (to-one) Додаткові SELECT по товарах, ризик каскадних змін На графі з’являються додаткові сутності та їхня identity
Граф із дублікатами id Виняток або непередбачуваний перезапис даних В одному контексті не має бути двох версій одного рядка

Що особливо неприємно: merge() часто робить усе правильно з точки зору ORM, але не так, як задумував бізнес. Наприклад, ви не хотіли змінювати deliveryAddress, але в detached-об’єкті він опинився null, тому що DTO не включало адреси. Hibernate чесно копіює цей null у managed-екземпляр, і на flush ви отримуєте оновлення адреси на null. З точки зору алгоритму merge усе чесно. З точки зору бізнесу — ви випадково втратили дані.

І ще один практичний аспект: merge() робить код сервісу менш прозорим. Коли ви бачите:

// Один рядок, за яким може ховатися "ціла історія" SQL і каскадів
entityManager.merge(detachedOrder);

вам дуже важко зрозуміти, які поля реально оновлюються, які зв’язки зачіпаються, чи буде читання з бази, чи спрацює cascade і скільки SQL загалом піде до БД. Це знання живе «в голові команди», а не «в коді». І ось цей розрив між читабельністю та реальною поведінкою — часте джерело історій з продакшена у стилі «ми змінили один рядок, а впало все».

7. Діагностика merge-проблем у лабораторії

Добра новина в тому, що merge() — не магія. Погана — що він виглядає магією, доки ви не ввімкнули спостережуваність. У Commerce Persistence Lab ми з першого дня домовилися: SQL trace — це не режим «коли все гальмує», а режим «завжди, коли навчаємося». Тому діагностика merge-проблем починається не з переписування коду, а з перевірки фактів.

Перше питання все те ж: який об’єкт реально managed, а який так і лишився detached. contains() тут і далі корисний, але на графі головним прожектором стає SQL trace: саме він показує, у що перетворився невинний merge().

Друге питання — що саме приїхало в графі. Якщо в ньому є часткова колекція, дублікати за id або випадкові дочірні сутності, Hibernate буде дуже старанно синхронізувати саме це.

Третє питання — коли спрацював flush. У merge-сценаріях це особливо важливо, бо зміни в managed-графі можуть накопичитися, а потім раптово піти в БД перед JPQL query або на commit.

І нарешті, корисно пам’ятати: merge() — це не «один рядок», а цілий сценарій синхронізації. Тому після кожної зміни в merge-коді спочатку дивіться в SQL trace: що читалося, що писалося, скільки запитів пішло і які таблиці взагалі були зачеплені. Не тому що ви параноїк, а тому що Hibernate вміє бути надто послужливим.

Коли update-сценарій починає залежати від того, який фрагмент графа випадково приїхав у вхідних даних, корисніше спершу взяти поточну managed-сутність і змінювати лише дозволені поля. Такий потік зазвичай простіше захищати на code review і простіше прогнозувати за SQL.

8. Типові помилки при merge() на графі сутностей

Помилка №1: мерджити «шматок» графа так, ніби це повний знімок.
Дуже поширена історія: DTO прислав лише два поля й один item, а ви зібрали detached-entity і зробили merge(). Hibernate сприймає це як стан, який ви хочете бачити в базі цілком. Якщо якісь поля або зв’язки в detached-об’єкті опинилися null або порожніми, ви ризикуєте стерти дані, які взагалі не збиралися змінювати.

Помилка №2: ігнорувати cascade й дивуватися, що оновилося «занадто багато».
На графі merge() працює разом із cascade. Якщо на зв’язку стоїть CascadeType.MERGE або ALL, ви фактично дозволили Hibernate пройти по зв’язках і мерджити далі. Потім ви бачите в SQL оновлення в таблицях, яких «не чіпали», і починаєте підозрювати Hibernate в чаклунстві. Насправді це не чаклунство, а буквальне виконання правил каскаду.

Помилка №3: приносити в merge два різні об’єкти з однаковим id.
Це класична пастка з дублікатами: два OrderItem{id=501} у колекції або два Product{id=10} у різних місцях графа. Hibernate не може тримати дві версії одного рядка в одному контексті, тому ви або отримаєте виняток, або отримаєте непередбачуваний перезапис. Джерело проблеми майже завжди в мапінгу вхідних даних і в пересозданні об’єктів замість нормалізації.

Помилка №4: замінювати колекцію цілком заради точкової зміни.
Коли ви робите detachedOrder.setItems(newList) і мерджите, ви запускаєте синхронізацію to-many зв’язку. Це часто означає додаткові SELECT для завантаження поточного складу і серію SQL-операцій для приведення його до нового стану. Навіть якщо ви хотіли змінити qty лише в одній позиції, ви випадково перетворили задачу на «перезібрати колекцію».

Помилка №5: не читати SQL trace після змін merge-логіки.
Якщо ви судите про merge-сценарій лише за кодом сервісу, ви майже напевно недооціните ціну та побічні ефекти. Єдиний чесний спосіб зрозуміти, що справді сталося, — подивитися SQL: скільки запитів, які таблиці, які UPDATE/INSERT/DELETE і в якому порядку. Без цього merge() легко перетворюється на «працює… доки не почне працювати інакше».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ