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["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() легко превращается в «работает… пока не начнёт работать иначе».

1
Задача
Hibernate deep-dive, 5 уровень, 2 лекция
Недоступна
merge() корня заказа с одной позицией
merge() корня заказа с одной позицией
1
Задача
Hibernate deep-dive, 5 уровень, 2 лекция
Недоступна
Защита от двух представлений одной позиции заказа
Защита от двух представлений одной позиции заказа
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ