💾 Раздел 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 в сессии или базе.

Связанные темы