💾 Розділ 16 · Питання #13

Як працює механізм flush в Hibernate

Скидання контексту (flush) — це внутрішній процес Hibernate та JPA, під час якого всі накопичені в оперативній пам'яті зміни сутностей (INSERT, UPDATE, DELETE) транслюються в SQ...

Скидання контексту (flush) — це внутрішній процес Hibernate та JPA, під час якого всі накопичені в оперативній пам’яті зміни сутностей (INSERT, UPDATE, DELETE) транслюються в SQL-команди і відправляються через драйвер JDBC у мережевий буфер бази даних. Розуміння механіки flush необхідне для запобігання непередбачуваним дедлокам, порушенням обмежень цілісності (Constraints) та управління порядком виконання операцій.


🟢 Junior Level

Що таке flush і чим він відрізняється від commit

Розробники часто плутають поняття flush та commit:

  • flush(): Переводить стан пам’яті JVM у базу даних. Усі підготовлені оператори SQL (INSERT, UPDATE, DELETE) фізично відправляються до СУБД у межах поточної відкритої транзакції. Зміни стають видимими всередині цієї ж транзакції, але ще не зафіксовані для інших користувачів.
  • commit(): Фіксує транзакцію в СУБД (SQL COMMIT). У цей момент СУБД записує зміни в журнал транзакцій (WAL), знімає блокування з рядків і робить дані видимими для інших транзакцій. Перед кожним комітом Hibernate автоматично викликає flush().
                  FLUSH VS COMMIT У ЖИТТЄВОМУ ЦИКЛІ
                  
 [ Java Heap: EntityManager ]
   user.setName("Анна");
         │
         │  1. entityManager.flush()
         ▼
 [ Сокет JDBC -> СУБД: Поточна транзакція (Не зафіксована) ]
   SQL: UPDATE users SET name = 'Анна' WHERE id = 1;
   (Дані видно лише поточному з'єднанню, накладено рядкові блокування)
         │
         │  2. transaction.commit()
         ▼
 [ СУБД: Постійне сховище (WAL / Data Files) ]
   SQL: COMMIT;
   (Блокування знято, дані видно всім транзакціям)

Коли Hibernate виконує flush автоматично

  1. Перед комітом транзакції (Transaction Commit): Завершення методу @Transactional завжди тригерить скидання всіх накопичених змін.
  2. Перед виконанням JPQL / Criteria / Native запитів: Щоб запит повернув актуальний стан даних, змінених у пам’яті поточної сесії (при режимі FlushModeType.AUTO).
  3. При ручному виклику entityManager.flush(): Застосовується розробником для примусової синхронізації (наприклад, при пакетній вставці або для отримання згенерованого СУБД ID).
@Transactional
public void exampleFlush() {
    User user = entityManager.find(User.class, 1L);
    user.setName("Нове ім'я");

    // У цей момент у БД ще старе ім'я.
    
    // Явний flush відправляє SQL UPDATE просто зараз:
    entityManager.flush(); 
    
    // Тепер UPDATE відправлено в БД, але транзакція ще НЕ завалідована/закомічена!
    // Якщо станеться виняток, транзакція відкотиться (ROLLBACK),
    // і зміна "Нове ім'я" зникне з БД.
}

🟡 Middle Level

Режими скидання (FlushModeType)

У JPA та Hibernate поведінка скидання регулюється властивістю FlushModeType:

// Встановлення глобально для сесії
entityManager.setFlushMode(FlushModeType.AUTO); // За замовчуванням

// Або точково для одного конкретного запиту
entityManager.createQuery("SELECT u FROM User u")
    .setFlushMode(FlushModeType.COMMIT)
    .getResultList();
Режим Поведінка Коли використовувати
FlushModeType.AUTO (За замовчуванням) Скидає зміни перед комітом транзакції, а також перед виконанням запитів, які можуть зачепити змінені таблиці. Стандартний режим розробки. Забезпечує сувору консистентність читання власних змін (Read-Your-Own-Writes).
FlushModeType.COMMIT Скидає зміни тільки під час коміту транзакції. Перед запитами SELECT скидання не виконується. Високонавантажені сценарії оптимізації, коли ми точно знаємо, що виконуваний SELECT не залежить від попередніх UPDATE/INSERT.

Оптимізація AUTO Flush: Простори запитів (Query Spaces)

Ранні версії ORM скидали абсолютно всі сутності перед будь-яким запитом SELECT. Сучасний Hibernate використовує розумну перевірку Query Spaces:

@Transactional
public void smartFlushDemo() {
    User user = entityManager.find(User.class, 1L);
    user.setName("Олексій"); // Змінено сутність User (таблиця 'users')

    // Запит зачіпає зовсім іншу таблицю 'orders':
    List<Order> orders = entityManager.createQuery("FROM Order", Order.class).getResultList();
    
    // Hibernate аналізує метамодель: таблиці 'orders' та 'users' не перетинаються!
    // FLUSH НЕ БУДЕ ВИКЛИКАНИЙ перед цим запитом! Економія ресурсів CPU та мережі.
}

Виняток: Для нативних SQL-запитів (createNativeQuery) Hibernate не може автоматично проаналізувати зачеплені таблиці, тому за замовчуванням перед нативним SQL він виконує повний flush усіх накопичених змін.


🔴 Senior Level

Черга дій: ActionQueue та суворий порядок виконання SQL

Всередині сесії Hibernate (SessionImpl) усі операції накопичуються в спеціальній структурі даних — черзі дій org.hibernate.engine.spi.ActionQueue.

Коли настає фаза flush(), Hibernate виконує операції НЕ в тому порядку, в якому ви викликали методи в Java-коді, а суворо за внутрішнім регламентом ActionQueue:

                    СУВОРИЙ ПОРЯДОК ВИКОНАННЯ В ACTIONQUEUE
                    
  1. OrphanRemovalAction        (Видалення сутностей-сиріт з колекцій)
            │
            ▼
  2. EntityInsertAction         (Усі операції persist / save)
            │
            ▼
  3. EntityUpdateAction         (Усі UPDATE, згенеровані Dirty Checking)
            │
            ▼
  4. CollectionRemoveAction     (Очищення зв'язуючих таблиць @ManyToMany / @OneToMany)
            │
            ▼
  5. CollectionUpdateAction     (Оновлення елементів колекцій)
            │
            ▼
  6. CollectionRecreateAction   (Перестворення колекцій)
            │
            ▼
  7. EntityDeleteAction         (Усі операції remove / delete)

Навіщо потрібен такий порядок:

Такий регламент захищає від помилок зовнішніх ключів (Foreign Key Constraints). Щоб зв’язати батька та дитину, батько має бути вставлений раніше (INSERT parent -> INSERT child). При видаленні — навпаки, спочатку зачищаються зв’язуючі таблиці та колекції, потім дочірні рядки, і лише в кінці — батько.

Пастка унікального обмеження (Unique Constraint Collision)

Через те, що EntityInsertAction (Крок 2) виконується раніше, ніж EntityDeleteAction (Крок 7), виникає класична production-пастка:

// Поле 'username' має обмеження UNIQUE в базі даних
@Transactional
public void replaceUser() {
    User oldUser = userRepository.findByUsername("john_doe");
    entityManager.remove(oldUser); // Заплановано DELETE

    User newUser = new User();
    newUser.setUsername("john_doe"); // Той самий унікальний логін
    entityManager.persist(newUser); // Заплановано INSERT

    // Наприкінці транзакції спрацьовує FLUSH:
    // 1. ActionQueue намагається виконати: INSERT INTO users (username) VALUES ('john_doe')
    // 2. БД вибухає помилкою: ERROR: duplicate key value violates unique constraint!
    // 3. До виконання DELETE справа навіть не доходить!
}

Рішення: Примусово розділити операції через проміжний виклик flush():

entityManager.remove(oldUser);
entityManager.flush(); // Фізично відправляє DELETE у БД просто зараз

entityManager.persist(newUser); // Тепер INSERT пройде успішно!

Оптимізація пакетної обробки (order_inserts та order_updates)

За замовчуванням в ActionQueue вставки та оновлення виконуються в порядку їх додавання. Якщо чергувати різні сутності: User -> Order -> User -> Order, JDBC-драйвер не зможе використати батчинг, оскільки кожен перехід вимагає закриття поточного PreparedStatement.

Увімкнення налаштувань сортування групує однотипні SQL-оператори:

spring:
  jpa:
    properties:
      hibernate:
        jdbc.batch_size: 50
        order_inserts: true # Сортує INSERT за типами сутностей
        order_updates: true # Сортує UPDATE за типами сутностей

4 Tricky Questions

1. Чи можна перехопити виняток порушення зовнішнього ключа або унікальності після entityManager.flush() і продовжити нормальну роботу з тією ж сесією?

Відповідь:

  1. Категорично ні. Специфікація JPA суворо визначає: при виникненні будь-якого PersistenceException під час flush() поточний PersistenceContext переходить у нескоординований, неконсистентний стан.
  2. Транзакція негайно позначається драйвером та менеджером транзакцій як Rollback-Only.
  3. Будь-які спроби виконати подальші запити через цей же EntityManager завершаться помилкою: Transaction rolled back because transaction was marked as rollback-only.
  4. Сесія підлягає негайному закриттю. Якщо потрібно залогувати помилку в БД, це необхідно робити в окремій незалежній транзакції (@Transactional(propagation = Propagation.REQUIRES_NEW)).

2. Чому виклик entityManager.flush() не звільняє пам’ять JVM, на відміну від entityManager.clear()?

Відповідь:

  1. flush() взаємодіє виключно з базою даних: він генерує SQL і скидає мережевий буфер JDBC.
  2. Усі об’єкти в оперативній пам’яті JVM залишаються в стані Managed всередині структур entitiesByKey та entityEntries контексту персистентності. Посилання на них, як і раніше, сильні (Strong References).
  3. Щоб звільнити пам’ять, необхідно після entityManager.flush() явно викликати entityManager.clear(), що розірве зв’язок із сесією і переведе об’єкти в статус Detached.

3. Якщо всередині методу з @Transactional(readOnly = true) викликати entityManager.flush(), що станеться?

Відповідь:

  1. У Spring Framework анотація @Transactional(readOnly = true) переводить сесію Hibernate в режим FlushModeType.MANUAL (або вимикає dirty checking).
  2. При виході з методу автоматичне скидання перед комітом взагалі не виконується, заощаджуючи ресурси на перевірку знімків.
  3. Однак, якщо розробник усередині такого методу явно викличе entityManager.flush(), Hibernate виконає перевірку dirty checking і спробує відправити SQL у базу даних.
  4. Якщо з’єднання з СУБД на рівні JDBC драйвера було переведено в Connection.setReadOnly(true), СУБД викине помилку: ERROR: cannot execute UPDATE in a read-only transaction.

4. Чому flush() перед запитом JPQL спрацьовує при пошуку за полями сутності, але НЕ спрацьовує при пошуку за первинним ключем через entityManager.find()?

Відповідь:

  1. Метод find(Entity.class, id) у першу чергу опитує кеш першого рівня L1 (entitiesByKey).
  2. Якщо сутність уже знаходиться в пам’яті поточної сесії (навіть якщо її поля були змінені), find() миттєво повертає вже модифікований об’єкт із пам’яті без звернення до СУБД. Жодної розсинхронізації немає, тому виконувати дорогий flush() не потрібно.
  3. Навпаки, JPQL-запит завжди йде в базу даних через SQL SELECT. Щоб СУБД побачила змінені рядки у своїх таблицях, Hibernate зобов’язаний попередньо скинути їх через flush().

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь (Elevator Pitch)

«Механізм flush у Hibernate синхронізує стан PersistenceContext із базою даних, перетворюючи зміни в пам’яті на SQL INSERT, UPDATE і DELETE та відправляючи їх у буфер транзакції. На відміну від commit(), flush() не фіксує транзакцію — зміни можна відкотити. За замовчуванням використовується FlushModeType.AUTO, коли скидання відбувається перед комітом та перед JPQL-запитами до модифікованих таблиць. Критично важливо пам’ятати про ActionQueue: операції виконуються у фіксованому порядку (спочатку всі INSERT, потім UPDATE, і лише наприкінці DELETE). При заміні сутності з UNIQUE-колонкою це вимагає проміжного ручного flush(), інакше виникне помилка дубліката ключа».

Ключові метрики для Production-моніторингу

  • Flush Count (session.getStatistics().getFlushCount()): аномально високе число свідчить про часті проміжні скидання через неоптимальні запити.
  • Тривалість Flush Time: відображає час Dirty Checking + мережеві затримки JDBC при передачі пакета SQL.
  • Rollback-Only Exceptions Rate: індикатор збоїв під час автоматичного flush перед комітом.

Червоні прапорці (Чого говорити НЕ МОЖНА)

  • ❌ «Flush фіксує транзакцію, після нього не можна зробити Rollback» — Груба помилка. Flush лише відправляє SQL, фіксує транзакцію виключно commit().
  • ❌ «Hibernate виконує SQL-запити рівно в тому порядку, в якому я викликаю persist() і remove() у коді» — Порядок диктується ActionQueue (INSERT завжди передує DELETE).
  • ❌ «Після падіння flush() з помилкою бази можна зловити виняток у catch і продовжити роботу» — Сесія пошкоджена і знаходиться в статусі Rollback-Only.
  • ❌ «Виклик flush() очищає пам’ять JVM від об’єктів» — Пам’ять звільняє лише entityManager.clear().

Пов’язані теми