JavaRush /Курси /Hibernate deep-dive /@DynamicUpdate та ши...

@DynamicUpdate та ширина UPDATE

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

1. Ширина UPDATE: чому це важливо

Коли починаєте працювати з ORM, спершу радієте вже тому, що UPDATE «якось відбувається». Але ближче до реальності зʼясовується, що один UPDATE може бути різним. Він може змінювати одну колонку, а може «переписувати пів таблиці», навіть якщо ви змінили один прапорець. І ось тут мозок починає ставити запитання.

Уявіть, що ви прийшли до паспортного столу змінити одну літеру в прізвищі, а працівник каже: «Чудово! Давайте передрукуємо весь паспорт цілком, включно з номером, датою народження та місцем реєстрації». Формально результат правильний. Але відчуття таке, ніби десь ми виконуємо більше роботи, ніж потрібно. Ось «ширина UPDATE» — це приблизно про це: скільки колонок реально потрапляє до SET і скільки значень ми щоразу передаємо через SQL.

Після вибору форми моделі природно запитати, який SQL вона формує під час оновлення. Тут уже зручніше дивитися не на PromotionCampaign, а на Product: у нього вузькі зміни видно одразу — перейменування, зміна прапорця активності, коригування ціни. Саме на таких сценаріях добре видно, коли UPDATE залишається широким, а коли Hibernate може звузити його до змінених полів.

Це важливо не тому, що ми мріємо «оптимізувати кожен рядок», а тому, що вчимося передбачати поведінку ORM. @DynamicUpdate змінює форму SQL. А форма SQL — це те, що ви побачите в логах, те, що плануватиме база даних, і те, що ви потім пояснюватимете на code review, а інколи — навіть самі собі о 3 годині ночі.

Щоб побачити проблему наживо, достатньо уявити такий «звичайний» SQL без будь-якої магії:

-- «Широкий» UPDATE: у SET потрапляє більше колонок, ніж реально змінювалося
update product
set sku = ?, name = ?, status = ?, price_amount = ?, price_currency = ?, active = ?
where id = ?;

Якщо ви змінили лише name, то виникає запитання: навіщо Hibernate надсилає значення для sku, status, price_* , active? І ось тут якраз і зʼявляється тема @DynamicUpdate.

2. Режим UPDATE за замовчуванням: часто широкий

Якщо ви сьогодні вперше почули фразу «Hibernate оновлює всі колонки», не лякайтеся: це не означає, що він «не вміє» dirty checking. Уміє. Просто в режимі за замовчуванням dirty checking часто відповідає на запитання «оновлювати чи не оновлювати взагалі», а не обовʼязково на запитання «які саме колонки включати в SQL».

З погляду внутрішньої моделі, яку ми вже напрацьовували в темах про dirty checking і flush, типовий сценарій виглядає так: ви в межах транзакції завантажуєте Product, змінюєте одне поле, а на flush Hibernate вирішує: «Так, сутність dirty, отже потрібен UPDATE». Далі він бере заздалегідь підготовлений SQL-шаблон оновлення для цієї сутності й просто підставляє туди актуальні значення всіх колонок.

Дуже спрощено це можна показати так:

flowchart TD
    A["em.find(Product, id)"] --> B[Product стає managed]
    B --> C["Змінюємо поле: product.setName(...)"]
    C --> D[Flush/Commit]
    D --> E[Dirty checking: чи є зміни?]
    E -->|так| F[Hibernate виконує UPDATE]
    E -->|ні| G[UPDATE не потрібен]

Зверніть увагу на важливу думку: за замовчуванням Hibernate може знати, що змінилося “одне поле”, але все одно надіслати “широкий UPDATE”. Причина, як правило, банальна й прагматична: стабільний SQL добре кешується, добре перевикористовується як prepared statement, а база даних менше витрачає часу на парсинг і планування безлічі варіантів.

Ось мініприклад із нашого проєкту, у стилі “Commerce Persistence Lab”, де ми змінюємо лише імʼя товару. Код максимально нудний — як ранок понеділка, але зате чесний:

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductAdminService {
    private final EntityManager em;

    public ProductAdminService(EntityManager em) {
        // EntityManager — вхід до persistence context (того, де живуть managed-сутності)
        this.em = em;
    }

    @Transactional
    public void renameProduct(Long id, String newName) {
        // Усередині транзакції сутність стає managed, Hibernate починає відстежувати зміни
        Product p = em.find(Product.class, id);

        // Змінюємо одне поле: dirty checking побачить зміну і на flush надішле UPDATE
        p.setName(newName);
    }
}

Якщо Product без @DynamicUpdate, то зазвичай ви побачите в SQL trace щось на кшталт «онови все, що можна оновити», навіть якщо реально змінювалося одне поле. Це не «помилка Hibernate», це його звичайний і в певному сенсі дуже практичний режим роботи.

3. @DynamicUpdate: UPDATE по змінених полях

Ось тут і зʼявляється @DynamicUpdate. І починати розмову про нього варто не зі слова «оптимізація», а зі слова «форма SQL». Ця анотація просить Hibernate не використовувати заздалегідь фіксований UPDATE для сутності, а збирати SQL на льоту, орієнтуючись на те, які властивості були визнані dirty.

Тобто сенс такий: dirty checking і так знає, що змінилося. @DynamicUpdate каже: «Гаразд, раз ти все одно це знаєш, то давай у SET включимо лише ті колонки, які реально змінювалися». Важливо, що це анотація Hibernate, а не «чистий JPA», тому імпорт буде з пакета Hibernate.

Мінімальний фрагмент, саме фрагмент, а не повний клас, виглядає так:

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import org.hibernate.annotations.DynamicUpdate;

@Entity
@DynamicUpdate // Просимо Hibernate збирати UPDATE під реально змінені поля (dirty properties)
public class Product {
    @Id
    @GeneratedValue // Ідентифікатор генерується БД/провайдером, його не чіпаємо в UPDATE
    private Long id;

    // Якщо змінюємо лише name, то при @DynamicUpdate у SET зазвичай потрапить лише ця колонка
    private String name;
}

Після цього, якщо ви змінюєте лише name, SQL може стати вужчим — наприклад:

update product set name = ? where id = ?;

А якщо ви змінюєте два поля, то SQL розшириться рівно до цих двох полів, але не до всіх інших.

Щоб не було відчуття, ніби ми говоримо «на око», ось компактна таблиця-очікування. Насправді назви колонок залежать від вашого мапінгу, але ідея одна й та сама:

Сценарій зміни Без @DynamicUpdate (часто) З @DynamicUpdate (ідея)
Змінюємо лише name SET sku=?, name=?, status=?, ... SET name=?
Змінюємо name і active SET sku=?, name=?, status=?, ... SET name=?, active=?
Нічого не змінюємо UPDATE не буде UPDATE не буде

І ось тут важливо проговорити те, що не змінюється, щоб не потрапити в пастку очікувань:

@DynamicUpdate не скасовує dirty checking. Він не робить роботу Hibernate «магічно дешевою». Hibernate все одно має зрозуміти, що змінилося.

@DynamicUpdate не зменшує кількість запитів сам по собі. Якщо у вас був N+1 на читаннях або зайві SELECT через поганий fetch-план, ця анотація взагалі про інше.

@DynamicUpdate не є «лікуванням поганої моделі». Якщо сутність занадто широка, іноді правильне рішення — не «динамічно оновлювати широку таблицю», а зробити модель охайнішою. Ми якраз навчалися цього через ProductDetails, embeddables і композицію.

4. Експеримент у Commerce Persistence Lab

Зараз зробимо найкорисніший для розуміння крок: перетворимо @DynamicUpdate з абстрактної «анотації з інтернету» на спостережуваний ефект у SQL trace. Це особливо важливо для новачків: доки ви не побачили різницю на власні очі, мозок думатиме, що @DynamicUpdate — це «щось там про прискорення». А нам потрібна точна механіка.

Сценарій будемо проганяти на Product, бо це центральний обʼєкт проєкту й у нього природні «вузькі» зміни: перейменувати товар, приховати товар, змінити ціну. Щоб бачити ефект, увімкніть профіль SQL trace. У нас у проєкті він і так передбачається. Важливо не просто бачити SQL, а й не забувати: UPDATE зазвичай відправляється на flush/commit.

Сценарій A: змінюємо лише name

Сервісний метод, не обовʼязково саме так у вас названий, — сенс важливіший:

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogWriteService {
    private final EntityManager em;

    public CatalogWriteService(EntityManager em) {
        // Тримаємо EntityManager як залежність сервісу
        this.em = em;
    }

    @Transactional
    public void rename(Long id, String newName) {
        // Завантажуємо managed-сутність у межах транзакції
        Product p = em.find(Product.class, id);

        // Змінюємо лише одне поле — саме це ми хочемо побачити у вузькому UPDATE за @DynamicUpdate
        p.setName(newName);
    }
}

Тепер мисленнєвий експеримент по SQL:

Якщо Product без @DynamicUpdate, то часто лог буде схожий на широкий update, де оновлюються всі updatable-колонки Product, включно з колонками Money, status, active тощо.

Якщо Product з @DynamicUpdate, то при зміні лише name Hibernate постарається надіслати вузький UPDATE, де в SET буде тільки name.

Так, це звучить «очевидно», але саме так і зʼявляється звичка: ми не віримо словам, ми віримо SQL trace.

Сценарій B: змінюємо name і active

Тепер змінимо два поля — щоб побачити, як SQL розширюється рівно під них:

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogVisibilityService {
    private final EntityManager em;

    public CatalogVisibilityService(EntityManager em) {
        // EntityManager потрібен, щоб отримати managed Product і дочекатися flush
        this.em = em;
    }

    @Transactional
    public void renameAndHide(Long id, String newName) {
        // Знаходимо сутність: далі зміни відстежуватимуться автоматично
        Product p = em.find(Product.class, id);

        // Робимо дві зміни — за @DynamicUpdate очікуємо два поля в SET
        p.setName(newName);
        p.setActive(false);
    }
}

Сенс тут простий: при @DynamicUpdate UPDATE стане ширшим, ніж у сценарії A, але все одно не зобовʼязаний включати решту колонок.

І це гарне місце, щоб повʼязати все з темою flush. Якщо ви хочете «зловити момент», коли пішов SQL, можна на деякий час додати примусовий flush — у навчальних цілях це цілком нормально:

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogFlushProbeService {
    private final EntityManager em;

    public CatalogFlushProbeService(EntityManager em) {
        // Цей сервіс потрібен лише як «зонд» моменту flush
        this.em = em;
    }

    @Transactional
    public void renameAndFlush(Long id, String newName) {
        // Завантажуємо managed-сутність
        Product p = em.find(Product.class, id);

        // Робимо зміну...
        p.setName(newName);

        // ...і примусово проштовхуємо зміни до БД, щоб побачити SQL просто тут
        em.flush(); // тут відправляється UPDATE
    }
}

І ось тут зазвичай у студента трапляється «клік»: анотація впливає не на setter, не на find(), а саме на те, який SQL збирається на flush.

Сценарій C: змінюємо Money.amount у @Embeddable

У нашому проєкті ціна — це Money (@Embeddable). Це чудовий приклад для демонстрації того, що «ширина оновлення» стосується не лише «пласких» полів.

Припустімо, Money зберігається як price_amount і price_currency. Ми змінюємо лише amount:

import jakarta.persistence.EntityManager;
import java.math.BigDecimal;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductPricingService {
    private final EntityManager em;

    public ProductPricingService(EntityManager em) {
        // EntityManager потрібен, щоб працювати з managed Product та його embeddable-частинами
        this.em = em;
    }

    @Transactional
    public void changePrice(Long id, BigDecimal newAmount) {
        // Знаходимо товар у persistence context
        Product p = em.find(Product.class, id);

        // Змінюємо лише amount усередині embeddable Money
        // При @DynamicUpdate очікуємо, що в SET потрапить price_amount, але не обовʼязково price_currency
        p.getPrice().setAmount(newAmount);
    }
}

Якщо @DynamicUpdate увімкнено, Hibernate потенційно може оновити лише колонку amount, не чіпаючи currency, якщо currency не змінювалася. Без @DynamicUpdate знову часто виходить «онови все, що є в сутності».

І це, до речі, непогана причина тримати Money або іммутабельним, як ми обговорювали в темі value objects, або змінювати його акуратно: якщо ви випадково зачепите зайве поле в @Embeddable, динамічний update із радістю включить його в SQL. Hibernate в цьому місці дуже чесний: «ти змінив — я записав».

5. Ціна й користь @DynamicUpdate: де виграємо, а де платимо

Навколо @DynamicUpdate легко влаштувати релігійну війну: одні кажуть «вмикайте завжди — менше даних», інші кажуть «ніколи — убʼєте кеш і продуктивність». Як завжди, реальність нудніша й корисніша: це інструмент, який допомагає в деяких сценаріях і заважає в інших. Потрібно розуміти обидві сторони.

Користь @DynamicUpdate зрозуміла на пальцях: якщо сутність широка, а ваші сценарії запису зазвичай змінюють 1–2 поля, то вузький UPDATE виглядає логічніше. Це може зменшувати «шум» у SQL-логах і зменшувати обсяг параметрів, які ви ганяєте туди-сюди. Іноді це особливо помітно, якщо в таблиці є великі поля або рідко змінювані шматки, які не хочеться чіпати без потреби.

Ціна теж не магічна, а інженерна: коли SQL стає динамічним, у вас зʼявляється багато варіантів UPDATE. Це може погіршити перевикористання prepared statements, залежно від оточення, ускладнити діагностику і часом несподівано погіршити загальну картину продуктивності, якщо запити починають занадто часто «стрибати» за формою.

Нижче — проста таблиця, яка допомагає тримати баланс:

Вісь порівняння Звичайний режим (часто широкий UPDATE) @DynamicUpdate
Форма SQL Зазвичай стабільна, «один шаблон на сутність» Може змінюватися залежно від змінених полів
«Ширина» SET Часто максимальна Зазвичай мінімальна по змінених полях
Читабельність SQL trace Іноді шумно: змінили одне, оновили все Іноді зрозуміліше: видно, що саме змінювали
Поведінка кешів SQL/планів Частіше простіше перевикористовувати Може стати складніше через безліч форм
Рятує від поганого mapping Ні Так само ні, але створює ілюзію, що рятує

І тут дуже важливий педагогічний момент: @DynamicUpdate — це не «зробити швидше все», а змінити форму SQL. Іноді це допомагає. Іноді ні. Іноді навіть заважає. Якщо ставитися до анотації як до «речі для краси SQL», теж можна помилитися: красивий SQL не завжди швидкий, а швидкий SQL не завжди красивий. Наша мета — передбачуваність і керованість.

Як приймати рішення: точковий скальпель, а не «галочка за замовчуванням»

Коли початківець-розробник дізнається про @DynamicUpdate, рука свербить поставити його на всі сутності. Це зрозуміле бажання: здається, що «оновлювати лише змінене» — очевидно краще. Але Hibernate — це система компромісів, і «очевидно краще» часто перетворюється на «складно передбачити, чому в проді стало гірше».

Рішення краще приймати не за принципом «мені сподобалося», а через три запитання. Перше: наскільки сутність широка і наскільки реально помітна проблема широкого UPDATE. Друге: які у нас сценарії запису. Якщо ми майже завжди змінюємо все, наприклад оновлюємо всю картку товару пакетом, динамічний update мало що дасть. Третє: чи можемо ми вирішити проблему моделлю, а не прапорцем. Часто замість @DynamicUpdate правильніше тримати дані, що рідко змінюються, в окремій сутності (ProductDetails) або в окремій таблиці, щоб сама природа даних стала зрозумілішою.

Щоб це було зовсім прикладно, ось «нормальна доросла» звʼязка симптомів і рішень, без героїзму:

Спостереження в проєкті Що спробувати спочатку Де може бути доречним @DynamicUpdate
Таблиця дуже широка через модель Спростити модель, винести поля, що рідко змінюються Якщо винести не можна, а оновлення вузькі й часті
У логах дратівливо широкий UPDATE Переконатися, що ви справді змінюєте лише 1–2 поля Якщо так і є, і широкий update заважає
Сутність «брудниться» від випадкових змін Виправити accidental update: сетери, мапери, embeddable @DynamicUpdate тут не лікування, а просто інший вид симптомів

У нашому Commerce Persistence Lab хороша позиція для @DynamicUpdate — це сутність, у якої реально є вузькі, часті зміни, наприклад «активувати/деактивувати» і «перейменувати», і при цьому таблиця відчутно широка. Але якщо сутність і так компактна, застосування @DynamicUpdate — найчастіше просто додавання складності без виграшу.

Важливий нюанс: @DynamicUpdate не виправляє «випадкові UPDATE»

Дуже легко переплутати дві різні проблеми. Перша — «Hibernate оновлює занадто багато колонок, хоча я змінив одну». Друга — «Hibernate взагалі оновлює, хоча я не хотів оновлювати». @DynamicUpdate стосується лише першої. Якщо сутність стала dirty через випадкову зміну, наприклад хтось чіпає Address, мапер бездумно копіює поля, а логування викликає побічні ефекти, то @DynamicUpdate не робить ситуацію правильною — він просто робить її більш вибірково неправильною.

Є навіть кумедний побічний ефект: коли UPDATE широкий, ви іноді помічаєте його раніше, бо в логу прямо видно: «пішло все». А коли UPDATE вузький, він виглядає «нешкідливо», і ви можете довше не помічати, що бізнес-код робить зайві записи. Тому правило залишається тим самим: спочатку дисципліна моделі та транзакційного сценарію, потім — точкові налаштування.

І ще одне: @DynamicUpdate — це не інструмент для «розрулювання складних merge-сценаріїв» і не спосіб зробити detached-flow безпечнішим. Це взагалі інша тема. Тут ми не лікуємо detach/merge, не лікуємо N+1, не лікуємо транзакційні межі. Ми просто змінюємо форму конкретного UPDATE всередині flush-cycle.

6. Типові помилки під час використання @DynamicUpdate

Помилка № 1: ставити @DynamicUpdate на все підряд, бо «так логічніше».
Коли ви ставите анотацію на кожну сутність, ви перетворюєте поведінку SQL на таку, що плаває за формою, без явної причини. Іноді це виглядає як покращення: «о, стало менше колонок». Але ціна спливає пізніше: складніше пояснювати SQL, складніше порівнювати логи, складніше передбачати поведінку. Набагато здоровіше ставитися до @DynamicUpdate як до точкового налаштування під конкретну широку сутність і конкретні вузькі оновлення.

Помилка № 2: очікувати, що @DynamicUpdate зменшить кількість запитів.
Ця анотація не про кількість SELECT або UPDATE. Вона про вміст SET у UPDATE. Якщо у вас проблема у fetching, тобто зайві SELECT, N+1 або зайва ініціалізація звʼязків, @DynamicUpdate взагалі дивиться не туди. У найкращому випадку ви «красиво оновите» дані, які й так надто дорого прочитали.

Помилка № 3: використовувати @DynamicUpdate як заміну нормальному design мапінгу.
Іноді сутність стає широкою не тому, що «так треба», а тому, що в неї склали все підряд: і картку, і деталі, і адміністративні поля, і рідкі великі текстові фрагменти. У такому випадку правильне рішення найчастіше композиційне: винести частину в окрему сутність або таблицю (ProductDetails), а не просити Hibernate щоразу збирати «розумний UPDATE» для монстра.

Помилка № 4: не перевіряти ефект по SQL trace і вірити назві анотації.
Назва DynamicUpdate створює відчуття, що «вона точно спрацює». Але ORM — це система умов: які поля dirty, як влаштований @Embeddable, що саме змінюється в обʼєкті, коли стався flush. Якщо ви не ввімкнули SQL trace і не порівняли фактичні запити, ви, по суті, увімкнули прапорець «на удачу». В інженерії це зазвичай закінчується погано.

Помилка № 5: плутати «вузький UPDATE» з «безпечним UPDATE».
Навіть якщо ви оновлюєте одну колонку замість десяти, це не робить оновлення автоматично «правильним» у сенсі бізнес-логіки. Якщо зміна виникла випадково, або ви оновлюєте не той обʼєкт, або робите це в неправильному сценарії, @DynamicUpdate просто зробить SQL коротшим. А помилка залишиться. ORM, як і життя, не лікується скороченням рядків коду.

1
Задача
Hibernate deep-dive, 16 рівень, 3 лекція
Недоступна
Вузький UPDATE під час перейменування товару
Вузький UPDATE під час перейменування товару
1
Задача
Hibernate deep-dive, 16 рівень, 3 лекція
Недоступна
UPDATE лише за двома зміненими колонками
UPDATE лише за двома зміненими колонками
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ