JavaRush /Курси /Hibernate deep-dive /flush: фаза синхроні...

flush: фаза синхронізації

Hibernate deep-dive
Рівень 4 , Лекція 0
Відкрита

1. Симптом: SQL зʼявляється раніше, ніж завершується транзакція

Якщо ви вже дивилися на SQL-лог (а на цьому курсі це майже як звіряти погоду перед виходом на вулицю), то, напевно, ловили дивне відчуття: ви ще всередині сервісного методу, код ще не завершився, а в логах уже промайнув UPDATE або INSERT. І в голові одразу народжується побутове пояснення: «Напевно, Hibernate сам усе закомітив». Спойлер: ні.

Ми часто змішуємо в одну купу три різні події: зміну обʼєкта в памʼяті, надсилання SQL до бази та завершення транзакції. У «ручному SQL» вони зазвичай ідуть майже підряд: викликали UPDATE — він полетів — закомітили. Тож мозок звикає до простої схеми: «SQL = фінал». У Hibernate ці події можуть бути рознесені в часі. І слово, яке повʼязує dirty checking із SQL, — flush.

Ми вже бачили, що Hibernate вміє помічати зміну managed-сутності ще до будь-якого SQL. Тепер потрібен наступний шматок картини: у який момент ця помічена зміна взагалі перетворюється на запит до бази і чому це окрема фаза, а не інша назва commit.

Три світи: обʼєкт у памʼяті, persistence context і БД

Щоб flush перестав здаватися магією, корисно навести лад у «географії» застосунку. Уявіть, що в нас є три різні «майданчики», і одне й те саме поле product.name може одночасно існувати в трьох станах. Звучить тривожно, але для ORM це нормальне життя: він саме для цього і потрібен — щоб цим керувати.

Перший світ — це звичайний Java-обʼєкт у памʼяті. Ви викликаєте product.setName("Phone Pro") і змінюєте поле в обʼєкті. Це відбувається миттєво: без мережі, без JDBC і без бази. Другий світ — це persistence context (контекст, де Hibernate тримає managed-сутності, snapshots і чергу змін). Третій світ — це реальна база даних (PostgreSQL у нашому Commerce Persistence Lab), де значення зберігається в рядку таблиці.

Зручно тримати це так (проста схема):

flowchart LR
    A["Java-обʼєкт (Product): поля змінюються через сетери"] --> B["Persistence Context: керована сутність + snapshots + черга дій"]
    B --> C["База даних (PostgreSQL): рядки таблиць"]

Ключовий момент такий: коли ви змінюєте managed-обʼєкт, зміни одразу відбуваються у світі A (обʼєкт), і Hibernate починає розуміти, що snapshot відрізняється (світ B), але це ще не зобовʼязано негайно змінювати світ C (БД). Перехід B → C якраз і називається flush.

3. Що таке flush — без містики

У побутовій англійській слово flush і справді нагадує змив у туалеті, тому інколи хочеться думати: «натиснув flush — і все полетіло назавжди». У Hibernate зміст інший і значно більш інженерний. flush — це синхронізація стану persistence context з базою даних: Hibernate бере накопичені зміни managed-сутностей, перетворює їх на SQL-команди (INSERT/UPDATE/DELETE) і надсилає їх до БД в межах поточної транзакції.

Важливо: flush не «шукає» зміни з повітря. Він спирається на dirty checking: Hibernate фіксує, що саме змінилося, і готується до того, що під час синхронізації це доведеться записати в базу. Послідовність тут така: ми змінили обʼєкт → Hibernate це помітив (dirty checking) → під час flush це перетворюється на реальний SQL.

Можна уявити це як дуже короткий внутрішній алгоритм (не точний байткод Hibernate, але правильна модель):

// Модель: що приблизно відбувається всередині Hibernate під час синхронізації
1) У persistence context є managed-обʼєкти.
2) Для кожного managed-обʼєкта є snapshot.
3) Перед flush Hibernate порівнює snapshot і поточний стан.
4) Якщо є відмінності — формує SQL.
5) Надсилає SQL до БД (але транзакцію не закриває).

Звідси вже видно, чому flush — це не commit: flush надсилає SQL, а commit завершує транзакцію. Надіслати SQL можна кілька разів за одну транзакцію, а «закрити угоду» — лише один раз.

4. flush і commit: різні рівні фінальності

Плутанина між flush і commit насамперед дорого обходиться тому, що змушує неправильно читати SQL-лог. Коли ви бачите UPDATE у логах, інтуїтивно хочеться вирішити: «усе, запис точно збережено». Але в транзакційному світі така «точність» настає під час commit, а не під час flush.

Давайте акуратно порівняємо:

Характеристика flush commit
Що робить Синхронізує зміни з persistence context до БД, надсилаючи SQL Завершує транзакцію в БД (фіксує зміни)
Може відбуватися кілька разів в одній транзакції Так Ні (логічно один раз на транзакцію)
Гарантує, що зміни «залишаться назавжди» Ні (можливий rollback) Так (якщо commit успішний)
Може спричинити помилку constraint/унікальності раніше, ніж завершиться метод Так Так, але часто ви побачите проблему пізніше, якщо не було flush
Повʼязаний із dirty checking Безпосередньо: flush використовує результати dirty checking Опосередковано: commit зазвичай викликає flush або приводить до нього

Якщо хочеться більш «людської» аналогії, використовуйте таку: flush — це як натиснути Ctrl+S (зберегти чернетку на диск), а commit — як натиснути Publish (опублікувати статтю) або «завершити оплату». Ви могли зберегти файл, а потім скасувати зміни, відкотити гілку або взагалі не надіслати їх користувачу. У транзакції роль «скасувати» виконує rollback.

І ще важливий нюанс: flush відбувається всередині транзакції. Це означає, що SQL виконується в БД, але інші транзакції зазвичай не бачать цих змін до commit (ми не заглиблюємося в ізоляцію та рівні видимості, просто фіксуємо базову ідею).

5. Приклади flush: три мінісцени

Приклад 1: setter не дорівнює SQL

Ключова думка тут така: сетер — це не SQL. Навіть якщо ви змінили managed entity, Hibernate не зобовʼязаний у ту ж мілісекунду писати UPDATE. Він може зачекати до моменту синхронізації. І це не «лінощі», а нормальна архітектура unit of work.

Уявімо, що ми працюємо в Commerce Persistence Lab і змінюємо імʼя товару. Приклад навмисно маленький — щоб побачити суть, а не потонути у шарах.

import com.example.commerce.catalog.entity.Product;
import jakarta.persistence.EntityManager;

// Отримуємо managed-сутність: вона потрапляє в persistence context (світ B)
Product product = entityManager.find(Product.class, 1L);

// Змінюємо поле лише в памʼяті (світ A)
product.setName("Phone Pro");

// У цей момент Product уже змінено в памʼяті,
// Hibernate помітив зміну (dirty checking),
// але UPDATE ще не зобовʼязаний бути надісланий у БД (світ C).

За змістом відбувається ось що: find() повертає managed-обʼєкт. Ви змінюєте поле, dirty checking запамʼятовує, що обʼєкт «брудний», тобто відрізняється від snapshot. Але надсилати SQL поки зарано: Hibernate ще не дійшов до точки синхронізації.

І ось тут потрібна дисципліна: якщо дивитися лише на Java-код, легко почати думати: «чому UPDATE у логах іноді зʼявляється, а іноді ні?». Відповідь завжди одна: дивіться на момент flush, а не на момент виклику сетера.

Приклад 2: явний flush() як точка синхронізації

Тепер додамо рівно один рядок — entityManager.flush(). Це як поставити в коді прапорець: «Ось тут я хочу, щоб усе накопичене перетворилося на реальний SQL і пішло в базу».

import com.example.commerce.catalog.entity.Product;
import jakarta.persistence.EntityManager;

Product product = entityManager.find(Product.class, 1L);
product.setName("Phone Pro");

// Явно просимо Hibernate синхронізувати накопичені зміни з БД.
// Важливо: це НЕ commit, транзакція залишається відкритою.
entityManager.flush(); // тут Hibernate зобовʼязаний надіслати SQL

Якщо у вас увімкнено SQL trace (а в нас його вмикає профіль), то в логах ви побачите щось дуже схоже на:

-- Приклад SQL, який може зʼявитися відразу після flush()
update product set name=? where id=?

Ще раз: flush() не означає commit. Він означає: «надішли SQL просто зараз, але транзакцію не закривай». Як інструмент спостереження та діагностики це дуже зручно: ви можете спеціально поставити точку синхронізації й побачити поведінку системи ще до завершення методу.

Приклад 3: flush надіслав SQL, але потім стався rollback

Найпідступніший для новачка сценарій виглядає так: ви викликаєте flush(), бачите SQL у логах, радієте, а потім наприкінці методу прилітає виняток, транзакція відкочується — і в базі раптом «нічого не змінилося». Здається, що база «ігнорує SQL». Насправді база чесно виконала SQL у межах транзакції, а потім чесно відкотила саму транзакцію.

Розглянемо спрощений сервісний сценарій (у стилі labsupport, щоб не змішувати його з «бойовою» бізнес-логікою). Ми спеціально кидаємо виняток після flush.

import com.example.commerce.catalog.entity.Product;
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;

@Transactional
public void renameProductAndFail(EntityManager entityManager) {
    // Початок транзакції на рівні Spring (межі важливі для розуміння rollback)
    Product product = entityManager.find(Product.class, 1L);

    // Змінюємо managed-сутність: поки що ця зміна живе в persistence context
    product.setName("Draft name");

    // Примусова синхронізація: SQL піде в БД в межах ВІДКРИТОЇ транзакції
    entityManager.flush();                  // SQL пішов

    // Виняток призводить до rollback: зміни не стануть "назавжди"
    throw new RuntimeException("rollback"); // але commit не відбудеться
}

Що ви маєте винести з цього прикладу як мантру:

flush може призвести до реального SQL, але до commit це все ще можна відкотити.

Так, є тонкі нюанси на кшталт послідовностей (sequence) у PostgreSQL, які можуть збільшити значення навіть під час rollback, але це окрема історія про генерацію ідентифікаторів. У контексті сьогоднішньої лекції достатньо запамʼятати базову річ: дані, змінені через INSERT/UPDATE/DELETE всередині транзакції, не вважаються «назавжди», доки транзакцію не закомічено.

6. flush у unit of work: коротка тимчасова шкала

Коли ви читаєте SQL-лог на цьому курсі, дуже допомагає тримати в голові не «рядки Java», а «фази unit of work». У Hibernate майже все цікаве відбувається саме на межах фаз. flush — одна з таких меж: вона відділяє «зміни накопичено в памʼяті» від «зміни надіслано до БД».

Зручна тимчасова шкала виглядає так:

sequenceDiagram
    participant Code as Сервісний Java-код
    participant PC as Persistence Context
    participant DB as PostgreSQL

    Code->>PC: "find() -> managed entity"
    Code->>PC: "setName(\"Phone Pro\")"
    Note over PC: dirty checking фіксує зміну
    Code->>PC: "flush() (явно або автоматично)"
    PC->>DB: "надсилання SQL (UPDATE/INSERT/DELETE)"
    Note over DB: SQL виконано, але транзакція ще відкрита
    Code->>DB: "commit (у кінці транзакції)"

Ця картинка потрібна не заради краси, а для щоденної практики: ви бачитимете SQL «раніше за завершення методу», і ваше завдання — не панікувати, а спитати себе: «де тут flush?». Якщо flush був — усе логічно. Якщо ви його явно не викликали, значить, спрацював автоматичний тригер синхронізації, і саме він зазвичай створює відчуття, що SQL «вистрілив сам».

7. Типові помилки під час першого знайомства з flush

Перш ніж рухатися далі, корисно зафіксувати типові помилки. Вони підступні тим, що зовні все виглядає «працюючим», а модель у голові збирається неправильною. А неправильна модель у persistence layer майже завжди закінчується тим, що ви лікуєте симптоми, а не причину.

Помилка № 1: вважати, що flush і commit — це одне й те саме.
Якщо ви так думаєте, будь-який SQL посеред методу здаватиметься «раннім комітом», і ви почнете підозрювати Spring, Hibernate, фазу Місяця та сусіда по кріслі. Правильна модель простіша: flush надсилає SQL у межах відкритої транзакції, а commit — закриває транзакцію і робить зміни остаточними.

Помилка № 2: чекати SQL у момент виклику сетера.
Новачки іноді читають код так: «викликав setName() — значить, зараз буде UPDATE». Але сетер змінює Java-обʼєкт. Hibernate може зачекати до точки синхронізації. У підсумку людина дивиться на лог, не бачить UPDATE, додає save(), потім додає ще один save(), а потім дивується, чому запитів стало більше, а ясності — менше.

Помилка № 3: ігнорувати dirty checking і думати, що flush «сам усе вирішить».
flush — не магічна команда «збережи все підряд». Він синхронізує те, що Hibernate вважає зміненим. Якщо змін немає — flush() може взагалі не дати жодного UPDATE. І навпаки: якщо ви випадково змінили поле (наприклад, мутували value object), то flush чесно надішле SQL. Без моделі dirty checking це сприйматиметься як хаос.

Помилка № 4: робити висновки лише за Java-кодом, не дивлячись на SQL-лог.
Hibernate багато чого робить під час виконання, і значна частина його поведінки проявляється лише в SQL. За кодом ви бачите «я змінив імʼя», а по SQL — «у який момент це стало UPDATE». Якщо копати глибше, це різні запитання. І якщо ви хочете перестати «вірити ORM на слово», доведеться звикнути: SQL-лог — ваш детектор правди.

Помилка № 5: радіти flush() як «кнопці надійності» і ставити її всюди.
Після першого «осяяння» інколи зʼявляється нова релігія: «давайте після кожної зміни робити flush(), тоді точно все буде ок». Це приблизно як лікувати будь-яку проблему System.out.println() — інколи допомагає, але частіше просто робить усе повільнішим і шумнішим. flush має бути усвідомленою точкою синхронізації, а не ритуалом.

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