@Version і optimistic locking

Spring Data JPA
Рівень 25 , Лекція 1
Відкрита

1. Від lost update до optimistic locking

Коли вперше чуєте про optimistic locking, хочеться уявити щось на кшталт «замка на дверях» або «таблички: не чіпати, я тут працюю». Але проблема тут тонша: обидві транзакції самі по собі правильні, обидві в @Transactional, обидві проходять усі перевірки, і база не бачить нічого незаконного. Ми хочемо не заборонити читання і не «синхронізувати Java-потоки», а навчитися виявляти конфлікт запису.

Давайте ще раз сформулюємо проблему максимально прагматично. У нас є stock_item — рядок у таблиці, який постійно змінюють різні сценарії використання: резервування під час оформлення замовлення, повернення під час скасування замовлення, інколи ручне коригування. Коли дві зміни відбуваються майже одночасно, у кожної з них у голові — старе значення. Обидві виконують read modify write, і один із результатів може бути втрачений.

Важливо вловити одну думку: @Transactional робить операцію атомарною всередині самої транзакції, але не гарантує, що дві транзакції не перезапишуть одна одну. Транзакція — це про «все або нічого» для набору SQL-операцій, а lost update — про те, що дві різні транзакції успішно зробили все, але разом зіпсували підсумок.

І саме тут на сцену виходить optimistic locking. Він каже: «Гаразд, працюйте паралельно скільки хочете. Але коли будете записувати результат — доведіть, що ви записуєте поверх тієї версії даних, яку реально читали».

2. @Version і версія рядка

Найзручніша аналогія для @Version — це номер редакції документа. Уявіть, що ви редагуєте спільний Google Doc: ви відкрили документ версії 12, почали правити, а колега за хвилину встиг зберегти версію 13. Коли ви натиснете «зберегти», система має або акуратно об’єднати зміни, або сказати: «Стоп, документ уже змінився — конфлікт». JPA/Hibernate роблять приблизно те саме, тільки замість документа — рядок таблиці.

@Version — це анотація JPA, яка позначає поле сутності як поле версії. Hibernate починає сприймати сутність не просто як «набір колонок», а як «набір колонок + номер версії стану». Цей номер версії зберігається в таблиці поруч з іншими полями. Найприємніше: бізнес-код зазвичай майже не змінюється, тому що контроль конфліктів вбудовано в механіку UPDATE/DELETE.

Схематично конфлікт із версією виглядає так:

sequenceDiagram
    participant TxA as Транзакція A
    participant DB as База даних
    participant TxB as Транзакція B

    TxA->>DB: "SELECT StockItem(id=1) → version=5, qty=10"
    TxB->>DB: "SELECT StockItem(id=1) → version=5, qty=10"

    Note over TxA,DB: Оновлення виконується лише якщо збіглися id і version
    TxA->>DB: "UPDATE (очікую version=5) → OK, version стає 6"
    TxB->>DB: "UPDATE (очікую version=5) → 0 рядків оновлено (конфлікт)"

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

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

Що відбувається Без @Version З @Version
Дві транзакції читають один рядок Так Так
Обидві обчислюють нове значення Так Так
Другий запис може перезаписати перший «тихо» Так, класичний lost update Ні, конфлікт стане видимим
Застосунок дізнається про проблему Зазвичай ніяк Так, через optimistic lock failure

І тут важливий психологічний момент: optimistic locking не робить систему «завжди успішною», він робить систему чесною. Краще отримати зрозумілий конфлікт і обробити його, ніж мовчки втратити частину змін і потім дивуватися: «Чому залишки не сходяться».

Колонка version через Flyway

Раз у нас дисципліна «схема живе через міграції», то додавання версії починається не з Java-коду, а зі схеми. Новачкам часто хочеться: «Я додам поле в entity, Hibernate сам створить колонку». Але ми вже пройшли Flyway і розуміємо, що це саме той шлях, який ламає відтворюваність і перетворює базу на загадкову істоту «якось у мене локально вийшло».

Оскільки StockItem уже існує, ми додаємо нову міграцію, яка створить колонку version. Нам важливо відразу зробити її NOT NULL, тому що версія за змістом є обовʼязковою. Але якщо таблиця вже містить рядки, то простий NOT NULL без значення впирається в наявні дані. Тому навчально правильний мінімум — задати DEFAULT 0, щоб старі рядки отримали початкову версію.

Приклад невеликої міграції для PostgreSQL може виглядати так:

-- db/migration/V25_01__stock_item_add_version.sql
-- DEFAULT 0 потрібен, щоб наявні рядки одразу отримали початкову версію
-- Окремий індекс на version зазвичай не потрібен: PK(id) і так бере участь у WHERE id = ? AND version = ?
alter table stock_item
    add column version bigint not null default 0;

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

Ще один нюанс, який варто підкреслити: version зазвичай не індексують окремо. Він бере участь у UPDATE/DELETE разом із id, а id і так індексований як PK. Тобто WHERE id = ? AND version = ? зазвичай нормально відпрацьовує по PK, і окремий індекс на version не дає користі, але додає вартість запису.

3. JPA-мапінг: @Version у StockItem

Коли схема готова, можна чесно додати поле в entity. Тут є просте правило, яке рятує нерви: поле версії — це технічне поле, а не частина бізнес-моделі. Воно не має брати участь у ваших бізнес-обчисленнях, його не потрібно «красиво називати» в доменній логіці, і тим більше не потрібно намагатися «надати йому сенс» на кшталт «версія залишку = номер ревізії складу».

У нашому проєкті StockItem живе в com.example.shopdatajpa.inventory.entity. Додамо туди поле версії. Для навчального проєкту достатньо числового типу long: він простий, підтримується всюди, не потребує спеціальних конвертерів і чудово мапиться в PostgreSQL bigint.

Мінімальний приклад (показую лише важливу частину):

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

@Entity
public class StockItem {

    @Id
    private Long id; // Ідентифікатор рядка (PK): разом із version бере участь у пошуку "того самого" рядка

    @Version
    private long version; // Технічна версія для optimistic locking: бізнес-код не змінює її вручну

    // Сеттер для version зазвичай не потрібен: Hibernate сам керує значенням під час UPDATE/DELETE
}

Зверніть увагу на два тихі рішення:

Перше рішення — ми не зобовʼязані писати сеттер для version. Якщо анотації стоять на полях (field access), Hibernate працює напряму з полем через reflection. Це зменшує шанс, що хтось у сервісі вирішить «трохи допомогти ORM» і почне вручну збільшувати версію, а це майже завжди погана ідея.

Друге рішення — long, а не Long. Примітив не може бути null, отже в Java-обʼєкті версія завжди матиме значення (за замовчуванням 0). Це зручно для новачків, тому що ви не ловите «ой, версія null» у логах і налагодженні. У реальному проєкті Long теж трапляється, але тоді потрібно трохи більше дисципліни, особливо в момент «ще не збережено в БД».

Якщо вам хочеться трохи систематизувати варіанти, то ось практична табличка «без академії»:

Тип у Java Тип у PostgreSQL Коли підходить Коментар
long / int bigint / integer Майже завжди Найзрозуміліший варіант
Instant / LocalDateTime timestamp Іноді Версія як «час останньої зміни», але потребує акуратності
UUID (зазвичай не використовують) Майже ніколи для @Version Занадто екзотично для базового курсу

Для нашого курсу вибір long version — найчесніший: версія — це просто лічильник, який ORM збільшує під час кожної зміни рядка.

4. Автооновлення версії в Hibernate

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

Механіка виглядає так: коли Hibernate робить INSERT нового рядка для versioned entity, він виставляє початкову версію. Для числових версій це зазвичай 0. Під час кожного UPDATE Hibernate збільшує версію на 1 і записує нове значення назад у рядок. При DELETE версія теж бере участь у перевірці: видалення має видалити «ту версію», яку ви реально читали, а не «щось, що вже змінили».

Тут важливе методичне уточнення: optimistic locking — це не «перевірка в Java-коді», це умова на рівні SQL. Тобто база даних у момент оновлення відповідає на запитання: «Чи існує ще рядок із таким id і такою version?» Якщо так — оновлюємо і збільшуємо версію. Якщо ні — значить, стан змінився, і Hibernate вважає це конфліктом.

І ось чому не треба чіпати версію вручну. Якщо ви зробите щось на кшталт:

item.setAvailableQuantity(item.getAvailableQuantity() - qty);

// Демонстрація антипатерну: так робити не треба, версією керує ORM
// На конфлікті ви отримаєте OptimisticLockException / ObjectOptimisticLockingFailureException
item.setVersion(item.getVersion() + 1);

то ви ламаєте дві речі одразу. По-перше, ви втручаєтеся в контракт ORM, і поведінка стає непередбачуваною, особливо якщо десь ще є flush/commit, відкладені зміни та інше життя транзакції. По-друге, ви створюєте ілюзію контролю: здається, що ви «захистилися», а насправді ви просто внесли шум.

Правильна думка така: версія — це частина persistence-механіки, як первинний ключ або dirty checking. Це не бізнес-правило. Вам не потрібно «допомагати» Hibernate робити роботу, яку він стабільно робить уже десятиліттями.

5. Сервісний код з optimistic locking

Найприємніше в @Version — те, що правильно написаний сервісний код, який уже спирається на @Transactional і dirty checking, зазвичай не потрібно змінювати. Це прямо відповідає ідеї курсу: mapping — це інженерне рішення, яке впливає на поведінку всього застосунку, навіть якщо бізнес-методи лишилися тими самими.

Уявіть наш звичний метод резерву, який ми вже обговорювали в лекції про lost update. Сама доменна операція залишається тією самою: reserve(qty) переносить кількість між availableQuantity і reservedQuantity, а не просто віднімає одне число. Сервіс і далі виглядає звично: завантажили StockItem, викликали доменний метод, вийшли з транзакції.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class InventoryService {

    private final StockItemRepository stockItemRepository;

    public InventoryService(StockItemRepository stockItemRepository) {
        this.stockItemRepository = stockItemRepository;
    }

    @Transactional
    public void reserve(Long stockItemId, int qty) {
        // Завантажуємо entity в persistence context: Hibernate запам'ятовує поточну version
        StockItem item = stockItemRepository.findById(stockItemId).orElseThrow();

        // Виконуємо той самий доменний reserve-flow, що й раніше:
        // reserve(qty) змінює і availableQuantity, і reservedQuantity
        item.reserve(qty);

        // Якщо хтось паралельно встиг оновити цей самий рядок,
        // то на flush/commit буде викинуто виняток optimistic locking
    }
}

З погляду Java — нічого нового. І це добре, тому що ми не хочемо перетворювати кожен сценарій використання на «ручне порівняння версій, ручні ifʼи, ручні повторні спроби». Ми хочемо, щоб інфраструктура гарантувала: «якщо ви намагаєтеся записати застарілий стан — ви про це дізнаєтеся».

Сервіс залишається таким, яким він має бути за архітектурою: він виражає намір (reserve), а не «танцює з SQL». Деталі конкуренції при цьому стають частиною persistence layer, де їм і місце.

Окремо підкреслю: саме тому optimistic locking добре лягає на підхід «межа транзакції в сервісі». Якби ви оновлювали StockItem шматками, без транзакції, або «в контролері як вийде», механізм був би набагато менш передбачуваним. Не тому, що @Version поганий, а тому, що архітектура шарів зламана.

6. Межі @Version

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

Але є і межі, про які варто памʼятати.

@Version не замінює бізнес-валідацію. Наприклад, якщо у вас правило «не можна піти в мінус», воно все одно має бути виражене в коді, а інколи і в схемі через додаткові constraints, якщо це доречно. Версія не скаже вам: «ви зарезервували занадто багато». Вона скаже інше: «хтось уже змінив цей рядок з моменту, як ви його прочитали».

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

@Version працює, коли ви оновлюєте дані як entity через Hibernate. Якщо ви раптом робите bulk update (@Modifying) або прямий native SQL, ви можете обійти «нормальну» механіку lifecycle. І тоді optimistic locking або не спрацює так, як ви очікуєте, або потребуватиме явного урахування версії в запиті. Ми вже бачили, що bulk-операції взагалі живуть за іншими правилами — і версія теж не виняток.

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

7. Типові помилки під час роботи з @Version

Оптимістичне блокування часто ламається не тому, що механізм складний, а тому, що ми, люди, занадто оптимістично ставимося до своєї уважності. Нижче — помилки, які трапляються майже гарантовано, якщо не проговорити їх наперед.

Помилка № 1: намагатися збільшувати version вручну.
Спокуса зрозуміла: «Я ж бачу поле, значить можу ним керувати». Але @Version — це не ваш лічильник, а частина контракту між ORM і базою. Ручна зміна версії перетворює поведінку на непередбачуваний коктейль із dirty checking, flush-логіки та ваших «покращень». Найкращі ліки — взагалі не робити сеттер для версії та вважати це поле read-only для бізнес-коду.

Помилка № 2: додати поле в entity, але забути міграцію Flyway.
Тоді ви отримаєте або помилку запуску застосунку, якщо Hibernate намагається читати чи писати колонку, якої немає, або дивну поведінку залежно від налаштувань DDL. У нашому курсі схема — джерело правди, отже порядок простий: спочатку міграція, потім мапінг, потім запуск.

Помилка № 3: зробити колонку version nullable або без backfill для наявних рядків.
Оптимістичне блокування припускає, що версія є завжди. Якщо в таблиці вже лежать дані, а ви додали version як NULL, то частина рядків буде «без версії», і ви отримаєте або винятки, або нелогічні конфлікти. Навчально-практичний мінімум — NOT NULL DEFAULT 0, щоб наявні рядки отримали початкову версію.

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

Помилка № 5: очікувати, що @Version розвʼяже бізнес-конфлікти на кшталт «не вистачає товару».
Optimistic locking відповідає на запитання «чи не змінив хтось рядок з моменту читання». Він не відповідає на запитання «чи можна так змінювати залишок». Тому в голові треба тримати дві різні причини відмови: бізнес-помилку («недостатньо залишку») і технічний конфлікт узгодженості («версія застаріла»). Якщо змішати їх в одну купу, сервіс почне пояснювати користувачеві «внутрішню помилку ORM» замість нормального змісту.

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