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() легко перетворюється на «працює… доки не почне працювати інакше».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ