1. Звідки береться неочікуваний UPDATE
Неочікуваний UPDATE у Hibernate майже завжди означає одне: десь усередині транзакції змінилася managed-сутність. Якщо ви звикли до «ручного SQL», мозок очікує інше: UPDATE зʼявляється лише там, де ви самі його написали або явно викликали save(). ORM ламає цей рефлекс. Тут UPDATE — не команда з коду, а наслідок того, що managed-обʼєкт змінився в межах транзакції.
Щойно це стає зрозумілим, виникає наступне інженерне питання: чому оновлення іноді «зʼїжджає» туди, де код виглядає як читання? Саме це й називають accidental update.
Зафіксуймо все максимально приземлено. Наприкінці транзакції Hibernate синхронізує persistence context із базою. Для цього він перевіряє managed-сутності: якщо поточний стан відрізняється від snapshot, він зобовʼязаний підготувати оновлення. Пастка в тому, що ви могли не хотіти змінювати дані, але все одно десь змінили поле в managed-обʼєкті. Hibernate не вміє читати ваші наміри — він уміє читати зміни в памʼяті.
Наочно це виглядає так:
flowchart TD
A["@Transactional: транзакція розпочалася"] --> B["find(): отримали managed-сутність"]
B --> C["Код «майже читає»..."]
C --> D["...але десь змінює поле (setter/mapper/side effect)"]
D --> E["Наприкінці транзакції: dirty checking"]
E -->|є відмінність від snapshot| F["Готуємо SQL UPDATE"]
E -->|відмінностей немає| G["SQL UPDATE не потрібен"]
Зверніть увагу: на цій схемі немає обовʼязкового кроку «викликали save()». І це нормально. Hibernate працює за моделлю «змінили обʼєкт — ORM синхронізує».
2. Accidental update: зміст і причини
Термін accidental update звучить майже як діагноз, і приблизно так воно й є. Це ситуація, коли UPDATE у SQL-лозі справді відбувся, але з погляду бізнес-логіки ви вважаєте його «зайвим» або «неочікуваним». Важливо не переплутати: це не означає, що Hibernate «оновив щось сам по собі». Це означає, що в межах транзакції реально змінився стан managed-обʼєкта, просто ви не планували цього як бізнес-зміну.
Щоб не сперечатися з реальністю, зручно розділити два випадки. Намірене оновлення — це коли сценарій явно про запис: «перейменувати товар», «підтвердити замовлення», «сховати товар із каталогу». Випадкове оновлення — це коли сценарій виглядає як читання або підготовка відповіді, але всередині нього раптом відбувається «маленьке покращення даних»: підрізали пробіли, нормалізували регістр, підправили формат, перерахували поле «про всяк випадок», оновили службове поле часу тощо.
Нижче — табличка, яка допомагає тримати цю різницю в голові:
| Сценарій у коді | Як виглядає намір | Що реально сталося | Чому Hibernate має рацію |
|---|---|---|---|
| Метод «зібрати заголовок товару» | читання / форматування | product.setName(...) | поле змінилося відносно snapshot |
| Метод «показати замовлення в адмінці» | читання / відображення | order.setStatus(...) десь глибоко | статус змінився, отже потрібен UPDATE |
| DTO-маппер «застосувати патч» | оновити 1–2 поля | маппер поставив null або дефолти в інші | стан сутності став іншим |
Ключова дисципліна для розробника: коли ви бачите accidental update, не питайте «де баг у Hibernate?», питайте «яке поле стало іншим і хто його змінив?». Hibernate в цій історії зазвичай просто чесний бухгалтер: побачив різницю — заніс у відомість.
3. Найчастіший винуватець: «читання з побічним ефектом»
У більшості проєктів accidental update зʼявляється не через якісь екзотичні анотації або приховані механізми, а через дуже людську звичку: «я зараз акуратно поправлю дані прямо тут, попутно». Такі «попутно» ідеально маскуються під читання. Метод повертає рядок, DTO або просто робить логування — і здається, що він «нічого не змінює». Але якщо всередині був setter, ORM сприйме це як зміну.
Подивіться на приклад — у реальних проєктах він трапляється куди частіше, ніж хотілося б. Розробник хоче вивести красивий title і попутно вирішує «підчистити» імʼя:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public String loadProductTitle(Long id) {
// Завантажуємо сутність усередині транзакції: вона стає managed
Product product = entityManager.find(Product.class, id);
// Здається "нешкідливою нормалізацією" для UI,
// але це реальна зміна managed-сутності в persistence context
product.setName(product.getName().trim());
// Тут ми вже читаємо змінене значення, і наприкінці транзакції може піти UPDATE
return product.getSku() + " " + product.getName();
}
Якщо імʼя в базі було " Keyboard " (із пробілами), після trim() воно стане "Keyboard". Для Java це просто рядок. Для Hibernate це реальна зміна стану managed-сутності. У SQL-лозі ви побачите UPDATE, навіть якщо ніде немає save().
Потрібно окремо підкреслити важливий момент: якщо trim() не змінив рядок, UPDATE може й не зʼявитися, тому що dirty checking порівнює snapshot і поточний стан. Саме тому такий баг іноді «то зʼявляється, то зникає», і від цього стає ще більш містичним. Містика тут проста: вхідні дані різні.
Тепер безпечна версія того самого методу. Ми все ще нормалізуємо дані для відображення, але не чіпаємо entity:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public String loadProductTitleSafely(Long id) {
// Сутність managed, але ми не змінюємо її стан
Product product = entityManager.find(Product.class, id);
// Нормалізуємо лише для відображення: це локальна змінна, не запис у entity
String normalizedName = product.getName().trim();
// У persistence context стан entity не змінювався — отже й UPDATE не обовʼязковий
return product.getSku() + " " + normalizedName;
}
Результат для користувача той самий, але UPDATE не виникне, тому що стан managed-обʼєкта залишився незмінним. Тут зазвичай дуже хочеться сказати: «ну це ж очевидно». Так, рівно до першого авралу, коли ви втомилися і вам здається, що setName(trim()) — це не запис, а косметика. Hibernate у відповідь спокійно каже: «косметика? чудово, записую».
Ще один варіант «читання з побічним ефектом» — коли нормалізація захована в окремий метод сутності і тому виглядає ще більш законно. Наприклад, ви зробили доменний метод normalizeName() і викликаєте його «на читанні»:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public String titleWithNormalize(Long id) {
// Завантажуємо managed-сутність
Product product = entityManager.find(Product.class, id);
// Важливо: якщо всередині normalizeName() є setter-и, це все одно запис
product.normalizeName();
// Зовні метод виглядає "як читання", але ORM побачить зміни полів
return product.getSku() + " " + product.getName();
}
На рівні ідеї доменні методи — це добре. На рівні accidental update важливо памʼятати: якщо всередині normalizeName() змінюються поля, то це запис, навіть якщо ви назвали метод якось мʼяко й приємно. ORM не читає назви методів, він читає зміни полів.
4. Сетери із сюрпризом: коли присвоєння змінює ще щось
Наступне за популярністю джерело accidental update — setter-и з побічними ефектами. У «чистій Java» setter — це зазвичай банальне this.x = x;. У реальному проєкті setter часто перетворюється на мініоперацію: він не лише присвоює значення, а й нормалізує його, перераховує похідні поля, змінює службові позначки. Це може бути виправдано, але тоді потрібно чесно визнати: такий setter — частина write-моделі, і ви не повинні викликати його в read-сценаріях.
Уявімо, що ми вирішили автоматично оновлювати updatedAt під час зміни імені товару. Наївний варіант виглядає так:
import java.time.Instant;
public void setName(String name) {
// Змінюємо "бізнес-поле"
this.name = name;
// Побічний ефект: змінюємо службове поле часу
// Навіть якщо name за змістом не змінився, updatedAt зміниться майже завжди
this.updatedAt = Instant.now();
}
У чому пастка? Навіть якщо за змістом ви «нічого не змінювали», але setter викликався (наприклад, у мапері), updatedAt майже напевно зміниться. Hibernate побачить відмінність між snapshot і поточним станом та відправить UPDATE. У SQL це виглядає як «оновили товар», хоча ви могли «просто зібрати заголовок» або «застосувати DTO, де імʼя було тим самим».
Ще більш підступний варіант — коли setter робить нормалізацію і водночас чіпає інше поле, наприклад поле для пошуку:
import java.util.Locale;
public void setName(String name) {
// Зберігаємо "як є" (або майже як є)
this.name = name;
// Додаткове поле для пошуку / індексації:
// навіть якщо name "той самий", searchName може перерахуватися інакше через trim / регістр
this.searchName = name == null ? null : name.trim().toLowerCase(Locale.ROOT);
}
Тепер навіть «нешкідлива» операція setName(product.getName()) може стати зміною, якщо searchName перераховується інакше — наприклад, через пробіли, регістр або різні локалі. Баг виглядає особливо дивно, тому що ви «ставили те саме». Але по факту ви не ставили те саме — ви перерахували ще одне поле.
На цьому етапі курсу нам не потрібно забороняти setter-и. Нам потрібно провести чесну межу: якщо setter робить побічні ефекти, це вже не «просто присвоєння». Викликати його треба так само свідомо, як ви б писали ручний SQL. Запускати такий setter у методі, який позиціонується як читання, — це все одно що в методі getReport() тихо виконати DELETE FROM ... «щоб табличка була чистішою». Формально можна, але на code review вас любитимуть дуже… своєрідно.
5. DTO-мапінг: оновлення всього
Третій великий клас accidental update — сліпе копіювання даних із DTO в сутність. Іронія в тому, що мапінг зазвичай пишуть із добрими намірами: «я хочу акуратно застосувати дані із запиту». Але якщо копіювати все підряд, рано чи пізно скопіюєте зайве. Hibernate у цьому місці, як завжди, гранично послідовний: скопіювали зайве — отримали зайвий UPDATE.
Припустімо, у нас є запит на оновлення товару. Для простоти візьмемо Java record:
// DTO запиту: може надходити частково заповненим (наприклад, name=null, коли поле не надіслали)
public record UpdateProductRequest(String name, ProductStatus status) {
}
Тепер наївний мапер (або просто наївний код сервісу) може виглядати так:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void updateProduct(Long id, UpdateProductRequest dto) {
// Завантажуємо managed-сутність: будь-які зміни в ній будуть відстежуватися
Product product = productRepository.findById(id).orElseThrow();
// Небезпека: коли dto.name() == null, ми затремо поле в базі
product.setName(dto.name());
// Небезпека: коли поле не хотіли змінювати, але прийшло "дефолтне" значення — теж перезапишемо
product.setStatus(dto.status());
}
Проблема тут не в тому, що «так не можна». Проблема в тому, що таке оновлення не розрізняє наміри. Коли dto.name() прийшло null (бо клієнт не хотів змінювати імʼя, а просто не надіслав поле), ви затрете імʼя в базі. Hibernate надішле UPDATE, і він буде на 100% логічним. Нелогічним виявиться лише ваш бізнес-результат.
Друга типова проблема — коли DTO містить значення «як є» (наприклад, "Keyboard "), а setter нормалізує його. Тоді ви знову отримуєте accidental update, тільки вже через мапер: здається, ви просто «переприсвоїли імʼя», а насправді змінили нормалізоване значення.
Є більш акуратний стиль — selective mapping: застосовувати лише ті поля, які справді мають змінитися. На рівні коду це часто виглядає нудно (а нудний код — зазвичай добра ознака в persistence layer):
import java.util.Objects;
public void apply(UpdateProductRequest dto, Product product) {
// Застосовуємо поле лише тоді, коли воно справді прийшло в запиті й справді відрізняється
if (dto.name() != null && !Objects.equals(dto.name(), product.getName())) {
// Так ми не чіпаємо setter зайвий раз (а setter може мати побічні ефекти)
product.setName(dto.name());
}
// Для enum зазвичай достатньо порівняння за посиланням, але теж перевіряємо на null
if (dto.status() != null && dto.status() != product.getStatus()) {
product.setStatus(dto.status());
}
}
Тут ми робимо дві речі, які заощаджують години розслідувань. По-перше, не перетворюємо відсутність поля в DTO на команду «зтерти значення в базі». По-друге, не смикаємо setter, якщо значення і так те саме. Це не лише про оптимізацію, хоча й вона приємна; це ще й про зниження ризику випадково зачепити побічний ефект усередині setter-а.
Важливо розуміти нюанс: виклик setter-а сам по собі ще не гарантує UPDATE — це ми вже знаємо. Але виклик setter-а підвищує ймовірність зміни, тому що всередині можуть бути нормалізація, оновлення службових полів або модифікація повʼязаних значень. Тому скорочення «зайвих викликів setter-а» — це реальний інженерний захист, а не смаківщина.
6. Розслідування unexpected UPDATE: пошук причини
Коли вперше ловиш unexpected UPDATE, хочеться зробити дві речі: звинуватити Hibernate й вимкнути SQL-лог, щоб цього жаху не бачити. Краще зіграємо в детектива. У нас уже є всі докази: managed-стан, snapshot-модель і SQL-слід як підсумок. Цього достатньо, щоб без магії знайти конкретний рядок, де стан став іншим.
Починати розслідування корисно не з коду, а з простого питання: «яка саме таблиця оновилася?». Якщо UPDATE пішов у products, значить змінився Product. Якщо в purchase_orders — значить, змінювали PurchaseOrder. Далі дивимося на список колонок у SET ... і приблизно розуміємо, які поля потенційно зачеплені. На цьому етапі не потрібно сперечатися, «чому колонок багато». Колонок справді може бути більше, ніж ви змінювали, і це нормально для базового рівня — ми зараз шукаємо джерело зміни, а не тюнінгуємо форму UPDATE.
Наступний крок — переконатися, що сутність узагалі була managed у поточній транзакції. Якщо ви отримали її через entityManager.find(...) або repository.findById(...) всередині @Transactional, то це майже напевно managed-сценарій. Якщо обʼєкт прийшов із зовнішнього шару і вже detached, це інша історія й інші механіки — але не сьогоднішня тема.
Після цього є два дуже практичні прийоми.
Перший — діагностичний «термометр» Hibernate: Session.isDirty(). Він показує, чи вважає Hibernate поточну сесію «брудною», тобто чи є в ній зміни. Це Hibernate-specific API, тому ми дістаємо Session через unwrap():
import org.hibernate.Session;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void debugUnexpectedUpdate(Long id) {
// Завантажуємо managed-сутність
Product p = entityManager.find(Product.class, id);
// Дістаємо Hibernate Session (специфічний API, не JPA)
Session session = entityManager.unwrap(Session.class);
// До змін сесія "чиста"
System.out.println(session.isDirty()); // false
// Вносимо "невинну" зміну — для Hibernate це вже привід стати dirty
p.setName(p.getName().trim());
// Після зміни сесія стає "брудною" — отже на flush/commit імовірний UPDATE
System.out.println(session.isDirty()); // true
}
Цей код добрий тим, що перетворює «відчуття» на факт. Якщо до вашої «невинної» дії isDirty() був false, а після став true, значить, ви точно десь змінили managed-стан. Далі вже питання техніки: що саме і де.
Другий прийом — використовувати detach() як «ізоляційну стрічку». Ми вже знаємо з попереднього дня: detached-обʼєкт не бере участі в dirty checking. Тому якщо ви хочете перевірити гіпотезу «UPDATE зʼявляється через брудний managed-обʼєкт», можна тимчасово відʼєднати сутність від контексту й подивитися, чи зникне оновлення. Це не лікування, а діагностика, як «вимкнути автомат і подивитися, чи перестане іскрити». Приклад:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public String titleButDetached(Long id) {
// Спочатку сутність managed
Product product = entityManager.find(Product.class, id);
// "Відʼєднуємо" її від persistence context: далі це просто Java-обʼєкт
entityManager.detach(product);
// Тепер зміни не потраплять у dirty checking і не призведуть до UPDATE
product.setName(product.getName().trim());
// Для логіки застосунку це може бути нормально, але важливо памʼятати: ви працюєте з detached
return product.getSku() + " " + product.getName();
}
Якщо після detach() UPDATE зник, значить, причина справді була в dirty checking managed-сутності, а не в якихось «прихованих save». Це дуже швидко звужує коло пошуку.
І нарешті, найпряміший спосіб знайти винуватця — поставити брейкпоінт у конкретний setter і подивитися call stack. Звучить банально, але працює майже завжди. Ваше завдання — не «зрозуміти Hibernate взагалі», а знайти, хто саме у вашій транзакції торкнувся поля. Дуже часто винуватцем виявляється несподіваний шар: якийсь «форматувач для UI», «нормалізатор» або «мапер», який ви вважали нешкідливим.
Щоб не перетворювати діагностику на хаос, зручно тримати перед очима маленьку таблицю інструментів і їхнього сенсу:
| Інструмент | Що дає | Чим допомагає саме з accidental update |
|---|---|---|
| SQL trace | факт UPDATE, таблиця, часто колонки | підтверджує, що оновлення справді було |
| Session.isDirty() | «сесія брудна чи ні» | показує момент, коли зміни зʼявилися |
| detach() | виключає обʼєкт із dirty checking | дає змогу швидко підтвердити або спростувати гіпотезу |
| брейкпоінт у setter-і | call stack | показує точний рядок винуватця |
Зверніть увагу: жоден пункт не вимагає «додати ще анотацій». Accidental update майже ніколи не лікується анотацією. Він лікується дисципліною: знати, де ви пишете, а де читаєте.
7. Правила, які заощаджують години
Коли accidental update вже одного разу вкусив, зʼявляється спокуса перейти в режим «ніколи не чіпати entity руками». Це теж крайність. Нам потрібна не параноя, а акуратний стиль. Найкорисніша звичка — подумки ділити код на read-шлях і write-шлях, навіть якщо технічно обидва живуть в одній транзакції та обидва працюють із тими самими сутностями.
У read-шляху намагайтеся не викликати методи, які змінюють стан сутності, навіть якщо вони «просто нормалізують». Будь-яке обчислення для відображення краще робити в локальних змінних: так і безпечніше, і читабельніше. Якщо дуже хочеться «поправити дані» під час читання, чесніше перетворити це на явний write-сценарій: окремий метод сервісу, окремий намір — і тоді UPDATE вже не дивує, тому що ви його очікуєте.
У write-шляху, навпаки, не бійтеся того, що save() не викликано. Якщо сутність managed, dirty checking зробить свою справу. Але уважно ставтеся до мапінгу: оновлюйте лише те, що справді має оновитися. Це не «зайвий код», а контракт вашого сценарію. А якщо просте застосування DTO викликає неочікувані зміни, вважайте це сигналом: у вас змішані «дані для виводу» і «дані для запису» або setter-и занадто розумні для свого ж блага.
І ще один практичний принцип, який спочатку здається занудним, а потім стає улюбленим: якщо метод називається як читання (getTitle(), buildSummary(), toDisplayString()), він не повинен змінювати стан сутності. Сюрпризи в таких місцях — найдорожчий вид сюрпризів, тому що їх там не чекають.
8. Типові помилки під час пошуку та виправлення accidental update
Помилка №1: шукати UPDATE лише там, де є save().
Після Spring Data багато хто звикає вважати save() головним джерелом запису. У Hibernate реальний запис зʼявляється не «через save», а через різницю між snapshot і поточним станом managed-сутності. Тому відсутність save() не означає відсутності запису, а наявність save() не означає, що запис узагалі був потрібен.
Помилка №2: робити «нешкідливу нормалізацію» через setter у методі читання.
trim(), toUpperCase(), форматування, підстановка дефолтів — усе це виглядає як косметика, доки ви не згадаєте, що обʼєкт managed. Для Hibernate це не косметика, а зміна даних. Якщо нормалізація потрібна лише для виводу, тримайте її в локальній змінній і не чіпайте сутність.
Помилка №3: сліпо мапити DTO в сутність і вважати це «акуратним оновленням».
DTO часто надходить із частково заповненими полями, null-ами, дефолтами або значеннями «як ввели». Якщо ви механічно присвоюєте все це в сутність, отримуєте зміни, яких не планували. Selective mapping нудний, але він утримує контракт оновлення у ваших руках.
Помилка №4: писати setter-и з побічними ефектами й забувати, що вони побічні.
Setter, який оновлює updatedAt, перераховує поля для пошуку або тригерить додаткову логіку, перетворюється на маленьку бізнес-операцію. Це іноді нормально, але тоді такий setter не можна використовувати в місцях, які ви вважаєте «читанням». Інакше ви самі створюєте accidental update, а потім дивуєтеся його чесності.
Помилка №5: ігнорувати момент, коли сесія стала dirty, та намагатися «зрозуміти Hibernate по зірках».
Якщо ви не фіксуєте момент появи змін — через Session.isDirty(), брейкпоінти в setter-ах або тимчасовий detach() — розслідування перетворюється на ворожіння. Hibernate в цьому місці якраз зручний: він дає достатньо спостережуваності, потрібно лише нею користуватися.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ