JavaRush /Курси /Hibernate deep-dive /InventoryItem: конфлікт і SQL-журнал

InventoryItem: конфлікт і SQL-журнал

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

1. Вступ

Коли ви обираєте сутність для демонстрації optimistic locking, хочеться таку, де помилку видно не лише в теорії, а й «на пальцях». InventoryItem у нашому Commerce Persistence Lab ідеально підходить: у неї є availableQty і reservedQty. Тому будь-який конкурентний перезапис швидко перетворюється на проблему, яку навіть бізнес зрозуміє без презентації на 40 слайдів.

Уявіть два паралельні запити або два потоки, які резервують залишок. Обидва читають availableQty = 10. Перший хоче зарезервувати 2, другий — 3. У голові кожного все логічно: «102 = 8» і «103 = 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 виняток усередині транзакції і продовжити, ніби нічого не сталося.
Конфлікт версії означає, що ваша спроба оновлення не застосувалася. Ба більше, транзакцію зазвичай треба відкотити. Тому коректний стиль — вважати це окремим результатом операції: або повертаємо конфлікт назовні, або запускаємо нову спробу в новій транзакції (і лише якщо це виправдано бізнесом). Усередині тієї самої спроби «дотискати» ситуацію зазвичай не можна і не потрібно.

1
Опитування
Оптимістичне блокування, рівень 18, лекція 4
Недоступний
Оптимістичне блокування
Версії та конфлікти оновлень
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ