JavaRush /Курси /Hibernate deep-dive /Неочікувані UPDATE...

Неочікувані UPDATE та accidental update

Hibernate deep-dive
Рівень 3 , Лекція 2
Відкрита

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 в цьому місці якраз зручний: він дає достатньо спостережуваності, потрібно лише нею користуватися.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ