1. Граф сущностей и PurchaseOrder
Если до сих пор merge() выглядел как «одна строка кода», то только потому, что мы мысленно мерджили одну сущность. В боевом приложении так почти не бывает: пользователь меняет заказ, а заказ — это не один столбец в одной таблице. Это целая связанная конструкция, и Hibernate вынужден синхронизировать именно её.
В Commerce Persistence Lab хороший пример настоящего графа — это заказ. У нас есть PurchaseOrder как корень (aggregate root), есть OrderItem как позиции, и почти всегда где-то рядом торчат Customer и Product. Даже если вы меняете только статус заказа, рядом в объекте могут лежать коллекция позиций, адрес доставки, totalAmount и другие поля, которые вы случайно притащили — или, наоборот, не притащили.
Вот простой мысленный рисунок: сейчас нам важна форма графа, без ухода в детали fetch-плана.
flowchart TD
PO["PurchaseOrder (id=100)"] --> C["Customer (id=7)"]
PO --> I["items: List<OrderItem>"]
I --> OI1["OrderItem (id=501)"] --> P1["Product (id=10)"]
I --> OI2["OrderItem (id=502)"] --> P2["Product (id=11)"]
В detached-flow такой граф часто приходит снаружи — в виде 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);
вам очень трудно понять, какие поля реально обновляются, какие связи затрагиваются, будет ли чтение из базы, сработает ли каскад и сколько 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: игнорировать каскад и удивляться, что обновилось «слишком много».
На графе merge() живёт вместе с cascade. Если на связи стоит CascadeType.MERGE или ALL, вы фактически разрешили Hibernate пройти по связям и мерджить дальше. Потом вы видите в SQL обновления в таблицах, которые «не трогали», и начинаете подозревать Hibernate в колдовстве. На самом деле это не колдовство, а буквальное исполнение правил каскада.
Ошибка №3: приносить в merge два разных объекта с одинаковым id.
Это классическая ловушка duplicates: два OrderItem{id=501} в коллекции или два Product{id=10} в разных местах графа. Hibernate не может держать две версии одной строки в одном контексте, поэтому вы либо получите исключение, либо получите непредсказуемую перезапись. Источник проблемы почти всегда в маппинге входных данных и в пересоздании объектов вместо нормализации.
Ошибка №4: заменять коллекцию целиком ради точечного изменения.
Когда вы делаете detachedOrder.setItems(newList) и мерджите, вы запускаете синхронизацию to-many связи. Это часто означает дополнительные SELECT для загрузки текущего состава и серию SQL-операций для приведения его к новому состоянию. Даже если вы хотели изменить qty у одного item’а, вы случайно превратили задачу в «пересобрать коллекцию».
Ошибка №5: не читать SQL trace после изменений merge-логики.
Если вы судите о merge-сценарии только по коду сервиса, вы почти гарантированно недооцените стоимость и побочные эффекты. Единственный честный способ понять, что реально произошло, — посмотреть SQL: сколько запросов, какие таблицы, какие UPDATE/INSERT/DELETE и в каком порядке. Без этого merge() легко превращается в «работает… пока не начнёт работать иначе».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ