1. Вступ
Коли ви обираєте сутність для демонстрації optimistic locking, хочеться таку, де помилку видно не лише в теорії, а й «на пальцях». InventoryItem у нашому Commerce Persistence Lab ідеально підходить: у неї є availableQty і reservedQty. Тому будь-який конкурентний перезапис швидко перетворюється на проблему, яку навіть бізнес зрозуміє без презентації на 40 слайдів.
Уявіть два паралельні запити або два потоки, які резервують залишок. Обидва читають availableQty = 10. Перший хоче зарезервувати 2, другий — 3. У голові кожного все логічно: «10 − 2 = 8» і «10 − 3 = 7». Але якщо обидва оновлення застосуються незалежно, фінальний запис буде або 7, або 8. А правильний результат — 5, тому що резервування мають сумуватися. Ось чому «останній переміг» — не стратегія, а вада.
Щоб далі не плутатися, зафіксуємо просту думку: optimistic locking не робить систему «завжди успішною». Він робить систему чесною. Замість тихого перезапису даних ми отримуємо явний конфлікт і можливість ухвалити рішення: перечитати дані, повторити спробу або повернути зрозумілу помилку коду, який викликає цей запис.
Тепер корисно зібрати все це в один лабораторний сценарій: взяти один InventoryItem, зіткнути дві незалежні транзакції та прочитати конфлікт у SQL-журналі.
2. Модель InventoryItem і @Version
Саму роль @Version тут зручно пояснити одним реченням: Hibernate додасть версію до WHERE і підвищить її після успішного UPDATE. Для експерименту нам потрібен робочий знімок InventoryItem, на якому конфлікт легко відтворити, а потім побачити в SQL-журналі.
Нижче — мінімальна форма сутності. Важливі три речі: у рядка є id, у нього є технічна version, і поруч є поля, які справді шкода втратити під час конфлікту.
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
@Entity
public class InventoryItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // Технічний PK, ідентифікатор рядка
@Version
private Long version; // Версія для optimistic locking (її веде Hibernate, не домен)
}
Long тут зручний тим, що початкове значення версії Hibernate проставить сам, коли сутність уперше синхронізується з БД.
Тепер додамо поля залишків і маленьку доменну операцію reserve(int qty). Цього достатньо, щоб побачити застарілу версію на реальній арифметиці, а не на абстрактному лічильнику.
public class InventoryItem {
private int availableQty; // Скільки ще можна зарезервувати
private int reservedQty; // Скільки вже зарезервовано
public void reserve(int qty) {
// Захист від «резервування -5 штук» (на практиці це часто результат помилки вище по стеку)
if (qty <= 0) {
throw new IllegalArgumentException("qty має бути додатним");
}
// Доменна перевірка: не можна зарезервувати більше, ніж доступно
if (availableQty < qty) {
throw new IllegalStateException("Недостатньо товару");
}
// Важливо: змінюємо обидва поля узгоджено, щоб зберігалася «математика залишків»
availableQty -= qty;
reservedQty += qty;
}
}
З боку БД для цього сценарію важливо лише одне: у таблиці вже є стовпець version поруч із залишками. Якщо дивитися на таблицю вже після додавання версії, нас цікавлять щонайменше такі поля:
create table inventory_item (
id bigserial primary key,
version bigint not null, -- Версія для optimistic locking
available_qty int not null, -- Доступно для резервування
reserved_qty int not null -- Уже зарезервовано
);
Саме цей стовпець не дасть другому UPDATE знайти рядок за умовою version = ?.
3. Відтворюваний конфлікт: дві транзакції
Найчастіша причина, чому новачкові «не вдається відтворити optimistic locking», — спроба зробити це в межах одного EntityManager. А один EntityManager — це один persistence context, тобто один «знімок реальності» всередині транзакції. Усередині одного persistence context Hibernate має забезпечити identity map: одному рядку БД відповідає один керований об’єкт. Тому ви не зможете отримати дві різні «версії світу» в одному контексті.
Щоб відтворити конфлікт чесно, нам потрібні дві незалежні транзакції. У живому застосунку це зазвичай дві паралельні HTTP-операції (навіть якщо ви не хочете думати про HTTP, світ усе одно так працює). У лабораторії або тесті це найзручніше змоделювати через два EntityManager, створені одним EntityManagerFactory.
Схема конфлікту дуже наочно виглядає як послідовність подій:
sequenceDiagram
participant T1 as "Транзакція 1 (EM1)"
participant DB as PostgreSQL
participant T2 as "Транзакція 2 (EM2)"
T1->>DB: "SELECT inventory_item (version=0)"
T2->>DB: "SELECT inventory_item (version=0)"
T2->>DB: "UPDATE ... SET ..., version=1 WHERE id=? AND version=0"
T2-->>DB: commit OK
T1->>DB: "UPDATE ... SET ..., version=1 WHERE id=? AND version=0"
DB-->>T1: "0 рядків оновлено"
T1-->>T1: OptimisticLockException
Ключовий момент: обидві транзакції стартують з однаковою версією (version = 0). Потім одна встигає оновити рядок і збільшує версію. Друга приходить зі старою версією, і її UPDATE більше не потрапляє в рядок.
Мінімальний код (у стилі «лабораторний скальпель», без Spring-магії) може виглядати так:
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
// Два незалежні EntityManager => два незалежні контексти збереження
EntityManager em1 = emf.createEntityManager();
EntityManager em2 = emf.createEntityManager();
// Дві незалежні транзакції (саме це й потрібно для конфлікту версій)
em1.getTransaction().begin();
em2.getTransaction().begin();
Тепер ми читаємо один і той самий рядок у двох контекстах:
// Обидва EM читають один і той самий рядок, але «тримають» його в різних контекстах збереження
InventoryItem first = em1.find(InventoryItem.class, 1L);
InventoryItem second = em2.find(InventoryItem.class, 1L);
// На старті версії збігаються, бо обидві транзакції бачать один і той самий стан БД
System.out.println("Версія Tx1 = " + first.getVersion()); // Версія Tx1 = 0
System.out.println("Версія Tx2 = " + second.getVersion()); // Версія Tx2 = 0
Далі робимо так, щоб одна транзакція обігнала іншу:
second.reserve(3); // Змінюємо керовану сутність у межах Tx2
em2.getTransaction().commit(); // На commit Hibernate надішле UPDATE і підвищить версію (стане 1)
І лише після цього намагаємося комітити першу транзакцію:
first.reserve(2); // Tx1 усе ще думає, що стара версія актуальна
em1.getTransaction().commit(); // Очікуємо конфлікт версії на UPDATE (застаріла версія)
У реальному тесті ви обгорнете останній commit() в assertThrows, а в лабораторному запуску побачите виняток. Але вже зараз важлива думка: конфлікт виникає не тому, що «два потоки одночасно щось чіпають», а тому, що друга спроба оновлює рядок із застарілою версією.
4. Конфлікт на flush і commit
У цьому сценарії важливий один практичний факт: конфлікт з’явиться там, де Hibernate реально надішле версійний UPDATE. Тому його можна побачити або на commit(), або раніше — на явному flush().
Для лабораторії flush() зручніший: він не завершує транзакцію, а просто змушує Hibernate синхронізувати persistence context із БД просто зараз. Так простіше пов’язати виняток із конкретним UPDATE у логах і не гадати, що сталося вже на межі завершення транзакції.
У сервісному коді це часто виглядає так: після критичної зміни залишків я хочу одразу зрозуміти, успіх це чи конфлікт. Тоді ми робимо flush() посеред методу. Наприклад:
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InventoryReservationTxService {
private final EntityManager entityManager;
public InventoryReservationTxService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional
public void reserve(Long itemId, int qty) {
// 1) Завантажуємо керовану сутність у поточний persistence context
InventoryItem item = entityManager.find(InventoryItem.class, itemId);
// 2) Змінюємо стан у пам’яті (поки без SQL)
item.reserve(qty);
// 3) Примусово надсилаємо SQL зараз, щоб конфлікт проявився в цьому рядку
entityManager.flush(); // конфлікт версії проявиться тут
}
}
Тут flush() — діагностична кнопка, а не обов’язковий ритуал у кожному сервісі. Після конфлікту оптимістичного блокування поточна транзакція все одно вважається невдалою; сенс у тому, щоб побачити проблему в зрозумілій точці, а не ловити її лише наприкінці.
5. SQL-журнал: UPDATE ... WHERE version = ?
Цінність SQL-журналу тут не в самому факті винятку. По ньому видно, яка транзакція йшла зі старою версією і чому другий UPDATE не знайшов рядок.
Для комфортної діагностики зазвичай вмикають SQL trace і bind-параметри (у проєкті це робиться профілем sql-trace). Мінімальна ідея конфігурації виглядає приблизно так:
logging:
level:
org.hibernate.SQL: debug # Друкуємо SQL, який надсилає Hibernate
org.hibernate.orm.jdbc.bind: trace # Друкуємо значення bind-параметрів (включно з version)
Тепер у логах ви побачите не лише сам SQL, а й значення параметрів. І саме тут починається найкорисніше.
Без @Version Hibernate зазвичай робить оновлення приблизно так (спрощено):
| Варіант | SQL-форма UPDATE | Що це означає |
|---|---|---|
| Без версії | ... WHERE id = ? | «онови рядок за id: хто встиг останнім, той і переміг» |
| З @Version | ... WHERE id = ? AND version = ? | «онови лише якщо рядок і далі має ту версію, яку я читав» |
З @Version ви побачите SQL такого виду (приблизно; форматування залежить від налаштувань):
update inventory_item
set available_qty = ?, reserved_qty = ?, version = ?
where id = ? and version = ?
Зверніть увагу на подвійну роль версії: вона одночасно присутня в SET і в WHERE.
У WHERE Hibernate кладе «стару версію» — ту, яка була в об’єкта на момент завантаження в persistence context. У SET Hibernate кладе «нову версію» — зазвичай oldVersion + 1. Це важливий психологічний момент: версія збільшується не тому, що ви її збільшили. Вона збільшується тому, що Hibernate вважає оновлення успішним і переводить рядок на наступну ревізію.
Тепер уявімо наш конфлікт. Друга транзакція (Tx2) комітиться першою. У логах ви побачите приблизно такий набір подій: спочатку SELECT, потім UPDATE із версією 0 у WHERE. І якщо bind-параметри ввімкнені, ви буквально побачите значення версії:
Hibernate: update inventory_item set available_qty=?, reserved_qty=?, version=? where id=? and version=?
прив’язаний параметр (3:BIGINT) <- [1] -- нова версія
прив’язаний параметр (5:BIGINT) <- [0] -- стара версія в WHERE
Це ідеальний випадок: ви бачите, що Hibernate намагається «підняти» версію з 0 до 1.
Потім настає черга першої транзакції (Tx1). Вона теж вважає, що версія 0 актуальна (вона ж читала рядок раніше!). Тому вона сформує такий самий UPDATE — із version = 0 у WHERE. Але в базі вже лежить версія 1, тому що Tx2 встигла оновити рядок.
Що побачите в логах? Ви знову побачите UPDATE ... WHERE version = 0, а потім отримаєте виняток. Найважливіша частина пояснення звучить так: «SQL синтаксично коректний, але він не знайшов рядок, який відповідає умовам id = ? AND version = 0, бо поточна версія в базі вже інша».
У цей момент Hibernate кидає OptimisticLockException (або більш “внутрішній” виняток Hibernate, який піднімається нагору й обгортається). На рівні Spring ви часто побачите щось із сімейства ObjectOptimisticLockingFailureException. Не треба лякатися довгих назв: сенс один — оновлення не застосувалося, бо версія застаріла.
Найцінніша вправа для мозку (і для майбутнього production-аналізу) — навчитися ставити собі два запитання під час читання SQL-журналу:
Перше запитання звучить так: «Яку версію завантажили в транзакцію?». Це видно або по SELECT (якщо ви виводите стан об’єкта), або по WHERE version = ? у UPDATE.
Друге запитання звучить так: «Хто встиг підняти версію раніше?». Це видно по тому, який UPDATE пройшов першим і яку нову версію він записав у SET.
Щойно ви відповіли на ці два запитання, конфлікт перестає бути «незрозумілою помилкою ORM» і стає просто математикою: два процеси спробували оновити один рядок, один встиг раніше, другий прийшов зі старим номером ревізії.
6. Сценарій у проєкті: незалежні транзакції
Після лабораторії з двома EntityManager важливо побачити, що в застосунку це не штучний трюк. Кожен окремий запит або фонова операція приходить зі своєю транзакцією і своїм persistence context, тому застаріла версія виникає тим самим способом, лише вже під реальним навантаженням.
У Commerce Persistence Lab логіка резервування зазвичай живе в пакеті service фічі inventory. Нам досить однієї короткої ідеї: сервіс читає InventoryItem, викликає reserve(qty) і фіксує зміни. За optimistic locking @Version гарантує, що якщо паралельно хтось уже оновив цей самий InventoryItem, ви отримаєте явний конфлікт замість тихого перезапису.
Сам optimistic locking при цьому залишається обов’язком Hibernate; репозиторій тут просто звичайна точка входу до сутності:
import org.springframework.data.jpa.repository.JpaRepository;
// Репозиторій як «точка входу» до сутності; сам optimistic locking робить Hibernate через @Version
public interface InventoryItemRepository extends JpaRepository<InventoryItem, Long> {
}
А сервіс (із явним flush() для діагностики) — так:
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InventoryService {
private final EntityManager entityManager;
public InventoryService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional
public void reserve(Long itemId, int qty) {
// Знаходимо сутність і працюємо з нею як із керованим об’єктом
InventoryItem item = entityManager.find(InventoryItem.class, itemId);
// Уся бізнес-математика залишається в домені
item.reserve(qty);
// Робить конфлікт «видимим» у цьому місці, а не десь на межі завершення транзакції
entityManager.flush();
}
}
Тут flush() в кінці — не обов’язок «завжди так робити». Це спосіб зробити конфлікт видимим там, де вам простіше керувати результатом операції. У деяких сценаріях ви залишите все на commit. Але для навчання і діагностики це місце дуже зручне: ви чітко бачите, що конфлікт стосується запису в БД, а не вашої арифметики в Java.
7. Мінітест: конфлікт в одному запуску
Тут потрібен найкоротший мікроскоп: один запуск, одна застаріла версія. Для цього навіть не потрібна багатопоточність — достатньо вручну створити два EntityManager і керувати транзакціями.
Мінімальна заготовка тесту (у стилі «щоб було зрозуміло, де ми взагалі перебуваємо») може виглядати так:
import jakarta.persistence.EntityManagerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
// Підіймаємо Spring-контекст, щоб отримати налаштований EntityManagerFactory
@SpringBootTest
class InventoryOptimisticLockTest {
@Autowired
EntityManagerFactory emf;
}
Далі — сам сценарій. Ми навмисно створюємо дві транзакції, читаємо один рядок і комітимо в «неправильному» порядку:
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertThrows;
class InventoryOptimisticLockTest {
@Test
void shouldFailOnStaleVersion() {
// Два незалежні контексти збереження, щоб кожен «жив у своєму світі»
EntityManager em1 = emf.createEntityManager();
EntityManager em2 = emf.createEntityManager();
em1.getTransaction().begin();
em2.getTransaction().begin();
// Обидва читають один і той самий рядок з однією і тією самою версією
InventoryItem a = em1.find(InventoryItem.class, 1L);
InventoryItem b = em2.find(InventoryItem.class, 1L);
// Tx2 обганяє: комітить першим і піднімає версію в БД
b.reserve(3);
em2.getTransaction().commit(); // успішний commit, версію збільшено
// Tx1 усе ще думає, що версія 0 актуальна
a.reserve(2);
// Явний flush робить застарілу версію видимою в найпередбачуванішій точці
assertThrows(jakarta.persistence.OptimisticLockException.class, em1::flush);
}
}
На чистому JPA це найчастіше буде OptimisticLockException. На шарах поверх JPA можна зустріти і Hibernate-, і Spring-обгортки, тому корисно прив’язуватися саме до місця виникнення конфлікту — flush із застарілою версією — а не до думки, що він обов’язково має спливти лише на commit().
І заодно тут добре видно межу застосовності optimistic locking. Якщо такі конфлікти рідкісні, @Version — чудовий варіант за замовчуванням: ми чесно виявили гонку і вирішили, що робити далі. Але якщо один InventoryItem перетворюється на гарячий рядок, а кожна невдала спроба тягне за собою новий дорогий цикл читання і запису, ціна повторних спроб починає швидко зростати.
8. Типові помилки під час optimistic locking
Помилка №1: спроба відтворити конфлікт усередині одного EntityManager.
Коли ви робите find() двічі в одній транзакції, Hibernate віддає вам той самий керований об’єкт із кешу першого рівня. Це один знімок реальності, і він не може конфліктувати сам із собою. Для конфлікту потрібні два незалежні persistence context: або два EntityManager, або два паралельні запити в реальному застосунку.
Помилка №2: очікування, що конфлікт з’явиться в момент виклику reserve() або сеттера.
Optimistic locking — це перевірка на рівні запису рядка. Поки SQL не надіслано, конфлікту ні з чим порівнювати. Тому виняток ви побачите на flush() або на commit(). Якщо ви хочете зловити конфлікт раніше і ближче до бізнес-логіки, використовуйте явний flush() як діагностичний інструмент.
Помилка №3: фокус лише на винятку та ігнорування SQL.
Текст винятку корисний, але його часто загортають, і він може виглядати лячно. SQL-журнал показує суть: UPDATE ... WHERE version = ?. Якщо ви навчитеся очима знаходити version у WHERE, ви почнете розуміти optimistic locking швидше, ніж ваш мозок встигне відкрити StackOverflow.
Помилка №4: вимкнені bind-параметри і спроба вгадувати, яка версія брала участь у конфлікті.
Без параметрів ви побачите where id=? and version=?, але не побачите, що саме було в version. Для навчальної лабораторії майже завжди варто вмикати org.hibernate.orm.jdbc.bind: trace, щоб бачити значення і точно розуміти, яка транзакція йшла зі старою версією.
Помилка №5: спроба «проковтнути» optimistic lock виняток усередині транзакції і продовжити, ніби нічого не сталося.
Конфлікт версії означає, що ваша спроба оновлення не застосувалася. Ба більше, транзакцію зазвичай треба відкотити. Тому коректний стиль — вважати це окремим результатом операції: або повертаємо конфлікт назовні, або запускаємо нову спробу в новій транзакції (і лише якщо це виправдано бізнесом). Усередині тієї самої спроби «дотискати» ситуацію зазвичай не можна і не потрібно.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ