JavaRush /Курси /Hibernate deep-dive /Оптимістичне блокування і @...

Оптимістичне блокування і @Version

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

1. Вступ

Якщо ви колись редагували документ у Google Docs і бачили «хтось іще змінив файл», то ви вже інтуїтивно розумієте optimistic locking. Ідея проста: ми не ставимо залізний замок на двері й не блокуємо рядок наперед, але перевіряємо під час збереження, чи ніхто не встиг «переставити меблі», поки ми вважали, що кімната наша. У Hibernate роль такого «детектора змін» виконує поле версії.

Ми вже бачили, як звичайний read–modify–write тихо губить одне з оновлень. Отже, потрібен механізм, який не вгадує, який із двох оновлень «правильніший», а принаймні помічає: рядок між читанням і записом уже встиг змінитися.

Важливо одразу зняти одну популярну спокусу: версія — це не бізнес-лічильник і не «версія товару в каталозі». Це технічне поле конкурентного контролю. Воно не відображає предметний зміст, не має бути частиною доменної логіки (типу «якщо версія парна — знижка»), і зазвичай ви не хочете світити його назовні, якщо у вас взагалі є вебшар. Усередині рівня персистентності це просто маркер: «ця копія даних актуальна щодо БД або вже застаріла».

Під капотом Hibernate використовує версію майже як атомарну операцію compare-and-swap зі світу багатопотоковості: «онови рядок, якщо версія досі дорівнює очікуваній». Для нас, як для інженерів, це дуже цінна річ: вона перетворює тиху втрату даних на явний сигнал. Вона не вирішує все за нас (про реакцію на конфлікт поговоримо пізніше), але робить проблему спостережуваною.

2. @Version: як Hibernate вмикає версії

Коли ви ставите на поле анотацію @Version, Hibernate починає ставитися до сутності як до «версійованої». Це означає, що під час будь-якого звичайного оновлення цієї сутності — тим самим «звичайним» способом через керований об’єкт і dirty checking — Hibernate включатиме версію в SQL. І це не «опція десь збоку», а базова семантика optimistic locking у JPA/Hibernate.

Додаємо @Version в InventoryItem

Почнімо з головного героя сьогоднішнього дня — InventoryItem. За проєктом Commerce Persistence Lab це сутність залишків, де паралельні зміни природні: два замовлення одночасно намагаються резервувати один і той самий товар. Тому @Version тут — як ремінь безпеки: не робить вас безсмертними, але рятує від дурної й дорогої аварії.

Нижче — мінімальний фрагмент сутності. Зверніть увагу: поле версії не бере участі в методі reserve(qty). Воно взагалі не є бізнес-логікою.

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;

@Entity
public class InventoryItem {

    @Id
    private Long id; // Технічний ідентифікатор рядка в таблиці

    @Version
    private Long version; // Технічний маркер optimistic locking (не бізнес-поле)
}

Тепер додамо трохи «тіла» — саме ті поля, які ми оновлюватимемо під час резервування. Тут важливо не захопитися й не перетворити приклад на роман на 500 рядків: нам потрібна механіка @Version, а не повний домен.

import jakarta.persistence.Version;

public class InventoryItem {

    @Version
    private Long version; // Hibernate оновлює це поле сам після успішного flush/update

    private int availableQty; // Скільки доступно для резервування
    private int reservedQty;  // Скільки вже зарезервовано

    public void reserve(int qty) {
        // Важливо: бізнес-метод НЕ чіпає version — він змінює лише доменні поля
        availableQty -= qty;
        reservedQty += qty;
    }
}

Окремо відзначу деталь, яка новачків іноді дивує: поле версії зазвичай роблять Long, а не long. Причина не в «культі обгорток», а в практичній ініціалізації. Для нової сутності значення версії до persist() може бути null, а після вставки Hibernate сам проставить початкову версію. Так легше уникнути дивної плутанини, коли в нової сутності «версія 0» зʼявляється ще до того, як вона взагалі існує в БД.

Придатні типи версії

У реальному житті ви зустрінете два популярні варіанти: числову версію та версію за часом. Ми в цьому курсі й у проєкті триматимемося числової версії — вона передбачувана й добре читається в SQL-лозі.

Ось невелика таблиця, щоб у вас у голові зʼявилося «просте меню», а не відчуття, що можна ставити @Version на що завгодно, навіть на String з емодзі.

Тип версії (Java) Як поводиться Коментар
Long /
Integer
+1 на кожний успішний update Найзручніший для навчання та діагностики за SQL
java.sql.Timestamp змінюється на «поточний час» Трапляється, але гірше читається в логах і в тестах для новачків

Ми обираємо Long, бо в SQL-лозі чудово видно «було 5 → стало 6», і простіше пояснити, чому один UPDATE пройшов, а інший — ні.

3. Версійний UPDATE у SQL

Зараз буде важливий момент, заради якого взагалі починається розмова про @Version. У звичайному неверсіонованому світі Hibernate робить приблизно такий SQL: «онови рядок із таким-то id». І якщо паралельно хтось устиг записати нове значення — ну… таке життя, останнє оновлення перемогло.

У версійованому світі Hibernate робить хитріше: «онови рядок, якщо його версія досі та сама, яку я читав». Якщо версія вже інша — значить, хтось устиг записати раніше. І тоді Hibernate бачить, що оновлення не зачепило жодного рядка, та піднімає виняток. Тобто lost update перетворюється на явний конфлікт, який можна обробити.

SQL-форма, яку потрібно впізнавати

Ось приблизно така форма запиту — ваш головний візуальний маркер optimistic locking:

update inventory_item
set available_qty=?,
    reserved_qty=?,
    version=?         -- нова версія (Hibernate її обчислить і запише)
where id=? and version=?; -- стара версія: контроль, що рядок не чіпали з моменту читання

Якщо перевести це на людську мову, вийде так: «запиши нові значення і нову версію, але тільки якщо рядок із таким id досі має очікувану стару версію».

Зазвичай у ваших логах будуть ще bind-параметри. В умовному прикладі це могло б виглядати так:

-- Приклад смислових значень, які стоять за знаками питання (?)
-- стара версія = 5
-- нова версія = 6
-- де id=1 і version=5

У реальному Hibernate-трасуванні ви побачите ?, але сенс саме такий: у SET іде нова версія, а в WHEREстара версія, та, що була в persistence context на момент читання.

WHERE ... version = ? як «контрольна пломба»

Уявіть товар на складі як коробку з пломбою. Ви прочитали дані — це як подивитися на пломбу і записати її номер. Коли ви несете коробку назад, ви перевіряєте: «пломба досі з тим самим номером?» Якщо хтось устиг розкрити коробку, номер пломби змінився — і ви вже не вдаєте, що все нормально. Ви зупиняєте процес і кажете: «Стоп, коробку чіпали».

Без версії у вашої сутності немає «пломби». Ви просто сподіваєтеся, що ніхто не встиг. У реальній системі це надія приблизно рівня «у нас же прод, хто туди полізе». Спойлер: полізуть. Не тому, що злі, а тому що паралельність — це нормальна фізика серверів.

Якщо рядок уже оновили

Якщо інша транзакція вже встигла зробити успішний UPDATE і підняти версію, ваш версійний UPDATE більше не збіжиться за умовою version=?. Тоді база оновить 0 рядків. Hibernate перевіряє кількість зачеплених рядків, бачить «0» і інтерпретує це як конфлікт версії.

З точки зору JPA це зазвичай призводить до OptimisticLockException. У чистому Hibernate ви можете побачити StaleObjectStateException. А якщо все це біжить усередині Spring, то часто спливає перекладений виняток на кшталт ObjectOptimisticLockingFailureException. Назви корисно впізнавати хоча б як сигнал: «ага, це не NullPointerException, а конкурентний конфлікт».

4. Версія в persistence context

Дуже часта «помилка мислення» у новачка така: «Я поставив @Version, отже конфлікт або оновлення версії відбувається в момент, коли я викликаю item.reserve(qty)». Ні. Виклик методу змінює Java-поля. Але optimistic locking — це про запис у БД, а отже проявляється там, де Hibernate реально робить UPDATE.

Поки ви змінюєте об’єкт у пам’яті, жодного порівняння версій із БД не відбувається. Hibernate порівняє версії й спробує оновити рядок лише тоді, коли надсилатиме SQL — зазвичай на flush() або під час завершення транзакції. У цій лекції ми зосередимося на тому, як версія змінюється під час успішного запису, без конфлікту.

Версія до і після flush()

Зробімо маленький сервісний метод — у дусі нашого проєкту, — який явно показує момент зміни версії. Сценарій простий: знайшли InventoryItem, запам’ятали версію, зарезервували, зробили flush(), подивилися на версію ще раз.

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

@Service
public class InventoryDebugService {

    private final EntityManager entityManager;

    public InventoryDebugService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional
    public void reserveAndPrintVersion(Long itemId, int qty) {
        // Сутність стає керованою (потрапляє до persistence context)
        InventoryItem item = entityManager.find(InventoryItem.class, itemId);

        // До flush() версія ще та, що була прочитана з БД
        System.out.println("до = " + item.getVersion()); // до = 0

        // Змінюємо доменні поля: це ще не звернення до БД
        item.reserve(qty);

        // Саме тут Hibernate сформує UPDATE ... WHERE id=? AND version=? і надішле його до БД
        entityManager.flush();

        // Після успішного UPDATE Hibernate оновить version-поле в об'єкті в пам'яті
        System.out.println("після = " + item.getVersion());  // після = 1
    }
}

Зверніть увагу на дві речі. По-перше, після reserve(qty) версія ще стара: у пам’яті у вас просто змінилися поля availableQty і reservedQty. По-друге, після flush() Hibernate надіслав UPDATE, база його прийняла, і Hibernate оновив version-поле в керованому об’єкті.

Чому Hibernate змінює version в об’єкті

Це важлива частина «магії, яка насправді логіка». Hibernate зобов’язаний синхронізувати in-memory стан керованої сутності з тим, що реально записано в таблицю. Якщо він надіслав UPDATE і в цьому UPDATE установив version=version+1, то в пам’яті теж має опинитися нова версія. Інакше наступний update цієї самої сутності всередині тієї самої транзакції виглядав би дивно: Hibernate далі вважав би, що версія стара, хоча він сам же її підвищив.

З точки зору вашої ментальної моделі зручно уявляти це так: у керованої сутності є «паспорт актуальності». Після успішного оновлення Hibernate вклеює в паспорт нову печатку і далі живе з нею.

Що буде при INSERT

Коли ви створюєте нову сутність і зберігаєте її через persist(), Hibernate під час вставки теж задає стартову версію. У числовому варіанті це зазвичай 0 (іноді 1 — залежить від налаштувань і діалекта, але для нас важлива ідея: стартова версія зʼявляється автоматично).

Ось приклад-демонстрація, де в нової сутності версія спочатку null, а потім стає числом:

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

@Transactional
public void createItem(EntityManager em) {
    InventoryItem item = new InventoryItem();

    // До persist() сутність ще не в БД, тому версія може бути null
    System.out.println(item.getVersion()); // null

    // persist() зробить сутність керованою, але INSERT зазвичай піде на flush/commit
    em.persist(item);
    em.flush(); // Явно надсилаємо INSERT, щоб побачити, як проставилася версія

    // Після успішного INSERT Hibernate проставить стартову версію в об'єкті
    System.out.println(item.getVersion()); // 0
}

Тут важлива сама механіка: у нової сутності версія спочатку відсутня, а після успішного INSERT стає числом. Доменні поля для цієї точки нічого не змінюють.

5. Колонка version у БД: Flyway

Коли ви додаєте @Version в entity, Hibernate сам по собі не має керувати схемою. У нашому курсі схема живе в Flyway, і це свідоме правило. Отже, нам потрібно додати колонку версії в таблицю.

Тут є один практичний нюанс, про який часто забувають: якщо таблиця вже містить рядки, просто додати NOT NULL колонку без default — означає одразу поставити БД у стан образи. PostgreSQL чесно скаже: «А що мені в старі рядки записувати?» Тому ми робимо міграцію акуратно: додаємо колонку з default, щоб існуючі записи отримали стартову версію.

Приклад міграції Flyway

Нижче — невеликий фрагмент SQL-міграції. Він короткий, але відображає правильну ідею.

alter table inventory_item
    add column version bigint; -- 1) розширюємо схему: додаємо колонку

update inventory_item
set version = 0               -- 2) ініціалізуємо старі рядки стартовою версією
where version is null;

alter table inventory_item
    alter column version set not null; -- 3) посилюємо обмеження, коли дані вже підготовані

Так, можна було б зробити add column version bigint not null default 0, і для багатьох випадків це теж робочий варіант. Але варіант у три кроки добре показує саму логіку зміни схеми: спочатку розширюємо таблицю, потім ініціалізуємо існуючі дані, потім посилюємо обмеження. Такий стиль ще й людям читається легше: видно не тільки результат, а й безпечну послідовність змін.

Індекси

Зазвичай окремий індекс на version не потрібен. Ми не шукаємо за версією і не будуємо звіти на кшталт «покажи всі рядки з version=7». Версія бере участь у WHERE разом із id, і в нас і так є індекс/PK за id. Тобто PostgreSQL спочатку знаходить рядок за id, а потім перевіряє значення версії. Тому додавання індексу на версію заради optimistic locking — це найчастіше зайва робота.

6. Де ставити @Version у проєкті

Після того як ви побачили магію WHERE version=?, з’являється природне бажання: «О, класно! Додаймо @Version взагалі в усі сутності, а раптом». Це зрозумілий порив, але краще тримати голову холодною. Версія — інструмент конкурентної цілісності, і його треба ставити там, де є ризик паралельних оновлень, які справді важливі.

У Commerce Persistence Lab найочевидніший кандидат — InventoryItem, бо це «поточний стан» залишків, а поточний стан майже завжди редагується конкурентно. Другий хороший кандидат — PurchaseOrder, якщо ви допускаєте паралельні зміни статусу або складу замовлення (у реальному житті таке буває, наприклад, коли одночасно працює користувач і фонові процеси). Для каталогу (Product) питання більш контекстне: якщо у вас є backoffice, де кілька менеджерів одночасно редагують картку товару, версія теж може бути корисною — але конфліктів може стати більше, і це вже архітектурний вибір.

Щоб не мучити вас сухими правилами, дам практичне інженерне формулювання. Версію варто ставити там, де ви не хочете допустити ситуацію «останній записав — той і переміг», і водночас готові трактувати конфлікт як нормальний результат операції. Для залишків це майже завжди правда. Для довідників — залежить від домену й процесів.

Є ще один психологічний момент: @Version не робить систему «блокувальною». Це не заборона паралельної роботи, а спосіб чесно сказати: «паралельність сталася, і ми її побачили». Якщо ваша команда або ваш сервісний шар не готові до того, що іноді операція завершуватиметься конфліктом, версіонування сприйматиметься як «чому воно падає», хоча насправді воно запобігає значно страшнішому: тихому некоректному запису.

7. Гарантії @Version та обмеження

Дуже корисно відокремлювати «що механізм робить» від «що я хотів, щоб він робив». @Version гарантує, що Hibernate спробує оновити рядок лише за збігу очікуваної версії й перетворить невідповідність на помилку конкурентності. Тобто він захищає вас від lost update у стилі «тихо перезаписали», роблячи це явним конфліктом.

Але @Version не гарантує, що бізнес-умови самі себе виконають. Наприклад, якщо у вас availableQty може стати від’ємним, версія не врятує: обидві транзакції можуть пройти перевірку «qty вистачає» на старих даних, а потім одна з них впаде через конфлікт. І це нормально: optimistic locking — механізм виявлення конкуренції, а не механізм бізнес-валидації.

Ще один момент, який іноді дивує: версія стосується всієї сутності. Це означає, що навіть якщо два потоки змінюють різні поля, вони все одно конфліктують. З точки зору цілісності це часто правильно: обидві операції спиралися на застарілий стан сутності як цілого. Але якщо ви очікували «нехай один змінює reservedQty, інший updatedAt, і вони не заважають одне одному», то доведеться прийняти реальність: optimistic locking працює на рівні рядків і сутностей, а не на рівні «стовпчик за стовпчиком». Для початку корисно тримати саме цю базову модель — вона найпередбачуваніша.

8. Типові помилки під час додавання @Version

Помилка № 1: сприймати version як бізнес-поле й намагатися керувати ним вручну.
Дуже хочеться зробити щось на кшталт item.setVersion(item.getVersion() + 1), «щоб було чесно». Але це саме той випадок, коли ви заважаєте інструменту робити свою роботу. Версію змінює Hibernate в момент успішного оновлення або вставки, і ручне втручання ламає сенс перевірки «очікувана версія → нова версія».

Помилка № 2: чекати, що конфлікт буде виявлено в момент зміни Java-полів.
Метод reserve(qty) або setAvailableQty(...) не звертається до бази. Тому жодної перевірки версії там бути не може. Конфлікт буде виявлено тоді, коли Hibernate надішле SQL. Якщо вам потрібно «побачити результат» усередині сервісного методу, доведеться явно зробити flush(), щоб SQL і перевірка версії відбулися прямо тут.

Помилка № 3: додати @Version в entity, але забути про міграцію Flyway.
Hibernate вміє багато чого, але в нашому baseline він не керує схемою. Якщо ви додали поле версії в Java, але не додали колонку в БД, усе закінчиться дуже швидко й дуже сумно: винятками під час запуску застосунку або під час першого ж запиту. Тому правило просте: @Version у коді → version у міграції.

Помилка № 4: ставити @Version «про всяк випадок» усюди підряд і дивуватися кількості конфліктів.
Версія — це не «прискорювач якості». Це інструмент конкурентної чесності. Якщо у вас сутність часто й паралельно оновлюється, конфлікти почнуть спливати. Це не вада — це сигнал. Але якщо ви не готові змінювати сценарії або UX (наприклад, робити повторне читання або явний конфлікт), то отримаєте «падаючий» сервіс замість «тихо некоректного». Тому ставимо @Version там, де справді важливіша коректність, ніж ілюзія, що все завжди проходить.

Помилка № 5: забувати дивитися на SQL-лог і не вчитися впізнавати WHERE ... version=?.
Optimistic locking — дуже спостережуваний механізм. Він буквально пише свій підпис у SQL. Якщо ви не дивитеся на generated SQL, ви втрачаєте головний інструмент діагностики курсу. Привчайте себе: побачили конфлікт — відкрили SQL — знайшли update ... where id=? and version=? — зрозуміли, що сталося.

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