💾 Розділ 16 · Питання #14

У чому різниця між persist() і merge()

Методи persist() та merge() інтерфейсу EntityManager відповідають за переведення сутностей під керування контексту персистентності (PersistenceContext), проте вони призначені дл...

Методи persist() та merge() інтерфейсу EntityManager відповідають за переведення сутностей під керування контексту персистентності (PersistenceContext), проте вони призначені для принципово різних вихідних станів об’єктів, мають різну семантику роботи з пам’яттю та генерують різні послідовності SQL-запитів.


🟢 Junior Level

Головні відмінності: коротке зведення

  • persist(entity): Призначений для нових сутностей у стані Transient (які ще не мають запису в базі даних). Робить переданий Java-об’єкт керованим (Managed) і нічого не повертає (void).
  • merge(entity): Призначений для сутностей у стані Detached (раніше збережених, але відв’язаних від поточної сесії). Знаходить або завантажує копію з БД, переносить у неї поля з переданого об’єкта та повертає посилання на новий керований екземпляр.
                   PERSIST VS MERGE: ПОВЕДІНКА В ПАМ'ЯТІ
                   
   1. ВИКЛИК PERSIST:
      [ user (Transient) ] ──persist(user)──> [ user (Managed) ]
      (Той самий екземпляр стає керованим, повертає void)

   2. ВИКЛИК MERGE:
      [ detachedUser ] ──merge(detachedUser)──> [ managedUser (Managed) ]
                                                        │
      (detachedUser ЗАЛИШАЄТЬСЯ Detached!               ▼
       Усі подальші операції потрібно вести строго з managedUser!)

Порівняльна таблиця

Критерій entityManager.persist(entity) entityManager.merge(entity)
Цільовий стан Transient (новий об’єкт) Detached (від’єднаний об’єкт)
Значення, що повертається void Посилання на новий Managed екземпляр
Статус переданого об’єкта Стає Managed Залишається Detached
SQL, що генерується Лише INSERT (при flush) Спочатку SELECT, потім UPDATE або INSERT
Поведінка за наявності ID в БД Викидає EntityExistsException Оновлює існуючий рядок

Базові приклади

// Використання persist():
User newUser = new User();
newUser.setName("Максим");

entityManager.persist(newUser); 
// newUser тепер Managed. Можна змінювати поля:
newUser.setName("Максим Оновлений"); // Dirty checking збереже це при flush

// Використання merge():
User detachedUser = getFromCacheOrRest(); // Об'єкт з ID=1, але поза сесією
detachedUser.setName("Нове ім'я");

// ПОМИЛКА:
// entityManager.merge(detachedUser);
// detachedUser.setEmail("test@ya.ru"); // НЕ збережеться!

// ПРАВИЛЬНО:
User managedUser = entityManager.merge(detachedUser);
managedUser.setEmail("test@ya.ru"); // Збережеться в БД!

🟡 Middle Level

Внутрішній алгоритм роботи merge()

Коли застосунок викликає entityManager.merge(detachedEntity):

  1. Hibernate витягує первинний ключ (id) із переданого об’єкта.
  2. Перевіряє наявність сутності з таким id у кеші першого рівня L1 (entitiesByKey).
  3. Якщо сутності в сесії немає — виконує SQL-запит SELECT до бази даних.
  4. Якщо запис у БД знайдено:
    • Створює/бере Managed екземпляр.
    • Копіює всі поля з detachedEntity у цей Managed екземпляр.
    • Планує операцію SQL UPDATE в ActionQueue (якщо Dirty Checking виявить відмінності).
  5. Якщо запис у БД не знайдено (або ID був null):
    • Створює новий Managed екземпляр.
    • Копіює поля.
    • Планує операцію SQL INSERT.
  6. Повертає посилання на отриманий Managed об’єкт.

Чому merge() для нових сутностей — антипатерн

Часто розробники викликають merge() для збереження всіх сутностей поспіль, не замислюючись про їхній стан:

User newUser = new User();
newUser.setName("Олег");
entityManager.merge(newUser); // Формально спрацює, АЛЕ...

Ціна помилки:

  • Якщо генератор ID не визначений або використовується ручне призначення ID, Hibernate виконає зайвий штрафний SELECT, щоб переконатися, що запису з таким ID немає в таблиці.
  • Створюється зайвий дублюючий екземпляр об’єкта в оперативній пам’яті JVM.
  • Для нових сутностей завжди слід використовувати persist().

Каскадні операції: CascadeType.PERSIST vs CascadeType.MERGE

Якщо батьківська сутність містить дочірні колекції:

@OneToMany(mappedBy = "order", cascade = CascadeType.PERSIST)
private List<OrderItem> items = new ArrayList<>();
  • Якщо викликати entityManager.persist(order), каскад передасться на всі елементи items.
  • Якщо ж для order викликати entityManager.merge(order), але в анотації відсутній CascadeType.MERGE, пов’язані дочірні елементи не будуть об’єднані із сесією і їхні зміни втратяться.

🔴 Senior Level

Реалізація у Spring Data JPA: Як працює repository.save()

Багато розробників упевнені, що Spring Data JPA CrudRepository.save() викликає якийсь спеціальний магічний метод. Насправді під капотом SimpleJpaRepository реалізований простий вибір:

@Transactional
public <S extends T> S save(S entity) {
    if (entityInformation.isNew(entity)) {
        entityManager.persist(entity);
        return entity;
    } else {
        return entityManager.merge(entity);
    }
}

Проблема «Assigned ID» (Ідентифікаторів, що задаються вручну):

Як Spring Data визначає entityInformation.isNew(entity):

  1. За замовчуванням: якщо поле @Id об’єкта дорівнює null (або 0 для примітивів), об’єкт вважається новим (isNew = true), викликається persist().
  2. Якщо ID задається вручну (наприклад, бізнес-ключ String uuid, taxNumber або композитний ключ):
    • Поле ID вже заповнене до збереження (id != null).
    • Spring помилково вважає сутність Detached (isNew = false)!
    • Замість прямого INSERT Spring викликає entityManager.merge(entity).
    • Hibernate виконує паразитний попередній SQL SELECT, отримує null і лише потім робить INSERT.

Архітектурне рішення: Сутність повинна реалізувати інтерфейс org.springframework.data.domain.Persistable<ID>:

@Entity
public class Payment implements Persistable<String> {
    @Id
    private String transactionUuid;

    @Transient
    private boolean isNewEntity = true;

    @Override
    public String getId() { return transactionUuid; }

    @Override
    public boolean isNew() { return isNewEntity; }

    @PostPersist
    @PostLoad
    void markNotNew() { this.isNewEntity = false; }
}

Тепер Spring викличе швидкий persist() без зайвого SELECT.

Небезпека каскадного merge() на непроініціалізованих Lazy колекціях

Уявімо сценарій:

  1. У першій транзакції завантажено Department з лінивою колекцією employees (FetchType.LAZY).
  2. Колекція employees не була зачеплена (залишилася неініціалізованим проксі PersistentBag).
  3. Сесія закрилася, об’єкт став Detached.
  4. У другій транзакції викликається:
    Department managedDept = entityManager.merge(detachedDept);
    
  5. Внутрішня поведінка: DefaultMergeEventListener в Hibernate перевіряє стан колекцій. Якщо колекція employees позначена CascadeType.MERGE або ALL, і вона не була ініціалізована:
    • Hibernate залишає існуючу колекцію в managedDept непроініціалізованою.
    • Проте, якщо колекція була замінена в detached-об’єкті на звичайний new ArrayList<>(), Hibernate порахує, що старі співробітники були видалені (Orphan Removal), і виконає їх видалення з БД!

4 Tricky Questions

1. Що станеться, якщо в поточній сесії вже завантажено User(id=1) (Managed), а ми викликаємо entityManager.persist(user2) з тим же id=1? А якщо викликати entityManager.merge(user2)?

Відповідь:

  1. Для persist(user2): Hibernate перевіряє карту entitiesByKey. Виявивши, що сутність з таким типом та ID вже знаходиться в PersistenceContext, він негайно викине виняток: jakarta.persistence.EntityExistsException (або NonUniqueObjectException).
  2. Для merge(user2): Жодної помилки не буде. Hibernate візьме екземпляр User(id=1), що вже знаходиться в сесії, скопіює в нього поля з user2 і поверне посилання на існуючий Managed об’єкт. Жодного нового SQL SELECT виконуватися не буде, оскільки об’єкт уже є в L1 кеші.

2. Чому сигнатура persist() повертає void, а merge() повертає об’єкт T?

Відповідь:

  1. persist(e) мутує статус переданого Java-об’єкта прямо на місці (In-place mutation). Об’єкт e, на який у вас вже є посилання, сам стає керованим (Managed) у PersistenceContext. Повертати його немає сенсу.
  2. merge(e) не може зробити переданий об’єкт e керованим, тому що в PersistenceContext уже може знаходитися інший екземпляр для цього самого рядка БД (порушилася б Identity Guarantee). Тому merge() змушений повернути посилання на той екземпляр, який реально прив’язаний до сесії, залишаючи вихідний e у статусі Detached.

3. Ми передали об’єкт у стані Removed у метод persist(), і в метод merge(). Якими будуть результати?

Відповідь:

  1. persist(removedEntity): Специфікація JPA прямо дозволяє скасувати видалення. Статус сутності повертається в Managed, а запланований в ActionQueue запит SQL DELETE видаляється.
  2. merge(removedEntity): Специфікація JPA забороняє злиття видаленої сутності. Виклик entityManager.merge(removedEntity) викине виняток java.lang.IllegalArgumentException.

4. Що станеться, якщо передати Transient об’єкт із незаповненим ID у merge(), якщо у сутності стратегія ID — GenerationType.SEQUENCE?

Відповідь:

  1. Метод merge() розпізнає об’єкт як новий (оскільки id == null).
  2. Hibernate звернеться до бази даних за наступним значенням послідовності (SELECT nextval(...)).
  3. Створить новий екземпляр сутності, призначить йому згенерований ID, скопіює решту полів і переведе новий об’єкт у статус Managed.
  4. Вихідний об’єкт залишиться зі значенням id == null і в статусі Transient.
  5. Метод поверне новий керований об’єкт із уже призначеним ID.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь (Elevator Pitch)

«Метод persist() переводить нову (Transient) сутність у стан Managed. Він повертає void, переданий об’єкт стає керованим, а операція планує лише SQL INSERT без зайвих читань. Метод merge() переводить від’єднану (Detached) сутність у Managed. Він виконує SELECT, копіює поля у знову знайдений або створений об’єкт і повертає посилання на нього, залишаючи вихідний екземпляр Detached. У Spring Data JPA метод save() всередині себе перевіряє isNew(): для нових об’єктів викликає persist(), для існуючих — merge(). При ручному завданні ID сутності повинні реалізовувати Persistable<ID>, щоб уникнути зайвого SELECT перед вставкою».

Ключові метрики для Production-мониторингу

  • Кількість SELECT перед INSERT при операціях збереження: сигналізує про неефективне використання merge() для нових сутностей із призначеними ключами.
  • Тривалість виконання repository.save(): аномальне зростання вказує на каскадну перевірку великих графів об’єктів усередині merge().

Червоні прапорці (Чого говорити НЕ МОЖНА)

  • ❌ «Метод merge() робить вихідний переданий об’єкт керованим» — Груба помилка. Вихідний об’єкт залишається detached, повертається нова копія.
  • ❌ «Завжди використовую merge(), тому що він універсальний і сам вирішує, робити INSERT чи UPDATE» — Це породжує зайвий попередній SELECT та подвійний оверхед за пам’яттю.
  • ❌ «Після виклику entityManager.persist() одразу виконується SQL INSERT» — Тільки при стратегії IDENTITY. У всіх інших випадках INSERT відкладається до фази flush().
  • ❌ «persist() можна безпечно викликати для detached-об’єкта» — Викине EntityExistsException за наявності ID у сесії або базі.

Пов’язані теми