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 не можна використовувати як індикатор запису, зручно взяти окремий спрощений приклад зі стратегією 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 після persist = " + product.getId());
// у варіанті з SEQUENCE id зазвичай уже відомий, хоча INSERT ще може чекати flush

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

System.out.println("id після 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.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ