JavaRush /Курсы /Hibernate deep-dive /persist() и отложенны...

persist() и отложенный INSERT

Hibernate deep-dive
2 уровень , 1 лекция
Открыта

1. Путаница вокруг persist()

persist() не равен немедленному INSERT. Если вы когда-нибудь писали код в духе «создать объект — сохранить его в базу», то наверняка держали в голове простую и очень человеческую картинку: вызвал сохранение — и где-то там в PostgreSQL тут же появился INSERT. Модель приятная, как кнопка “Save” в Word: нажал — сохранилось. Но Hibernate живёт немного иначе, и именно из-за этого потом появляются сюрпризы в SQL-логе.

Путаница обычно рождается из трёх источников. Во‑первых, многие видели save() в Spring Data и бессознательно приравнивают любую операцию сохранения к немедленной записи в БД. Во‑вторых, нас обманывает сам код: строка entityManager.persist(product) выглядит как «я же сказал: сохрани!». В‑третьих, SQL выполняется не в момент “я нажал кнопку”, а в момент синхронизации контекста с БД — и это ломает интуицию.

Самый честный тезис лекции такой: persist() — это в первую очередь операция над памятью приложения (над persistence context), а не над таблицей в БД. База данных, конечно, в итоге поучаствует… но чуть позже.

Карта состояний у нас уже есть; теперь нужен первый конкретный переход. Новый объект должен перестать быть просто new Product() и попасть под управление Hibernate — и ровно это делает persist().

2. persist() как регистрация сущности

Проще всего понимать persist() как регистрацию новой сущности в persistence context. Представьте, что это не «магия Hibernate», а довольно приземлённый список объектов, за которыми ORM сейчас следит в рамках текущей работы. Тогда persist() — это команда: «Эй, Hibernate, вот этот объект — новый. Возьми его под управление». Только после этого объект становится настоящей ORM-сущностью, а не просто Java-классом с полями.

Давайте посмотрим на минимальный пример. До persist() объект — обычный transient: вы создали его через new, заполнили поля, но ORM про него не знает.

Product product = new Product();     // новый объект: ORM о нём ещё не знает
product.setSku("SKU-101");           // заполняем поля в памяти приложения
product.setName("Mouse");

// product: transient (ещё не в persistence context)
entityManager.persist(product);      // регистрируем объект в persistence context
// product: managed (но это ещё не гарантирует INSERT прямо сейчас)

Обратите внимание на важный нюанс: мы уже считаем объект managed сразу после persist(), даже если SQL-вставка в таблицу ещё не произошла. Состояние определяется отношением к контексту, а не тем, что вы уже увидели в PostgreSQL.

Проверить этот переход проще всего через contains(): нам нужен только ответ, связан ли конкретный экземпляр с текущим EntityManager.

// До persist() объект не находится под управлением текущего persistence context
System.out.println(entityManager.contains(product)); // false

entityManager.persist(product); // после этого объект становится managed

// После persist() объект уже в persistence context (даже если INSERT ещё не был выполнен)
System.out.println(entityManager.contains(product)); // true

Если contains() вернул true, объект уже находится в persistence context. Для этой лекции этого достаточно: persist() сработал как переход transient -> managed, даже если SQL ещё ждёт синхронизации.

3. Отложенный INSERT и unit of work

Теперь подходим к самой важной и чаще всего ломающей интуицию части: INSERT после persist() часто откладывается. Hibernate (и JPA в целом) работает не в режиме «одна строка кода — одна SQL-команда», а в модели unit of work: сначала копит изменения, потом синхронизирует их с БД.

Внутри unit of work Hibernate делает две крупные вещи. Сначала он собирает изменения: новые сущности, изменённые, удаляемые. Потом, когда приходит момент синхронизации, отправляет соответствующие INSERT/UPDATE/DELETE в базу. Это позволяет ему упорядочивать операции, учитывать зависимости, не гонять JDBC туда-сюда по каждой мелочи и держать в голове целостную картину.

Вот очень упрощённая схема того, что происходит с новой сущностью:

flowchart TD
    A["Java: new Product()"] --> B[transient]
    B -->|"persist()"| C["managed в persistence context"]
    C --> D["план INSERT внутри unit of work"]
    D -->|синхронизация контекста с БД| E["SQL INSERT уходит в PostgreSQL"]

Ключевая мысль: после persist() у Hibernate появляется “план вставки”, но сама вставка может произойти позже. Чаще всего — ближе к завершению транзакции. Иногда раньше, если системе нужно синхронизироваться, например чтобы корректно выполнить запрос, который зависит от этих данных. Но точное “когда именно” определяется фазой flush(). Здесь нам достаточно словаря: persist() планирует, а “вставить” — это отдельная фаза.

Если на этом месте появляется лёгкое раздражение уровня «ну почему нельзя просто сразу?», то поздравляю: вы человек. Hibernate тоже немного такой — просто очень бюрократичный: сначала кладёт бумажку в папку, потом относит в архив.

4. id: когда он появляется

Появление id не означает, что запись уже лежит в БД. Логика «если id появился, значит всё сохранилось» звучит естественно, но в Hibernate это не всегда верное умозаключение.

Чтобы увидеть, почему id нельзя использовать как индикатор записи, удобно взять отдельный упрощённый mapping с SEQUENCE. Здесь важна не конкретная сущность проекта, а сам принцип: момент появления id зависит от стратегии генерации.

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
class Product {
  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE) // ID может быть получен из sequence ещё до INSERT
  private Long id; // это поле заполняется "внутренними" механизмами ORM/БД
}

Почему это важно именно в лекции про persist()? Потому что разные стратегии генерации id ведут себя по-разному. Иногда Hibernate может получить id до реального INSERT — например, через sequence. Иногда id появляется только после вставки — например, при identity-подходе. В обоих случаях смысл persist() одинаковый: объект стал managed. Но ваши наблюдения в коде будут разными, и это часто сбивает новичков.

Пример “в лоб”: вы делаете persist() и печатаете id.

entityManager.persist(product); // объект уже managed, но SQL может ещё не уйти в БД
System.out.println(product.getId()); // может быть null, а может уже быть числом

И вот тут начинается магия в плохом смысле: один человек видит null и думает, что persist «не сработал». Другой видит 123 и думает, что строка уже точно в БД. На практике оба могут ошибаться.

Есть два важных “якоря”, которые помогают сохранять спокойствие:

Во‑первых, состояние managed определяется не id, а связью с persistence context. То есть entityManager.contains(product) — более честная проверка, чем product.getId().

Во‑вторых, даже если id уже есть, это не гарантирует коммит транзакции. Если транзакция потом откатится, вы получите очень странную ситуацию: product.getId() не null, объект был managed, но строки в базе нет. И это не баг — это просто реальность транзакций.

Детали стратегий генерации сейчас не нужны; нам достаточно главного: не используйте id как универсальный индикатор “записано в базу”.

5. Как увидеть реальный INSERT: SQL trace и flush()

Увидеть реальный INSERT можно двумя способами: смотреть SQL trace и, для экспериментов, вызывать flush(). Когда мы говорим «вставка произойдёт позже», у студента обычно возникает резонный вопрос: «Окей, а как мне увидеть, когда именно она произошла?» Правильный ответ курса — через наблюдаемость: SQL trace. Мы для этого и включали логирование SQL в первый уровень. Hibernate не телепат: в итоге он всё равно говорит с базой через SQL, и именно это нужно читать.

Есть и второй удобный учебный приём: сделать момент вставки явным, вызвав синхронизацию вручную. В JPA для этого есть entityManager.flush(). Сегодня flush() нужен нам как фонарик: он делает момент SQL видимым. Здесь — без философии, просто наблюдение.

Вот пример, который хорошо работает как «рентген»:

entityManager.persist(product); // регистрируем сущность (INSERT ещё может не выполняться)

System.out.println("id after persist = " + product.getId());
// в варианте с SEQUENCE id обычно уже известен, хотя INSERT ещё может ждать flush

entityManager.flush(); // принудительная синхронизация: здесь Hibernate обязан отправить INSERT

System.out.println("id after flush = " + product.getId());
// после flush INSERT уже ушёл, и id точно синхронизирован с записью

Если у вас включён SQL trace, вы увидите в логе примерно такую картину (упрощённо, без всех колонок):

Для упрощённого варианта с SEQUENCE лог часто выглядит примерно так:

-- Примерно так ORM "добывает" ID (sequence) и затем делает реальную вставку
select nextval('product_seq');
insert into products (id, sku, name) values (?, ?, ?);

В других стратегиях ритм будет другим: где-то id появится позже, где-то сам INSERT случится раньше, чем вы ожидали. Важен не конкретный SQL, а наблюдение: между persist() и моментом фактической вставки есть промежуток, и он управляется фазой синхронизации.

Тут обычно происходит маленькое прозрение: «Ага, значит persist() — это не SQL, а регистрация плюс планирование». Именно это прозрение нам сегодня и нужно.

6. persist() в Commerce Persistence Lab

Теперь приземлим всё на наш сквозной проект. Напомню контекст: Commerce Persistence Lab — это лабораторный бэкенд, где мы моделируем каталог (Product), клиентов (Customer) и заказы (PurchaseOrder). Сейчас нам не нужен ни REST-слой, ни «настоящий интернет-магазин»; нам нужна воспроизводимая среда, где видно, что происходит с сущностью в памяти и какой SQL реально уходит в PostgreSQL.

Самый простой и честный способ показать persist() в проекте — написать небольшой сервис записи, который создаёт сущность через EntityManager. Мы намеренно используем EntityManager, потому что здесь нам важна прямая семантика JPA, а не то, как всё скрыто за абстракцией репозитория.

Небольшой фрагмент сервиса каталога может выглядеть так (сильно упрощённо):

import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;

@Transactional // граница unit of work: внутри неё Hibernate копит изменения и потом синхронизирует их с БД
public Long createProduct(String sku, String name) {
  Product product = new Product(sku, name); // transient: объект пока "живет" только в памяти
  entityManager.persist(product);           // managed: добавили в persistence context (INSERT будет позже)
  return product.getId();                   // ID может быть уже назначен, но это не равно "коммит в БД"
}

Здесь важно не то, что метод возвращает id (хотя это удобно), а то, что после persist() объект стал частью текущего unit of work. Он будет жить внутри контекста до конца транзакции, и Hibernate будет считать его «своим».

Точно так же можно создавать и заказ (опять же, минимально):

import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;

@Transactional // без транзакции unit of work нормально не работает
public Long createOrder(String orderNumber) {
  PurchaseOrder order = new PurchaseOrder(orderNumber); // transient
  entityManager.persist(order);                          // managed: ORM "взял под управление"
  return order.getId();                                  // то же правило: ID не равен гарантированному коммиту
}

Если вы включите SQL trace и запустите такие сценарии, то увидите “ритм” Hibernate. Он не обязан сразу отправлять SQL на каждой строке кода, но в конце транзакции (или при явной синхронизации) сделает нужные INSERT.

Отдельный бытовой момент: persist() имеет смысл именно для нового объекта. Если вы начинаете использовать persist() как «универсальную кнопку сохранения всего подряд», вы довольно быстро наткнётесь на ошибки уровня «entity already exists» или «detached entity passed to persist». Это не потому, что Hibernate вредный. Просто у persist() очень конкретная роль: ввести новую сущность в контекст.

7. Поздние ошибки целостности при persist()

Ошибки целостности при persist() часто всплывают позже, а не в момент вызова. Вы вызываете persist(), код бодро идёт дальше, а потом — бац — исключение. И вы смотрите на stacktrace и думаете: «Почему оно упало не на persist(), а где-то в конце метода или вообще при выходе из него?»

Причина почти всегда одна и та же: реальный INSERT улетел в базу не в момент persist(), а позже. А значит, и проверка ограничений БД — уникальности, NOT NULL, внешних ключей — тоже происходит позже.

Классический пример из нашего домена: у Product есть уникальный sku. Представьте, что вы создали два товара с одним и тем же sku в рамках одной транзакции. На уровне Java это выглядит невинно: два объекта, два persist(). Но PostgreSQL на вставке второго скажет: «Нет, так нельзя».

В коде это может выглядеть так:

Product a = new Product("SKU-777", "Mouse");
Product b = new Product("SKU-777", "Mouse 2");

entityManager.persist(a);
entityManager.persist(b);

entityManager.flush(); // исключение будет здесь, а не на persist()

Что важно понять: persist() не обязан «ходить в базу и проверять всё на месте». Его задача — поставить сущность в план. Проверка ограничений — задача базы в момент реального SQL. Поэтому в ORM-мире нормально, что ошибка вылезает позже. И это не повод злиться на Hibernate; это повод помнить, что у нас две реальности: реальность контекста в памяти и реальность таблиц в БД, и они синхронизируются не каждую миллисекунду.

И вот тут SQL trace снова становится лучшим другом. Он помогает увидеть, в какой момент реально улетел INSERT, и почему именно он привёл к ошибке.

8. Типичные ошибки при работе с persist()

В этой лекции мы разобрали persist() как «ввод в контекст», а не как «немедленный SQL». И теперь самое время проговорить грабли, на которые наступают даже хорошие разработчики, когда в голове всё ещё живёт старая модель «вызвал метод — запись уже в БД». Эти ошибки особенно коварны тем, что проявляются не сразу, а через странные логи, неожиданные исключения и реплики в духе «у меня сработало, а на проде упало».

Ошибка №1: думать, что persist() — это INSERT “прямо сейчас”.
Это самая базовая ловушка. Вы пишете persist(), ожидаете мгновенную запись, а потом удивляетесь, что в SQL-логе пока тишина. Hibernate мог просто ещё не синхронизировать контекст с БД. Если вы хотите увидеть момент записи, смотрите на SQL trace или используйте flush() как учебный «фонарик».

Ошибка №2: использовать id как индикатор “уже лежит в таблице”.
В зависимости от стратегии генерации идентификатора id может появиться раньше INSERT, позже INSERT или вообще быть назначенным вручную. А ещё транзакция может откатиться. Поэтому id != null — это полезный факт, но не доказательство того, что строка уже существует в базе и пережила коммит.

Ошибка №3: вызывать persist() для объекта, который не является новым (transient).
persist() — это операция «для новичков» в хорошем смысле: она вводит новый объект в контекст. Если объект уже существующий, уже был сохранён раньше или вообще пришёл к вам как detached-объект, persist() может привести к исключениям. Hibernate ожидает от вас другого сценария работы, поэтому не превращайте persist() в универсальную кнопку.

Ошибка №4: забыть про транзакцию и ожидать нормального поведения.
Сохранение — это почти всегда транзакционная история. В Spring-приложении это обычно означает @Transactional на сервисном методе. Если вы вызываете persist() в странном месте без транзакционной границы, вы рискуете получить ошибки “transaction required” или очень трудно объяснимое поведение “вроде persist был, а данных нет”. ORM в вакууме не живёт: ему нужен контекст, и часто ему нужна транзакция.

Ошибка №5: удивляться “поздним” исключениям (unique constraint, NOT NULL) и искать проблему не там.
Если ошибка БД проявилась на flush() или на выходе из транзакции — это логично: именно там мог улететь реальный INSERT. Не нужно воспринимать это как «Hibernate сломался». Это нормальная цена отложенной синхронизации: проблемы целостности проявляются тогда, когда SQL реально пошёл в PostgreSQL.

1
Задача
Hibernate deep-dive, 2 уровень, 1 лекция
Недоступна
`persist()` переводит новый объект в `managed`
`persist()` переводит новый объект в `managed`
1
Задача
Hibernate deep-dive, 2 уровень, 1 лекция
Недоступна
SQL-видимость новой строки после `flush()`
SQL-видимость новой строки после `flush()`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ