JavaRush /Курси /Hibernate deep-dive /Порівняння merge

Порівняння merge ( ) і find ( ) + mutate

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

1. Порівняння на одному сценарії

Тепер картина цілісна: можна або принести в систему detached-стан і спробувати злити його через merge(), або спершу взяти managed-замовлення і застосувати до нього команду на зміну. Зведімо обидва підходи до одного сценарію редагування в бекофісі — не заради красивих слів, а заради спостережуваної поведінки: які об’єкти будуть managed, де з’явиться SELECT і що саме Hibernate вирішить оновити під час flush().

Уявіть екран «Редагування замовлення». Оператор змінює статус і коригує кількість кількох конкретних позицій. Це типова операція «точково підправити агрегат», і саме на таких часткових оновленнях найчастіше й спливає магія рівня «чому воно оновило не те?».

Сервіс отримує не повний знімок сутності, а команду з тим, що користувач справді змінив. І ось тут починається головна відмінність: merge() сприймає вхід як джерело стану для копіювання, а find() + mutate — як набір конкретних змін. Ці дві моделі звучать схоже рівно доти, доки не стикаються з неповним входом.

2. Один вхід для обох шляхів

Візьмемо одну й ту саму команду та проганятимемо її двома способами. У ній немає повного списку полів замовлення і немає повного списку items — лише те, що оператор реально змінив.

package com.example.commerce.orders.dto;

import java.util.List;

public record OrderEditRequest(
        Long orderId,                  // id замовлення, яке редагуємо
        String newStatus,              // новий статус (наприклад, "PAID", "CANCELLED")
        List<ItemQtyPatch> itemPatches // патчі за позиціями: змінюємо кількість лише для перелічених itemId
) {
    public record ItemQtyPatch(
            Long itemId,  // id позиції замовлення (OrderItem)
            int newQty    // нова кількість для цієї позиції
    ) {}
}

Саме цей request піде і в гілку з merge(), і в гілку з find() + mutate. Жодних різних DTO під кожен підхід — інакше порівняння занадто швидко перетворюється на підміну умов.

3. Варіант A: merge()

Потік merge() — «копіюємо стан detached-об’єкта»

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

package com.example.commerce.orders.service;

import com.example.commerce.orders.dto.OrderEditRequest;
import com.example.commerce.orders.entity.OrderItem;
import com.example.commerce.orders.entity.OrderStatus;
import com.example.commerce.orders.entity.PurchaseOrder;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderEditingMergeService {
    private final EntityManager entityManager;

    public OrderEditingMergeService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional
    public void editOrderWithMerge(OrderEditRequest request) {
        PurchaseOrder detached = new PurchaseOrder();
        detached.setId(request.orderId());
        detached.setStatus(OrderStatus.valueOf(request.newStatus()));

        detached.setItems(
                request.itemPatches().stream()
                        .map(patch -> {
                            // У позиції є лише id і qty: це частковий граф, а не повний знімок замовлення
                            OrderItem item = new OrderItem();
                            item.setId(patch.itemId());
                            item.setQuantity(patch.newQty());
                            return item;
                        })
                        .toList()
        );

        entityManager.merge(detached);
        entityManager.flush();
    }
}

На рівні Java все виглядає компактно: із request швидко зібрали об’єкт і передали його в ORM. Але для Hibernate це вже не «зміни статус і ось ці qty», а «ось стан замовлення та його позицій». А стан у нас неповний: у замовлення немає решти полів, а в позицій — повного контексту зв’язку та інших даних.

Саме тому itemPatches тут легко перестають бути списком точкових змін і починають виглядати як новий варіант колекції items, який потрібно синхронізувати.

SQL під час merge()

За кодом дуже хочеться повірити, що далі буде «один UPDATE замовлення і ще пара UPDATE по позиціях». На практиці merge() часто спершу читає поточне замовлення та його колекцію, щоб отримати managed-представників і зрозуміти, що саме синхронізувати.

Тому типовий SQL-профіль merge-сценарію виглядає приблизно так:

-- merge отримує managed-корінь замовлення
select po.id, po.status
from purchase_order po
where po.id = ?;

-- а потім розбирається з поточним складом колекції items
select oi.id, oi.order_id, oi.quantity
from order_item oi
where oi.order_id = ?;

-- лише після цього відправляються зміни
update purchase_order
set status = ?
where id = ?;

update order_item
set quantity = ?
where id = ?;

І це ще оптимістичний варіант. Якщо detached-граф виявився біднішим, ніж реальне замовлення в БД, сюди легко додаються зайві читання, неочікувані UPDATE і конфлікт копій.

Є ще один ефект, який у таких порівняннях часто пропускають: якщо замовлення вже завантажене в поточний persistence context, merge() копіюватиме стан саме в нього. Тобто бідний detached-patch може перезаписати більш повну managed-сутність, з якою ви вже працювали в цій самій транзакції.

import jakarta.persistence.EntityManager;

// managed1 вже в persistence context (відстежується Hibernate)
PurchaseOrder managed1 = entityManager.find(PurchaseOrder.class, 100L);

// detached - окремий екземпляр із тим самим id (наприклад, прийшов із мапера/DTO)
PurchaseOrder detached = new PurchaseOrder();
detached.setId(100L);

// merge поверне посилання на той самий managed-об'єкт, який уже лежав у persistence context
PurchaseOrder managed2 = entityManager.merge(detached);

System.out.println(managed1 == managed2); // true

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

4. Варіант B: find() + mutate

Тепер проганяємо той самий OrderEditRequest через інший потік. Тут request — це не «майже-сутність», а команда: спершу читаємо поточне замовлення з БД, потім застосовуємо лише ті зміни, які справді надійшли.

package com.example.commerce.orders.service;

import com.example.commerce.orders.dto.OrderEditRequest;
import com.example.commerce.orders.entity.PurchaseOrder;
import com.example.commerce.orders.entity.OrderItem;
import com.example.commerce.orders.entity.OrderStatus;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderEditingService {
    private final EntityManager entityManager;

    public OrderEditingService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional
    public void editOrderFindAndMutate(OrderEditRequest request) {
        // Явно завантажуємо замовлення: тепер це managed-сутність (джерело істини)
        PurchaseOrder order = entityManager.find(PurchaseOrder.class, request.orderId());

        // Застосовуємо зміни точково: змінюємо лише те, що надійшло як "команда"
        order.setStatus(OrderStatus.valueOf(request.newStatus()));

        // Важливо: оновлюємо лише перелічені itemId, решти позицій не торкаємося
        for (var patch : request.itemPatches()) {
            OrderItem item = entityManager.find(OrderItem.class, patch.itemId());
            item.setQuantity(patch.newQty());
        }

        // Примусово показуємо SQL і ефект dirty checking
        entityManager.flush();
    }
}

Прямий find(OrderItem.class, patch.itemId()) тут потрібний лише для чесного порівняння SQL один до одного. У реальному сценарії оновлення все одно важливо перевірити, що позиція справді належить цьому замовленню, або змінювати її через агрегат і helper-метод. Інакше навіть безпечний потік швидко знову перетворюється на історію «знайшли будь-який child за id і тихо змінили його».

Так, рядків більше. Зате кожен із них відображає конкретну зміну: знайшли замовлення, змінили статус, знайшли перелічені позиції, змінили кількість. Якщо в request немає інших полів і інших items, Hibernate й не отримує приводу трактувати їх як новий стан усього графа.

5. Порівняння: SQL і сенс

Тепер порівнюємо не дві абстракції, а реакцію Hibernate на один і той самий OrderEditRequest. Питання вже не лише в кількості запитів, а й у тому, наскільки передбачувано кожна гілка трактує частковий вхід.

З точки зору SQL, find() + mutate часто виглядає так:

-- явно читаємо замовлення як поточну істину
select ... from purchase_order where id = ?;

-- потім читаємо лише ті позиції, які справді надійшли в патчі
select ... from order_item where id = ?;

-- а на flush йде рівно те, що змінили
update purchase_order set status = ? where id = ?;
update order_item set quantity = ? where id = ?;

З точки зору бізнес-сенсу різниця така: у find() + mutate ви частіше мислите командами зміни, а в merge() — передачею стану. Поки вхід повний, це терпимо. Щойно request несе лише статус і кілька itemPatches, merge() починає здогадуватися за вас, чого ви не хотіли чіпати.

Щоб зафіксувати порівняння максимально наочно, давайте зберемо «паспорт відмінностей» у таблицю.

Критерій merge() (detached → копіювання стану) find() + mutate (завантаження → застосування змін)
Джерело істини Detached-обʼєкт, який ви принесли БД → managed-сутність у persistence context
Що потрібно від вхідних даних Бажано повний знімок замовлення та потрібного графа Можна надіслати лише те, що змінюється
Робота з поверненим обʼєктом Потрібно обовʼязково використовувати обʼєкт, який повернув merge() Працюєте з одним managed-екземпляром
Ризик «випадкового перезапису» Високий, особливо при partial DTO і графах Нижчий: ви змінюєте лише те, що явно написали в коді
Видимість SQL «у коді» Частина SELECT прихована всередині merge() SELECT зазвичай явний — через find()
Дебаг і code review Складніше: «що саме копіюємо?» Простіше: «ось які поля чіпаємо»

І тепер найважливіше: це не таблиця «merge() поганий, find() хороший». Це таблиця «merge() вимагає дисципліни й повноти даних, а find() + mutate — безпечніший дефолт для бізнес-змін».

6. Вибір підходу

Щоб не тримати все це в голові як набір розрізнених думок, зручно мати маленьку блок-схему вибору. Вона не про «правильно / неправильно», а про те, що ви реально контролюєте: повноту входу та передбачуваність результату.

flowchart TD
    A["Потрібно оновити замовлення"] --> B{"Вхідні дані — повний знімок замовлення (усі важливі поля + весь потрібний граф)?"}
    B -->|Так| C{"Ви готові після merge працювати лише з поверненим managed-екземпляром і читати SQL trace?"}
    C -->|Так| D["Можна використовувати merge (але обережно, без змішування потоків)"]
    C -->|Ні| E["Краще find + mutate (менше магії, простіше підтримувати)"]
    B -->|Ні| E["find + mutate (вибіркове оновлення)"]

У звичайному редагуванні в бекофісі вхід майже завжди неповний: оператор змінив одне поле, UI надіслав патч. У такій моделі find() + mutate виграє не елегантністю, а інженерною передбачуваністю. merge стає доречнішим там, де detached-об’єкт справді є акуратним повним знімком і ви не намагаєтеся використовувати entity як PATCH-документ.

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

7. Типові помилки під час порівняння merge() і find() + mutate

Помилка №1: використовувати merge() як «часткове оновлення за id».
Дуже часта пастка: розробник створює новий PurchaseOrder, задає лише id, status і пару itemPatches, робить merge() і сподівається, що Hibernate оновить лише це. Hibernate так не вважає: він копіює стан цілком. Якщо решту полів і зв’язків не задано, ви або затрете їх значеннями за замовчуванням, або отримаєте помилку обмежень на flush().

Помилка №2: ігнорувати повернений обʼєкт із merge().
Це той випадок, коли один рядок коду економить вам кілька годин: завжди зберігайте результат merge() у змінну і продовжуйте роботу лише з ним. Якщо ви далі змінюватимете старий detached-екземпляр, ці зміни не потраплять під dirty checking, і ви щиро дивуватиметеся, чому «Hibernate нічого не зберіг».

Помилка №3: змішувати merge() і find() + mutate в одному update-потоці «про всяк випадок».
Коли в одному методі одночасно трапляються find(), потім merge(), потім знову прямі зміни, у вас виникає подвійна бухгалтерія сутностей. У такій ситуації навіть досвідченому розробнику може бути непросто зрозуміти, який об’єкт зараз managed, який detached і що саме опиниться в БД після flush().

Помилка №4: обирати merge() лише тому, що «так менше рядків».
Кількість рядків — поганий критерій для persistence-коду. У merge() більша частина складності захована не в Java-рядках, а в прихованих діях: створення або пошук managed-екземпляра, можливі читання з БД, каскади на граф і копіювання стану колекцій. find() + mutate зазвичай довший, але зате краще пояснюється і легше діагностується.

Помилка №5: порівнювати підходи без перегляду SQL trace.
На рівні Java-коду обидва підходи можуть виглядати приблизно однаково. Різниця часто проявляється в SQL-сліді: де зʼявилися зайві SELECT, чому раптом оновилося більше колонок, ніж ви очікували, або чому update зачепив не лише кореневу таблицю. Якщо ви порівнюєте merge() і find() + mutate, але не дивитеся SQL, ви порівнюєте відчуття, а не поведінку.

1
Опитування
Стан сутностей, рівень 5, лекція 4
Недоступний
Стан сутностей
Detached, merge і update
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ