Що таке 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
- Працює лише для стану
Managed: Зміни в об’єктах зі статусомTransientабоDetachedHibernate не відстежує. - Відкладене виконання (Write-Behind): Сетер
user.setName(...)не відправляє SQL миттєво. Усі зміни накопичуються в пам’яті до фазиflush(). - Холостий хід: Якщо повернути полю початкове значення до моменту коміту (
user.setName("Old"); user.setName("Old");), Dirty Checking зрозуміє, що стан збігається зі знімком, і SQLUPDATEзгенерований не буде.
🟡 Middle Level
Коли саме запускається перевірка Dirty Checking
Dirty Checking активується під час процедури flush контексту персистентності. Моменти спрацьовування залежать від режиму FlushModeType:
- При коміті транзакції: Завершення методу з
@Transactionalініціюєflush()та відправку всіх накопиченихUPDATEперед фіксацією транзакції в СУБД. - Перед виконанням 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(); - При явному виклику
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).
- Без
@DynamicUpdateSQL-запит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;
- Якщо змінити поле адреси:
user.getAddress().setCity("Київ"). - Hibernate при стандартному snapshot-аналізі порівнює поля вкладеного компонента рекурсивно.
- Пастка змінних користувацьких типів (
@Type): Якщо ви використовуєте власний клас і кастомнийUserType, методиequals(Object x, Object y)таdeepCopy(Object value)повинні бути реалізовані ідеально суворо. ЯкщоdeepCopy()повертає те саме посилання замість глибокого клону,loadedStateпосилатиметься на той самий об’єкт, що й поточне поле, Dirty Checking вважатимеold == currentі ніколи не збереже зміни в БД!
4 Tricky Questions
1. Чому Hibernate за замовчуванням оновлює ВСІ колонки таблиці в SQL UPDATE, і в яких випадках увімкнення @DynamicUpdate може ПОГІРШИТИ продуктивність високонавантаженої СУБД?
Відповідь:
- Статичний запит з усіма полями компілюється СУБД (PostgreSQL, Oracle, MySQL) рівно один раз. План виконання поміщається в Prepared Statement Cache як на стороні драйвера JDBC, так і в
shared_buffersСУБД. - При увімкненні
@DynamicUpdateдля таблиці з 20 колонок існує теоретично $2^{20} - 1$ різних варіантів SQLUPDATE. - Кожен унікальний варіант запиту вимагає від СУБД повторного парсингу, оптимізації та генерації нового плану виконання (Hard Parse), що перевантажує CPU бази даних і вимиває корисні запити з кешу планів.
- Крім того, сам Hibernate витрачає додатковий процесорний час на динамічну склейку SQL-рядка при кожному
flush().
2. Що станеться, якщо в транзакційному методі змінити сутність user.setName("Петро"), а потім у цьому ж методі буде викинуто неперевірений виняток (RuntimeException)? Чи збережеться ім’я в БД?
Відповідь:
- Ні, зміна в базі даних не зафіксується.
- За замовчуванням у Spring
@TransactionalперехоплюєRuntimeExceptionі виконує командуROLLBACKтранзакції бази даних. - Навіть якщо до моменту викидання винятку було виконано явний проміжний
entityManager.flush()і SQLUPDATEфізично пішов у мережевий сокет СУБД, відкат транзакції (ROLLBACK) поверне стан рядка в БД до початкового.
3. Ми завантажили сутність, змінили її поле user.setStatus("ACTIVE"), а потім викликали entityManager.detach(user). Чи згенерує Hibernate SQL UPDATE при коміті транзакції?
Відповідь:
- Ні, SQL
UPDATEне буде згенерований. - Метод
detach(user)видаляє екземпляр сутності зі структурentitiesByKeyтаentityEntriesконтекстуStatefulPersistenceContext. - У момент виклику
flush()перед комітом транзакції Hibernate сканує лише сутності, що залишилися в статусіStatus.MANAGED. Оскільки від’єднаного об’єкта в реєстрі більше немає, його знімок ігнорується, а накопичені зміни в пам’яті втрачаються.
4. Чому виклик методу user.setName(user.getName()) (присвоєння того самого значення) не генерує SQL UPDATE, але все одно має накладні витрати?
Відповідь:
- Dirty Checking виконує порівняння через
Objects.equals(loadedState[i], currentState[i]). Оскільки значення еквівалентні, Hibernate коректно визначає відсутність змін і не планує операцію вActionQueue. - Проте накладні витрати полягають у самому циклі обходу: для кожної сутності в контексті витягуються масиви полів через reflection/аксесори. Якщо в сесії завантажено 20 000 сутностей по 15 полів, рушій все одно виконає 300 000 перевірок рівності під час фази скидання контексту.
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь (Elevator Pitch)
«Dirty Checking — це вбудований механізм Hibernate, що відстежує зміни полів керованих сутностей (
Managed) та автоматично синхронізує їх із БД через SQLUPDATEпід час фази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().