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» замість нормального змісту.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ