1. Як Hibernate оновлює дані
Коли в логах раптово зʼявляється UPDATE, хоча в коді немає ні SQL, ні навіть явного «зберегти», перша реакція зазвичай така: «Hibernate живе своїм життям». Насправді все прозаїчніше: у нього просто інша модель роботи. Ви не кажете йому «онови таблицю», а змінюєте обʼєкт, і Hibernate сприймає це як частину unit of work. Далі він сам вирішує, що саме треба синхронізувати з базою.
Якщо ви пишете JDBC або вручну збираєте SQL, ви мислите командами: «хочу змінити імʼя товару → пишу UPDATE product set name=?». В ORM-світі фокус зміщується на стан обʼєкта: «отримую обʼєкт Product → змінюю product.setName(...) → наприкінці бізнес-операції ORM синхронізує зміни». Щоб це працювало, Hibernate має відповісти на два інженерні запитання. По-перше, які обʼєкти зараз перебувають під його керуванням — це про managed і persistence context. По-друге, які з них справді змінилися — і ось тут зʼявляються snapshot і dirty checking.
Ми вже відокремили managed-сутність від detached і побачили, що persistence context тримає обʼєкти не «для краси», а як робочу частину застосунку під час виконання. Тепер це стає зовсім практичним: доки сутність managed, Hibernate може порівнювати її поточний стан із початковим і вирішувати, чи взагалі потрібен UPDATE.
Якщо зовсім спростити, картина така: Hibernate робить «фото» початкового стану managed-сутності й пізніше порівнює його з поточним. Є відмінність — готуємо UPDATE. Немає відмінностей — рухаємося далі й не витрачаємо SQL даремно.
Щоб далі не жити в режимі «здається», давайте введемо терміни, якими будемо спокійно користуватися:
| Термін | Людське пояснення | Де живе |
|---|---|---|
| snapshot | «Знімок» початкових значень полів сутності в момент, коли вона стала managed | усередині persistence context |
| dirty checking | Порівняння поточних значень із snapshot | виконується Hibernate під час синхронізації з БД |
| dirty entity | Managed-сутність, у якої знайшли відмінності від snapshot | також усередині persistence context |
| automatic change detection | Загальна ідея: «Hibernate сам вирішує, чи потрібен UPDATE» | це поведінка ORM, а не окремий API |
2. Snapshot і dirty checking
Snapshot
Слово snapshot легко наводить на думку, ніби Hibernate створює десь «другий Product» і тримає його поруч про запас. Насправді це не ваш повноцінний обʼєкт і не те, з чим ви працюєте безпосередньо. Snapshot — це внутрішні дані Hibernate, найчастіше набір значень властивостей, збережених для майбутнього порівняння. І важливий момент: snapshot не потребує додаткового SQL-запиту. Hibernate не йде в базу «фотографувати» сутність — він уже отримав дані під час завантаження і саме їх зберігає як базову лінію.
Коли сутність стає managed (наприклад, через entityManager.find(Product.class, id) або через запит select p from Product p), Hibernate кладе її в persistence context. Поруч він зберігає службову інформацію: ідентифікатор, статус і, так, snapshot початкових значень. Доки сутність залишається managed, вона перебуває «під наглядом».
Корисно уявляти persistence context не просто як мапу id → обʼєкт, а як структуру, де для кожної managed-сутності є своя «картка обліку»:
flowchart LR PC["Контекст збереження"] --> E["Керована сутність Product #42"] PC --> S["Знімок для Product #42
name='Mouse'
price=49.99"] E --> DC["Порівняння змін"] S --> DC DC -->|diff| SQL["Запланувати UPDATE"]
Тут нам не важливо, як саме Hibernate зберігає snapshot усередині себе: внутрішніх деталей там багато, і зараз вони не потрібні. Нам важливо закріпити головне: snapshot — частина механіки persistence context, а не «ваша копія обʼєкта».
Щоб не плутатися, можна тримати в голові просту перевірку: якщо ви можете отримати це напряму в доменному коді та використовувати, отже, це не snapshot Hibernate. Snapshot Hibernate — річ внутрішня: він «усередині двигуна», а не «на панелі приладів».
Dirty checking
Dirty checking спрацьовує не в момент виклику setter, а тоді, коли Hibernate збирається синхронізувати persistence context із БД. Саме в цей момент він перестає бути просто «розумним контейнером обʼєктів» і починає вирішувати, який SQL потрібен. Детальна розмова про flush нам поки не потрібна; достатньо зафіксувати базову механіку: порівняння snapshot ↔ поточний стан.
Ідея дуже інженерно-нудна — а отже, надійна. У кожної managed-сутності є snapshot. Hibernate бере поточні значення полів і порівнює їх із ним. Якщо знаходить відмінність, сутність вважається dirty, і Hibernate планує SQL UPDATE. Якщо відмінностей немає — жодного оновлення, навіть якщо ви читали поля, логували обʼєкт або просто довго на нього дивилися (це, на жаль, не вважається зміною).
Псевдокод (не справжній API Hibernate, а саме «як думати»):
// Псевдокод: ілюстрація ідеї, не копіюйте в прод :)
for (ManagedEntity entity : persistenceContext) {
// Snapshot — «базова лінія»: значення на момент, коли сутність стала managed
Object[] snapshot = entity.getSnapshot();
// Поточний стан — значення полів просто зараз (після ваших setXxx())
Object[] current = entity.getCurrentState();
// Hibernate порівнює значення за властивостями: якщо є відмінності — планує UPDATE
if (!equalsByProperties(snapshot, current)) {
scheduleUpdate(entity); // SQL виконається пізніше, під час синхронізації (flush)
}
}
Тепер — мінімальний приклад на нашому проєкті Commerce Persistence Lab. Уявімо, що ми змінюємо імʼя товару:
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void renameProduct(Long id) {
// Завантажуємо сутність — з цього моменту вона managed, і Hibernate зробив snapshot
Product product = entityManager.find(Product.class, id);
// Змінюємо поле у пам'яті: SQL поки не надсилається
product.setName("Keyboard");
// SQL UPDATE з'явиться не "в цю секунду", а під час синхронізації з БД (зазвичай при flush/commit)
}
Тут важливо одне: product — managed. Отже, Hibernate вже зберіг snapshot (наприклад, name="Mouse"). Ви змінили поле. Доки транзакція не завершилась, це лише зміна обʼєкта у памʼяті. Але ближче до кінця unit of work Hibernate порівняє name зі snapshot, побачить відмінність і підготує UPDATE product set name=? where id=?.
І зворотний приклад: читання даних не робить сутність dirty:
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public String readProductTitle(Long id) {
// Сутність managed, snapshot є...
Product product = entityManager.find(Product.class, id);
// ...але ми нічого не змінюємо: лише читаємо поля
return product.getSku() + " " + product.getName();
}
Тут Hibernate нічого не зобовʼязаний оновлювати, тому що snapshot не розходиться з поточними значеннями полів. Ми просто прочитали дані. ORM не телепат: якщо ви нічого не змінили, синхронізувати нічого.
3. Dirty checking лише для managed-сутностей
Тут найчастіше й ламається логіка новачків: «Я ж тримаю обʼєкт у змінній, отже Hibernate його бачить». На жаль, ні. Hibernate не екстрасенс і не читає ваші локальні змінні. Він відстежує лише те, що перебуває в persistence context, тобто managed-сутності. Усе інше — звичайні Java-обʼєкти: хочете — змінюйте, хочете — викидайте, Hibernate навіть не образиться. Він просто про це не дізнається.
Щоб не гадати, простіше за все використовувати EntityManager.contains(entity). Це хороший «тест на managed», який нерідко економить години життя.
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void checkManaged(Long id) {
Product product = entityManager.find(Product.class, id);
// Перевіряємо, чи перебуває обʼєкт під керуванням persistence context (managed)
System.out.println(entityManager.contains(product)); // true
}
Тепер приклад із detach(). Учора він допомагав розбирати стани сутності, а сьогодні потрібен, щоб намацати межу dirty checking:
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void changeAfterDetach(Long id) {
Product product = entityManager.find(Product.class, id);
// Явно викидаємо сутність із persistence context: вона більше не managed
entityManager.detach(product);
// Hibernate більше не відстежує зміни цього обʼєкта
System.out.println(entityManager.contains(product)); // false
// Змінюємо поле — але dirty checking не спрацює, UPDATE не буде заплановано
product.setName("Temporary name"); // Hibernate це ігнорує
}
Після detach() обʼєкт стає detached. Він усе ще у вас у памʼяті, ви можете змінювати йому поля, але snapshot і dirty checking для нього більше не працюють, тому що Hibernate перестав вважати цей обʼєкт частиною поточного unit of work.
Якщо хочеться запамʼятати це «на пальцях», ось компактна таблиця (так, таблиця — це не bullet list, тож совість чиста):
| Стан сутності | У persistence context? | Hibernate зберігає snapshot? | Dirty checking може призвести до UPDATE? |
|---|---|---|---|
| transient | ні | ні | ні |
| managed | так | так | так |
| detached | ні | ні | ні |
| removed | так (до синхронізації) | так | не UPDATE, а зазвичай DELETE (у потрібний момент) |
І тут зʼявляється перший анти-міф: dirty checking — це не «магія репозиторію», а властивість managed-стану.
4. Setter і UPDATE: роль snapshot
Інтуїція «викликав setter → значить буде UPDATE» дуже людська. Але Hibernate мислить не подіями на кшталт «хтось викликав метод», а станами: «які значення були і які стали». Тому сам по собі setter нічого не гарантує. Якщо ви присвоїли те саме значення, snapshot і поточний стан збігаються — отже, сутність не dirty і UPDATE не потрібен.
Найпростіший приклад:
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void setSameValue(Long id) {
Product product = entityManager.find(Product.class, id);
// Беремо поточне значення...
String sameName = product.getName();
// ...і записуємо його назад: з точки зору даних змін немає
product.setName(sameName); // логічно нічого не змінили
}
Якщо sameName справді дорівнює поточному значенню, Hibernate не побачить різниці. У логах (за коректного налаштування SQL trace) ви побачите SELECT, але не побачите UPDATE лише через цей рядок.
Більш життєвий варіант, який часто дивує, а іноді призводить і до accidental update, про який у нас буде окрема лекція: ви робите «нормалізацію» рядка.
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void normalizeName(Long id) {
Product product = entityManager.find(Product.class, id);
// trim() може реально змінити значення відносно snapshot (якщо в БД були пробіли)
product.setName(product.getName().trim());
}
Якщо в базі було " Mouse " (із пробілами), то після trim() стане "Mouse". Це реальна зміна відносно snapshot — Hibernate чесно зробить UPDATE. А якщо в базі вже "Mouse" без пробілів, то trim() поверне те саме значення, і UPDATE може не зʼявитися.
Тут важливо не заплутатися: Hibernate не «вгадує наміри». Він не знає, що ви «просто почистили рядок». Він бачить факт: поле стало іншим або не стало. І діє відповідно.
Звідси корисний висновок для діагностики: коли ви бачите несподіваний UPDATE, шукайте не «де хтось викликав save», а «де значення справді стало іншим порівняно зі snapshot». Hibernate майже завжди «правий» у межах своєї моделі. Питання лише в тому, чому зміна сталася, якщо ви її не планували.
5. Сценарій: один find(), один UPDATE
Щоб snapshot-модель перестала бути абстракцією, корисно побачити одну практичну деталь: Hibernate порівнює підсумковий стан managed-сутності зі snapshot, а не друкує UPDATE після кожної вашої дії. Тому ви можете десять разів змінювати поле в одному методі, а SQL UPDATE усе одно буде один — наприкінці unit of work — і в ньому опиниться фінальне значення.
Приклад: ви змінюєте імʼя товару кілька разів (у житті так зазвичай не роблять, але для демонстрації ідеально).
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void renameProductSeveralTimes(Long id) {
// Один find() — одна managed-сутність, один snapshot
Product product = entityManager.find(Product.class, id);
// Можна змінювати поле скільки завгодно разів у межах транзакції...
product.setName("Keyboard");
product.setName("Keyboard Pro");
// ...але в підсумку Hibernate порівняє snapshot із фінальним значенням
product.setName("Keyboard Pro Max"); // фінальне значення
}
З точки зору Java ви тричі змінювали поле. З точки зору Hibernate snapshot був, скажімо, "Mouse", а підсумковий стан став "Keyboard Pro Max". Відмінність є — отже, потрібен UPDATE. Але один, тому що синхронізація відбувається пакетом наприкінці unit of work.
Якщо у вас увімкнений SQL trace, картина зазвичай виглядає приблизно так (спрощено, без bind-параметрів):
-- 1) Спочатку Hibernate завантажує сутність (і на цьому ж кроці формує snapshot)
select p1_0.id, p1_0.name, p1_0.sku
from product p1_0
where p1_0.id = ?
-- 2) Потім, під час синхронізації (flush/commit), надсилає UPDATE, якщо знайшов відмінності
update product
set name = ?
where id = ?
Зверніть увагу: SQL не зобовʼязаний бути «маленьким». Залежно від мапінгу Hibernate може сформувати UPDATE, який оновлює більше колонок, ніж ви очікуєте. Зараз нам важливий лише сам принцип появи UPDATE. Чому SQL іноді ширший, ніж очікувалося, і звідки беруться випадкові оновлення — це вже наступний шар аналізу.
Ще один маленький, але важливий момент: після успішної синхронізації Hibernate оновлює свій snapshot, тому що новий стан стає «новою базовою лінією». Тобто snapshot — не одноразова фотографія «на все життя сутності», а робочий інструмент конкретного persistence context.
6. Типові помилки під час роботи зі snapshot і dirty checking
Помилка №1: думати, що dirty checking працює для detached-обʼєктів.
Пастка «обʼєкт у мене є — отже Hibernate його бачить» трапляється постійно. Але після detach() (або після завершення транзакції, коли persistence context закрито) обʼєкт перетворюється на звичайний Java-екземпляр без нагляду. Його можна змінювати скільки завгодно, але в базу ці зміни самі не потраплять, тому що snapshot і порівняння живуть лише всередині persistence context.
Помилка №2: сприймати snapshot як «другий обʼєкт, який можна використовувати в коді».
Snapshot — не Product copy, не DTO і не «версія до змін», яку можна показати користувачу. Це внутрішні структури Hibernate, і для доменної логіки вони не призначені. Якщо вам справді потрібна версія «до і після» для бізнесу або аудиту — це окрема задача (і пізніше в нас буде Envers). Але snapshot — не про це.
Помилка №3: чекати SQL одразу після setter і дивуватися, що його немає.
Hibernate відокремлює зміну обʼєкта у памʼяті від надсилання SQL у базу. Dirty checking лише виявляє зміни, а сам SQL зʼявляється під час синхронізації unit of work із БД. Тому всередині методу ви можете не побачити жодної активності, і це нормально. Головне — не змішувати ці дві фази.
Помилка №4: вважати, що будь-який виклик setter неминуче створює UPDATE.
Setter — це подія у вашому коді. Hibernate ж працює за принципом «порівняли значення». Якщо нове значення збіглося зі snapshot, сутність не стане dirty. Це особливо корисно памʼятати, коли ви робите «нормалізацію» рядків або округлення чисел: іноді ви справді змінюєте дані, навіть якщо вам здається, що це «лише косметика».
Помилка №5: шукати UPDATE лише за наявністю save() у коді.
Звичка мислити за схемою «змінив → викликав save» часто маскує реальну механіку. UPDATE може зʼявитися і без save(), якщо ви працювали з managed-сутністю, і навпаки: save() не є магічною кнопкою «обовʼязково оновити», якщо ви не розумієте, у якому стані обʼєкт. Найкраще запитання для початку діагностики: «Обʼєкт був managed? І яке поле справді стало іншим відносно snapshot?»
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ