У чому різниця між 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):
- Hibernate витягує первинний ключ (
id) із переданого об’єкта. - Перевіряє наявність сутності з таким
idу кеші першого рівня L1 (entitiesByKey). - Якщо сутності в сесії немає — виконує SQL-запит
SELECTдо бази даних. - Якщо запис у БД знайдено:
- Створює/бере
Managedекземпляр. - Копіює всі поля з
detachedEntityу цейManagedекземпляр. - Планує операцію SQL
UPDATEвActionQueue(якщо Dirty Checking виявить відмінності).
- Створює/бере
- Якщо запис у БД не знайдено (або ID був
null):- Створює новий
Managedекземпляр. - Копіює поля.
- Планує операцію SQL
INSERT.
- Створює новий
- Повертає посилання на отриманий
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):
- За замовчуванням: якщо поле
@Idоб’єкта дорівнюєnull(або0для примітивів), об’єкт вважається новим (isNew = true), викликаєтьсяpersist(). - Якщо ID задається вручну (наприклад, бізнес-ключ
String uuid,taxNumberабо композитний ключ):- Поле ID вже заповнене до збереження (
id != null). - Spring помилково вважає сутність
Detached(isNew = false)! - Замість прямого
INSERTSpring викликаєentityManager.merge(entity). - Hibernate виконує паразитний попередній SQL
SELECT, отримуєnullі лише потім робитьINSERT.
- Поле ID вже заповнене до збереження (
Архітектурне рішення:
Сутність повинна реалізувати інтерфейс 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 колекціях
Уявімо сценарій:
- У першій транзакції завантажено
Departmentз лінивою колекцієюemployees(FetchType.LAZY). - Колекція
employeesне була зачеплена (залишилася неініціалізованим проксіPersistentBag). - Сесія закрилася, об’єкт став
Detached. - У другій транзакції викликається:
Department managedDept = entityManager.merge(detachedDept); - Внутрішня поведінка:
DefaultMergeEventListenerв Hibernate перевіряє стан колекцій. Якщо колекціяemployeesпозначенаCascadeType.MERGEабоALL, і вона не була ініціалізована:- Hibernate залишає існуючу колекцію в
managedDeptнепроініціалізованою. - Проте, якщо колекція була замінена в detached-об’єкті на звичайний
new ArrayList<>(), Hibernate порахує, що старі співробітники були видалені (Orphan Removal), і виконає їх видалення з БД!
- Hibernate залишає існуючу колекцію в
4 Tricky Questions
1. Що станеться, якщо в поточній сесії вже завантажено User(id=1) (Managed), а ми викликаємо entityManager.persist(user2) з тим же id=1? А якщо викликати entityManager.merge(user2)?
Відповідь:
- Для
persist(user2): Hibernate перевіряє картуentitiesByKey. Виявивши, що сутність з таким типом та ID вже знаходиться вPersistenceContext, він негайно викине виняток:jakarta.persistence.EntityExistsException(абоNonUniqueObjectException). - Для
merge(user2): Жодної помилки не буде. Hibernate візьме екземплярUser(id=1), що вже знаходиться в сесії, скопіює в нього поля зuser2і поверне посилання на існуючийManagedоб’єкт. Жодного нового SQLSELECTвиконуватися не буде, оскільки об’єкт уже є в L1 кеші.
2. Чому сигнатура persist() повертає void, а merge() повертає об’єкт T?
Відповідь:
persist(e)мутує статус переданого Java-об’єкта прямо на місці (In-place mutation). Об’єктe, на який у вас вже є посилання, сам стає керованим (Managed) уPersistenceContext. Повертати його немає сенсу.merge(e)не може зробити переданий об’єктeкерованим, тому що вPersistenceContextуже може знаходитися інший екземпляр для цього самого рядка БД (порушилася б Identity Guarantee). Томуmerge()змушений повернути посилання на той екземпляр, який реально прив’язаний до сесії, залишаючи вихіднийeу статусіDetached.
3. Ми передали об’єкт у стані Removed у метод persist(), і в метод merge(). Якими будуть результати?
Відповідь:
persist(removedEntity): Специфікація JPA прямо дозволяє скасувати видалення. Статус сутності повертається вManaged, а запланований вActionQueueзапит SQLDELETEвидаляється.merge(removedEntity): Специфікація JPA забороняє злиття видаленої сутності. ВикликentityManager.merge(removedEntity)викине винятокjava.lang.IllegalArgumentException.
4. Що станеться, якщо передати Transient об’єкт із незаповненим ID у merge(), якщо у сутності стратегія ID — GenerationType.SEQUENCE?
Відповідь:
- Метод
merge()розпізнає об’єкт як новий (оскількиid == null). - Hibernate звернеться до бази даних за наступним значенням послідовності (
SELECT nextval(...)). - Створить новий екземпляр сутності, призначить йому згенерований ID, скопіює решту полів і переведе новий об’єкт у статус
Managed. - Вихідний об’єкт залишиться зі значенням
id == nullі в статусіTransient. - Метод поверне новий керований об’єкт із уже призначеним ID.
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь (Elevator Pitch)
«Метод
persist()переводить нову (Transient) сутність у станManaged. Він повертаєvoid, переданий об’єкт стає керованим, а операція планує лише SQLINSERTбез зайвих читань. Метод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 у сесії або базі.