flush і commit

Spring Data JPA
Рівень 20 , Лекція 3
Відкрита

1. Коли змінюється поле й коли виконується SQL

Якщо ви лише починаєте, дуже легко мислити так: «Я викликав setName(), отже в базі вже є нове імʼя». Потім ви вмикаєте SQL-логи й бачите, що UPDATE зʼявляється не там, де ви його чекали. А іноді — навпаки: UPDATE «вилітає» раптово, ще до завершення методу. Справді здається, що це фокус. Насправді ж перед вами цілком логічна інженерна модель.

Hibernate, як і будь-яка ORM, працює за простим принципом: спочатку акуратно збирає зміни в памʼяті, а вже потім синхронізує їх із базою в зручний момент. Усередині транзакції це схоже на чернетку: ви редагуєте дані, Hibernate позначає, що саме змінилося, а SQL надсилає тоді, коли настав час синхронізації. Найважливіше — запамʼятати три різні етапи: виявлення змін (dirty checking), надсилання SQL (flush) і фінальну фіксацію (commit).

Щоб не тримати це в голові як три окремі фрази, давайте зробимо коротку карту процесу.

sequenceDiagram
    participant S as "Метод сервісу (@Transactional)"
    participant PC as Persistence Context
    participant DB as PostgreSQL

    S->>DB: "SELECT (findById)"
    DB-->>S: рядок -> сутність
    S->>PC: сутність стає керованою
    S->>PC: "змінюємо поле (dirty)"
    Note over PC: dirty checking позначає сутність як змінену
    S->>PC: flush (автоматичний або явний)
    PC->>DB: Виконується SQL UPDATE / INSERT / DELETE
    S->>DB: COMMIT

2. flush: синхронізація з базою

Слово flush буквально означає «змити», і це непогана асоціація: Hibernate «змиває» накопичені зміни з persistence context у базу даних, тобто виконує SQL, що відповідає вашим змінам. При цьому транзакція не завершується і зміни не стають «вічними». Програма просто доводить стан бази до моменту, коли в межах поточної транзакції виконано потрібні INSERT, UPDATE або DELETE.

Корисно уявляти flush як момент, коли Hibernate перестає тримати зміни лише «в голові» й справді надсилає їх у PostgreSQL. Але важливо не переплутати: після flush обʼєкт і далі залишається managed, persistence context нікуди не зникає, а транзакцію ще можна відкликати. Якщо ви викликали flush(), а потім кинули виняток, то з високою ймовірністю спрацює rollback — і база повернеться до початкового стану, ніби ви нічого не робили, принаймні з погляду фінального результату.

Ось невелика таблиця — її зручно тримати в голові як шпаргалку:

Подія Що відбувається Чого не відбувається
dirty checking Hibernate помічає, що керовану сутність змінили SQL ще може не надсилатися
flush Hibernate надсилає SQL у БД (у межах поточної транзакції) Транзакція ще не фіксується назавжди
commit БД фіксує зміни й робить їх видимими іншим транзакціям Не зобовʼязаний уперше надсилати SQL — він міг піти раніше на flush

Тепер — до практичного відчуття. Якщо ви змінюєте поле в керованій сутності, UPDATE може чекати до flush. А flush може статися автоматично або явно.

3. commit: фіксація транзакції

Після слова flush мозок часто хоче заспокоїтися: «Ну все, SQL пішов, отже збережено». Але транзакційний світ влаштований трохи хитріше: SQL може бути виконаний, та поки транзакцію не зафіксовано, зміни не вважаються остаточно прийнятими. commit — це момент, коли база каже: «Гаразд, це вже істина, це durable state».

І тут є тонкий, але важливий момент: commit — це не операція Hibernate, а операція бази даних — точніше, JDBC-транзакції, якою керує Spring через transaction manager. Hibernate бере участь у цьому процесі як виконавець, який перед комітом зобовʼязаний переконатися, що зміни синхронізовано. Тому перед commit майже завжди відбувається flush, навіть якщо ви явно його не викликали.

Якщо хочеться зовсім простої аналогії, то flush — це як «я відправив документ на принтер, він уже друкується», а commit — «я поставив підпис і відправив у архів, тепер скасувати не можна». До коміту ви все ще можете сказати: «Стоп, я передумав» — і зробити rollback.

4. Авто-flush: коміт і запити

Тут багато хто вперше перестає злитися на Hibernate й починає з ним дружити. Hibernate не робить flush «просто так». Він робить його в ситуаціях, коли інакше порушиться логіка unit of work. Два найчастіші сценарії — перед комітом і перед виконанням запиту, результат якого має враховувати ваші зміни.

Авто-flush перед commit

Наприкінці транзакції Spring виконає commit — якщо все добре — або rollback, якщо станеться виняток. Перед commit Hibernate зобовʼязаний синхронізувати зміни з БД, інакше комітити просто нічого. Тому якщо ви нічого явно не викликали, типовий потік такий: ви змінюєте поля в керованій сутності → Hibernate позначає зміни → наприкінці транзакції виконується flush → потім commit.

Ось приклад, який зазвичай дивує новачків: тут немає save(), але в базі все одно щось змінюється.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductAdminService {

    private final ProductRepository productRepository;

    public ProductAdminService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void renameProduct(Long productId, String newName) {
        // Завантажуємо сутність: після findById вона стає керованою в persistence context
        Product product = productRepository.findById(productId).orElseThrow();

        // Змінюємо поле в керованій сутності: dirty checking запамʼятає цю зміну
        product.setName(newName);

        // UPDATE зазвичай піде на auto-flush перед commit
    }
}

Якщо ви ввімкнули SQL-логи, UPDATE побачите ближче до завершення транзакції — часто просто перед commit. І це нормально.

Авто-flush перед SELECT-запитом

Ось тут починається найцікавіше. Припустімо, усередині транзакції ви змінили сутність, а потім викликали репозиторний метод, який виконує SELECT і має врахувати ці зміни. Якщо Hibernate не зробить flush перед запитом, ви отримаєте парадокс: у памʼяті сутність уже оновлена, а запит у БД виконається за старим станом і може повернути інший результат. Щоб не ламати принцип «прочитати те, що самі щойно записали» в межах unit of work, Hibernate часто виконує flush перед запитами.

Покажімо це на прикладі: змінюємо статус товару, а потім читаємо список активних товарів.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogReadWriteService {

    private final ProductRepository productRepository;

    public CatalogReadWriteService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void activateAndQuery(Long productId) {
        // Отримуємо керовану сутність у межах транзакції
        Product product = productRepository.findById(productId).orElseThrow();

        // Змінюємо стан: Hibernate позначить entity як dirty
        product.setStatus(ProductStatus.ACTIVE);

        // Далі йде запит, який має "побачити" свіжі зміни в межах unit of work
        productRepository.findByStatus(ProductStatus.ACTIVE);
        // Перед SELECT Hibernate може зробити flush -> UPDATE піде ДО SELECT
    }
}

Якщо ви подивитеся SQL-логи, вас може здивувати порядок: спочатку UPDATE, потім SELECT. Але це не дивина. Це Hibernate намагається зробити так, щоб запит у межах тієї самої транзакції побачив ваші зміни.

5. Явний flush: коли він потрібен

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

Один із найзрозуміліших сенсів явного flush — «fail fast». Припустімо, ви зробили кілька кроків бізнес-операції й хочете переконатися, що база вже прийняла зміни. Тоді ви викликаєте flush(), раніше отримуєте можливий виняток і не продовжуєте зайву роботу. Навіть без занурення в деталі обмежень сам принцип «спіймати помилку раніше» вже зараз дуже корисний.

Давайте подивимося на демонстрацію: ми змінюємо дані, робимо flush, а потім спеціально падаємо, щоб побачити, що flush не дорівнює commit.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class FlushDemoService {

    private final ProductRepository productRepository;

    public FlushDemoService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void flushButRollback(Long productId) {
        // Змінюємо керовану сутність: зміна поки "в контексті", а не обовʼязково в БД
        Product product = productRepository.findById(productId).orElseThrow();
        product.setName("Імʼя для demo flush");

        // Явно просимо Hibernate надіслати SQL у БД, але транзакцію ще не зафіксовано
        productRepository.flush(); // SQL може піти в БД просто зараз

        // Спеціально падаємо, щоб побачити rollback після вже виконаного SQL
        throw new IllegalStateException("Бах! Будь ласка, rollback");
    }
}

Якщо ви запустите це й потім перевірите базу, то побачите, що підсумкове значення не змінилося, тому що транзакцію відкликали. Тобто SQL міг виконатися, але фінального commit не сталося.

Ще один корисний сенс явного flush — діагностика. Коли ви вчитеся, дуже зручно вставити flush і побачити, у який момент Hibernate реально надсилає SQL. Це як поставити System.out.println() у потрібному місці, тільки для бази.

Але ось чого не варто робити: перетворювати flush() на «обовʼязковий фінальний штамп» після кожного save() або після кожного setXxx(). Зазвичай це робить код шумнішим, а транзакцію — менш ефективною, бо ви змушуєте Hibernate занадто часто синхронізуватися з БД.

6. flush у Spring Data і JPA

На практиці ви зустрінете три основні способи ініціювати flush. І тут важливо не влаштовувати «релігійну війну», а зрозуміти, що це просто різні рівні API. У нашому навчальному проєкті shop-data-jpa найчастіше достатньо репозиторних методів — вони зрозуміліші студенту й читаються як «ось тут ми явно синхронізуємо зміни».

Спочатку подивімося на коротку таблицю:

Інструмент Де знаходиться Коли зручно
productRepository.flush() Spring Data JpaRepository Коли ви працюєте через репозиторій і хочете явно синхронізувати зміни
productRepository.saveAndFlush(entity) Spring Data JpaRepository Коли ви зберігаєте новий обʼєкт і хочете одразу надіслати INSERT
entityManager.flush() JPA API (EntityManager) Коли ви працюєте на нижчому рівні й хочете показати, що відбувається «під капотом»

flush() на репозиторії

Це найзрозуміліший варіант: «Я працюю з ProductRepository, тож і кажу йому: синхронізуйся». У проєкті це виглядає природно.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogFlushService {

    private final ProductRepository productRepository;

    public CatalogFlushService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void updatePriceAndFlush(Long productId) {
        // Змінюємо керовану сутність: Hibernate накопичить зміну до flush/commit
        Product product = productRepository.findById(productId).orElseThrow();
        product.setPrice(product.getPrice().add(new java.math.BigDecimal("10.00")));

        // Явно синхронізуємося з БД, не завершуючи транзакцію
        productRepository.flush(); // SQL UPDATE піде раніше за завершення транзакції
    }
}

saveAndFlush(): «збережи й одразу синхронізуй»

Ця можливість корисна, коли ви створюєте новий обʼєкт, викликаєте save(), і вам з якоїсь причини важливо, щоб INSERT пішов просто зараз, а не колись на auto-flush. У звичайних бізнес-сценаріях це трапляється нечасто, але в навчальних лабораторіях і під час діагностики — дуже зручно.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CategoryCreateService {

    private final CategoryRepository categoryRepository;

    public CategoryCreateService(CategoryRepository categoryRepository) {
        this.categoryRepository = categoryRepository;
    }

    @Transactional
    public Long createAndFlush() {
        Category category = new Category();
        category.setCode("TEA");
        category.setName("Tea");

        // saveAndFlush = зберегти (persist/merge) + одразу надіслати SQL (flush), але не commit
        Category saved = categoryRepository.saveAndFlush(category);
        return saved.getId();
    }
}

Зверніть увагу: saveAndFlush() не «робить коміт». Він просто виконує save, потім flush, а транзакція все ще триває.

EntityManager.flush(): корисно розуміти

Оскільки ми все ж вивчаємо data layer, корисно один раз побачити EntityManager і зрозуміти, що репозиторії — це зручна обгортка. Але перетворювати проєкт на «ручне програмування через EntityManager» ми не будемо.

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

@Service
public class EntityManagerFlushService {

    private final EntityManager entityManager;
    private final ProductRepository productRepository;

    public EntityManagerFlushService(EntityManager entityManager,
                                    ProductRepository productRepository) {
        this.entityManager = entityManager;
        this.productRepository = productRepository;
    }

    @Transactional
    public void flushViaEntityManager(Long productId) {
        // Сутність залишається managed, ми лише керуємо моментом надсилання SQL
        Product product = productRepository.findById(productId).orElseThrow();
        product.setName("Flush через EM");

        // Явний flush через JPA API (по суті те саме, що й repository.flush())
        entityManager.flush(); // той самий зміст: надіслати SQL
    }
}

7. flush у логах і commit

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

У межах навчального режиму зазвичай достатньо двох речей: показувати SQL і показувати параметри, або принаймні розуміти, що параметри є. Налаштування профілів і логування ми вже робили раніше, тож зараз просто нагадаю принцип: вмикайте SQL-логи в окремому dev-профілі, щоб у звичайній роботі проєкт не шумів.

Ось приклад того, як може виглядати фрагмент логів у спрощеному вигляді, коли flush відбувається перед запитом:

-- ви змінили status у Product (dirty checking)
update product set status=? where id=?;
/* далі — запит, якому потрібно побачити свіжі зміни в межах цієї транзакції */
select p.id, p.name from product p where p.status=?;

Логіка така: Hibernate зрозумів, що зараз буде SELECT, який залежить від поточних змін, зробив flush, надіслав UPDATE, а вже потім виконав SELECT.

Ще раз: це не означає, що «все вже збережено назавжди». Це означає, що SQL виконано в межах транзакції, але фінальна точка — усе одно commit.

8. Мінісценарії з mini-shop

У нашому проєкті Mini Shop Data Layer майже будь-який write-use-case — це не один save, а кілька кроків: знайти товар, перевірити залишки, створити замовлення, перерахувати суму, оновити залишки. Усередині однієї транзакції Hibernate тримає керовані обʼєкти та зміни в persistence context, а SQL надсилає тоді, коли потрібно синхронізуватися.

Уявіть спрощений фрагмент логіки: ми «резервуємо залишок», а потім одразу виконуємо перевірочний запит, наприклад рахуємо, скільки товарів стало «out of stock». Якщо запит має враховувати зміни, Hibernate може зробити flush перед ним — і це правильно, інакше ваш запит житиме в минулій реальності.

Щоб відчути це на коді, можна уявити такий сервісний метод. Знову ж таки, він демонстраційний:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class InventoryStatsService {

    private final StockItemRepository stockItemRepository;

    public InventoryStatsService(StockItemRepository stockItemRepository) {
        this.stockItemRepository = stockItemRepository;
    }

    @Transactional
    public long reserveAndCount(Long stockItemId) {
        StockItem item = stockItemRepository.findById(stockItemId).orElseThrow();

        // Змінюємо керовану сутність: це може вплинути на результат наступного запиту
        item.setReservedQuantity(item.getReservedQuantity() + 1);

        // Будь-який countBy... — це запит у БД, і перед ним можливий auto-flush
        return stockItemRepository.countByReservedQuantityGreaterThan(0);
        // перед count-запитом може бути flush
    }
}

Навіть якщо countBy... виглядає невинно, це все одно запит до бази. І якщо Hibernate розуміє, що результат залежить від поточних змін, він намагатиметься синхронізувати зміни перед запитом. У межах однієї транзакції це допомагає зберегти передбачуваність.

9. Типові помилки під час роботи з flush і commit

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

Помилка № 2: очікувати, що SQL завжди йде строго в кінці сервісного методу.
У голові новачка метод @Transactional часто сприймається як коробка, де ніби нічого не відбувається, а наприкінці — бах, і все полетіло. На практиці Hibernate може зробити auto-flush перед SELECT, перед commit, а іноді й з інших причин. Це не хаос, а спроба забезпечити узгодженість читання в межах unit of work.

Помилка № 3: викликати flush() після кожної дрібної правки поля.
Так можна випадково перетворити ORM на дуже нервового курʼєра, який бігає в базу після кожного setXxx(). Зазвичай це не дає користі, зате підвищує вартість транзакції, ускладнює налагодження й робить код шумним. Явний flush — це інструмент точкового контролю й навчання, а не обовʼязковий ритуал пристойної людини.

Помилка № 4: не розуміти, що flush — це не «очищення» контексту.
Іноді flush плутають із тим, що контекст скидається й усе стає detached. Ні: flush не робить entity detached. Вона все ще managed, все ще живе в persistence context, і dirty checking далі працюватиме в тій самій транзакції.

Помилка № 5: змінювати дані в readOnly-транзакції й дивуватися, що вони не збереглися.
Ми вже обговорювали readOnly як маркер наміру. У деяких конфігураціях read-only транзакції можуть впливати на поведінку flush, наприклад Hibernate намагається не робити зайву синхронізацію. Тому якщо ви випадково змінюєте поля entity в @Transactional(readOnly = true), ви можете отримати дуже дивний ефект: у памʼяті ви «змінили», а в базі — ні. Правильна дисципліна тут проста: зміни даних живуть у write-транзакціях, а читання — у read-only, і ви не змішуєте ці ролі за звичкою.

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