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

Що таке dirty checking в Hibernate

Dirty Checking (Автоматичне відстеження «брудних» змін) — це вбудований механізм Hibernate та специфікації JPA, який автоматично фіксує будь-які мутації полів керованих сутносте...

Dirty Checking (Автоматичне відстеження «брудних» змін) — це вбудований механізм Hibernate та специфікації JPA, який автоматично фіксує будь-які мутації полів керованих сутностей (Managed) протягом транзакції та синхронізує їх із базою даних у момент скидання контексту (flush). Завдяки цьому механізму розробнику не потрібно вручну формувати запити UPDATE або викликати методи save() / update() у репозиторію.


🟢 Junior Level

Як працює Dirty Checking

Коли сутність завантажується з бази даних за допомогою entityManager.find() або JPQL-запиту, вона переходить у стан Managed. Hibernate не просто віддає Java-об’єкт застосунку, а зберігає в пам’яті точну копію його вихідного стану — знімок (Snapshot).

Наприкінці транзакції (або перед виконанням нового запиту) Hibernate порівнює поточні значення полів об’єкта зі знімком. Якщо виявлено відмінності, автоматично генерується та виконується SQL-запит UPDATE.

@Transactional
public void updateUserEmail(Long userId, String newEmail) {
    // 1. Завантаження сутності: Hibernate зберігає snapshot стану полів
    User user = entityManager.find(User.class, userId);

    // 2. Звичайна мутація через сетер
    user.setEmail(newEmail);

    // 3. ЖОДНИХ entityManager.merge(user) або repository.save(user)!
    // При виході з методу Spring зафіксує транзакцію,
    // Hibernate виконає dirty checking і сам відправить SQL UPDATE у БД:
    // UPDATE users SET email = ?, name = ?, age = ? WHERE id = ?
}
                   ЖИТТЄВИЙ ЦИКЛ DIRTY CHECKING
                   
   [ SELECT з БД ] ──> Завантаження User (name="Іван", email="a@ya.ru")
          │
          ├──> 1. Створення Java-об'єкта в Heap: User@0x1
          │
          └──> 2. Створення знімка (Snapshot): ["Іван", "a@ya.ru"] в L1 Cache
          
   [ Бізнес-логіка ] ──> user.setEmail("new@ya.ru")
          
   [ Фаза FLUSH ]
          │
          ├──> Порівняння: User@0x1 ("new@ya.ru") != Snapshot ("a@ya.ru")
          │
          └──> Генерація SQL: UPDATE users SET email = 'new@ya.ru' WHERE id = 1;

Основні правила Dirty Checking

  1. Працює лише для стану Managed: Зміни в об’єктах зі статусом Transient або Detached Hibernate не відстежує.
  2. Відкладене виконання (Write-Behind): Сетер user.setName(...) не відправляє SQL миттєво. Усі зміни накопичуються в пам’яті до фази flush().
  3. Холостий хід: Якщо повернути полю початкове значення до моменту коміту (user.setName("Old"); user.setName("Old");), Dirty Checking зрозуміє, що стан збігається зі знімком, і SQL UPDATE згенерований не буде.

🟡 Middle Level

Коли саме запускається перевірка Dirty Checking

Dirty Checking активується під час процедури flush контексту персистентності. Моменти спрацьовування залежать від режиму FlushModeType:

  1. При коміті транзакції: Завершення методу з @Transactional ініціює flush() та відправку всіх накопичених UPDATE перед фіксацією транзакції в СУБД.
  2. Перед виконанням JPQL / Criteria / Native запитів (при FlushModeType.AUTO): Якщо застосунок змінив сутність User, а потім виконує запит до таблиці користувачів:
    user.setStatus("BLOCKED");
    // Щоб запит повернув актуальні дані, Hibernate СПОЧАТКУ робить flush dirty checking:
    List<User> blockedUsers = entityManager.createQuery("FROM User WHERE status = 'BLOCKED'", User.class).getResultList();
    
  3. При явному виклику entityManager.flush(): Примусове скидання контексту розробником.

Проблема оновлення всіх колонок за замовчуванням та @DynamicUpdate

За замовчуванням Hibernate генерує один і той самий статичний SQL UPDATE для сутності, перераховуючи в ньому абсолютно всі колонки таблиці, навіть якщо змінилося лише одне поле:

-- Змінився тільки email, але оновлюються всі поля:
UPDATE users SET name = ?, email = ?, created_at = ?, role = ? WHERE id = ?;

Щоб генерувати SQL суворо за зміненими полями, застосовується анотація @DynamicUpdate:

@Entity
@Table(name = "users")
@org.hibernate.annotations.DynamicUpdate
public class User {
    // ...
}
-- При @DynamicUpdate згенерується точковий SQL:
UPDATE users SET email = ? WHERE id = ?;

Чому @DynamicUpdate НЕ увімкнений за замовчуванням:

  • СУБД кешує плани виконання для параметризованих запитів (Prepared Statement Execution Plans).
  • Без @DynamicUpdate SQL-запит UPDATE завжди ідентичний, база даних перевикористовує скомпільований план із кешу.
  • З @DynamicUpdate для кожного набору змінених колонок генерується унікальний SQL, що призводить до компіляції нового плану на кожен запит, забруднення кешу запитів СУБД та додаткових накладних витрат CPU Hibernate на динамічне збирання рядка запиту.
  • Правило: Застосовуйте @DynamicUpdate лише для дуже широких таблиць (30+ колонок), таблиць із BLOB/TEXT полями або при високому ризику конкурентного перезапису незв’язаних колонок.

Відключення відстеження (Read-Only сутності)

Порівняння зі знімком вимагає часу CPU $O(N \times M)$ ($N$ — кількість сутностей, $M$ — кількість полів) та витрачає додаткову пам’ять JVM на зберігання знімків.

Для запитів на читання знімки можна вимкнути:

// Через Spring Data JPA / Query Hint
@QueryHints(@QueryHint(name = org.hibernate.jpa.HibernateHints.HINT_READ_ONLY, value = "true"))
@Query("SELECT u FROM User u WHERE u.country = :country")
List<User> findUsersReadOnly(@Param("country") String country);

Для таких сутностей знімок loadedState не зберігається, і фаза dirty checking пропускає їх за $O(0)$, знижуючи час коміту транзакції.


🔴 Senior Level

Внутрішня реалізація Dirty Checking: Snapshot-based vs Bytecode Enhancement

Існує два фундаментальних механізми реалізації Dirty Checking в Hibernate:

┌────────────────────────────────────────────────────────────────────────┐
│                   МЕХАНІЗМИ ДЕТЕКЦІЇ ЗМІН                              │
├────────────────────────────────────┬───────────────────────────────────┤
│ 1. Знімок за замовчуванням         │ 2. Bytecode Enhancement Tracking  │
│    (Snapshot-based)                │                                   │
├────────────────────────────────────┼───────────────────────────────────┤
│ • SessionImpl зберігає Object[]    │ • Плагін компілятора модифікує    │
│ • При flush(): цикл за всіма       │   байт-код сутності               │
│   полями                           │ • Сутність реалізує SelfDirty     │
│ • Порівняння через equals / ==     │ • Сетер сам зводить dirty-прапорець│
│ • Витрата пам'яті: 2x (сутність +  │ • Нульовий оверхед пам'яті        │
│   снапшот кожного поля)            │   на знімок                       │
│ • Складність при flush: O(N * M)   │ • Складність при flush: O(K),     │
│                                    │   де K — тільки змінені           │
└────────────────────────────────────┴───────────────────────────────────┘

Механізм 1: Знімок за замовчуванням (Snapshot-based)

У StatefulPersistenceContext зберігається EntityEntry, що містить масив Object[] loadedState. При виклику flush() клас DefaultFlushEntityEventListener викликає метод Type.isDirty(old, current).

  • Для примітивів та незмінних типів (String, Long) виконується звичайна рівність значень.
  • Для колекцій (List, Set) використовується обгортка PersistentCollection (PersistentBag, PersistentSet), яка зберігає всередині себе знімок ключів та елементів на момент завантаження.

Механізм 2: Bytecode Enhancement (Просунутий трекінг)

За допомогою плагіна Maven/Gradle байт-код класів модифікується на етапі збирання:

<plugin>
    <groupId>org.hibernate.orm.tooling</groupId>
    <artifactId>hibernate-enhance-maven-plugin</artifactId>
    <executions>
        <execution>
            <configuration>
                <enableDirtyTracking>true</enableDirtyTracking>
            </configuration>
            <goals>
                <goal>enhance</goal>
            </goals>
        </execution>
    </executions>
</plugin>

Клас сутності прозоро починає реалізовувати інтерфейс org.hibernate.engine.spi.SelfDirtyTracker. При виклику будь-якого сетера байт-код автоматично додає ім’я зміненого поля у внутрішню структуру tracker. Під час flush() Hibernate не виконує жодних порівнянь: він миттєво бере список змінених полів із самого об’єкта, скорочуючи час скидання в десятки разів.

Тонкощі Dirty Checking для користувацьких типів та @Embedded

Якщо сутність містить складений об’єкт (@Embedded):

@Embedded
private Address address;
  1. Якщо змінити поле адреси: user.getAddress().setCity("Київ").
  2. Hibernate при стандартному snapshot-аналізі порівнює поля вкладеного компонента рекурсивно.
  3. Пастка змінних користувацьких типів (@Type): Якщо ви використовуєте власний клас і кастомний UserType, методи equals(Object x, Object y) та deepCopy(Object value) повинні бути реалізовані ідеально суворо. Якщо deepCopy() повертає те саме посилання замість глибокого клону, loadedState посилатиметься на той самий об’єкт, що й поточне поле, Dirty Checking вважатиме old == current і ніколи не збереже зміни в БД!

4 Tricky Questions

1. Чому Hibernate за замовчуванням оновлює ВСІ колонки таблиці в SQL UPDATE, і в яких випадках увімкнення @DynamicUpdate може ПОГІРШИТИ продуктивність високонавантаженої СУБД?

Відповідь:

  1. Статичний запит з усіма полями компілюється СУБД (PostgreSQL, Oracle, MySQL) рівно один раз. План виконання поміщається в Prepared Statement Cache як на стороні драйвера JDBC, так і в shared_buffers СУБД.
  2. При увімкненні @DynamicUpdate для таблиці з 20 колонок існує теоретично $2^{20} - 1$ різних варіантів SQL UPDATE.
  3. Кожен унікальний варіант запиту вимагає від СУБД повторного парсингу, оптимізації та генерації нового плану виконання (Hard Parse), що перевантажує CPU бази даних і вимиває корисні запити з кешу планів.
  4. Крім того, сам Hibernate витрачає додатковий процесорний час на динамічну склейку SQL-рядка при кожному flush().

2. Що станеться, якщо в транзакційному методі змінити сутність user.setName("Петро"), а потім у цьому ж методі буде викинуто неперевірений виняток (RuntimeException)? Чи збережеться ім’я в БД?

Відповідь:

  1. Ні, зміна в базі даних не зафіксується.
  2. За замовчуванням у Spring @Transactional перехоплює RuntimeException і виконує команду ROLLBACK транзакції бази даних.
  3. Навіть якщо до моменту викидання винятку було виконано явний проміжний entityManager.flush() і SQL UPDATE фізично пішов у мережевий сокет СУБД, відкат транзакції (ROLLBACK) поверне стан рядка в БД до початкового.

3. Ми завантажили сутність, змінили її поле user.setStatus("ACTIVE"), а потім викликали entityManager.detach(user). Чи згенерує Hibernate SQL UPDATE при коміті транзакції?

Відповідь:

  1. Ні, SQL UPDATE не буде згенерований.
  2. Метод detach(user) видаляє екземпляр сутності зі структур entitiesByKey та entityEntries контексту StatefulPersistenceContext.
  3. У момент виклику flush() перед комітом транзакції Hibernate сканує лише сутності, що залишилися в статусі Status.MANAGED. Оскільки від’єднаного об’єкта в реєстрі більше немає, його знімок ігнорується, а накопичені зміни в пам’яті втрачаються.

4. Чому виклик методу user.setName(user.getName()) (присвоєння того самого значення) не генерує SQL UPDATE, але все одно має накладні витрати?

Відповідь:

  1. Dirty Checking виконує порівняння через Objects.equals(loadedState[i], currentState[i]). Оскільки значення еквівалентні, Hibernate коректно визначає відсутність змін і не планує операцію в ActionQueue.
  2. Проте накладні витрати полягають у самому циклі обходу: для кожної сутності в контексті витягуються масиви полів через reflection/аксесори. Якщо в сесії завантажено 20 000 сутностей по 15 полів, рушій все одно виконає 300 000 перевірок рівності під час фази скидання контексту.

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

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

«Dirty Checking — це вбудований механізм Hibernate, що відстежує зміни полів керованих сутностей (Managed) та автоматично синхронізує їх із БД через SQL UPDATE під час фази flush. За замовчуванням Hibernate зберігає знімок (loadedState) при читанні об’єкта і порівнює його з поточним станом перед комітом транзакції. Ручний виклик save() або merge() для керованих об’єктів є надлишковим. За замовчуванням оновлюються всі колонки для ефективного перевикористання планів Prepared Statement у СУБД, але через @DynamicUpdate можна ввімкнути вибірковий UPDATE. Для оптимізації важких вибірок слід використовувати read-only запити або плагін Bytecode Enhancement».

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

  • Flush Duration (session.getStatistics().getFlushCount() / getTransactionCount()): середній час, що витрачається на детекцію змін. Зростання вище 50 мс свідчить про роздутий PersistenceContext.
  • Prepared Statement Cache Hit Ratio в пулі з’єднань (HikariCP / БД): різке падіння вказує на зловживання @DynamicUpdate.

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

  • ❌ «Після зміни поля сутності обов’язково потрібно викликати repository.save(entity) або entityManager.merge(entity)» — Це фундаментальне нерозуміння життєвого циклу JPA. Для Managed-сутностей dirty checking зробить усе сам.
  • ❌ «Dirty checking працює для будь-яких Java-об’єктів у застосунку» — Працює строго для сутностей у стані Managed всередині відкритої сесії.
  • ❌ «Я завжди ставлю @DynamicUpdate на всі класи для економії трафіку» — Призводить до деградації кешу планів запитів у СУБД.
  • ❌ «Кожен сетер сутності одразу ж відправляє запит UPDATE до бази даних» — Hibernate використовує write-behind і накопичує зміни до моменту flush().

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