1. Конфлікт і setter: dirty checking і відкладений SQL
Коли початківець-розробник уперше стикається з optimistic locking, він часто очікує приблизно такого: «Я викликав item.setAvailableQuantity(...) — і одразу ж, у цьому ж рядку коду, має статися конфлікт». Але Hibernate влаштований не як «SQL на кожен чих». Він працює в моделі unit of work: ви змінюєте об’єкт у пам’яті, а SQL надсилається до бази пізніше — зазвичай ближче до завершення транзакції. І це, як не дивно, не вада, а базовий принцип ORM.
Згадаємо, що таке керована сутність. Коли ви виконуєте repository.findById(...) всередині @Transactional, ви отримуєте об’єкт, «прикріплений» до persistence context. Ви змінюєте його поля звичайними сеттерами, а Hibernate згодом вирішує: «Ага, поле змінилося — отже, треба зробити UPDATE». Цей механізм називається dirty checking. Він не зобов’язаний надсилати SQL просто в момент виклику сеттера. Ба більше, якби він робив це щоразу, будь-яка бізнес-операція перетворилася б на кулемет по базі даних.
Подивімося на найзвичніший код із нашого проєкту shop-data-jpa. У ньому немає ні save(), ні явного SQL — і це нормально:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InventoryService {
@Transactional // Межа транзакції: commit/rollback відбудуться після виходу з методу
public void reserve(Long stockItemId, int qty) {
// Тут ми отримуємо керовану сутність (прикріплену до persistence context)
StockItem item = repository.findById(stockItemId).orElseThrow();
// Змінюємо стан Java-об’єкта в пам’яті (dirty checking запам’ятає зміни)
item.setAvailableQuantity(item.getAvailableQuantity() - qty);
// ВАЖЛИВО: SQL ще може не відправитися до БД.
// Конфлікт optimistic locking може проявитися пізніше — на flush/commit.
}
}
Ключова думка: setter змінює лише Java-об’єкт. А конфлікт optimistic locking можна виявити тільки тоді, коли Hibernate справді спробує виконати UPDATE у БД і побачить, що оновити «стару версію» вже не можна.
Для механіки flush тут достатньо побачити зміну одного поля availableQuantity. У повному StockItem.reserve(qty) із mini-shop змінюються і availableQuantity, і reservedQuantity, але version check на flush працює за тими самими правилами: Hibernate все одно перевіряє, чи оновлює ту саму версію рядка.
2. @Version у persistence context: знімок
Важливо відчути, що optimistic locking — це не «перевірка в Java-коді» і не якась магічна синхронізація потоків. Основа механіки лежить у дуже приземленій речі: persistence context зберігає знімок стану сутності, який був прочитаний із бази, і серед полів цього знімка є version. Далі все відбувається майже як у бухгалтерії: «Ми оновлюємо рівно те, що було записано в нас на момент читання».
Коли StockItem завантажується, Hibernate читає колонку version (і взагалі всі потрібні колонки) та запам’ятовує: «У сутності з id = 10 версія = 5». Якщо всередині транзакції ви змінили availableQuantity, Hibernate все одно пам’ятає, з якою версією ви стартували. І саме ця стара версія стане частиною WHERE у UPDATE.
Можна провести маленький «психологічний тест» для мозку: вивести версію до зміни і після неї. У нормальному випадку ви побачите, що Hibernate сам збільшує версію — ви руками її не торкаєтеся.
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void changeAvailableQuantity(Long id, int newQty) {
// Завантажуємо керовану сутність разом із поточною версією
StockItem item = repository.findById(id).orElseThrow();
// Версія до змін (значення з БД на момент читання)
System.out.println("version(before) = " + item.getVersion()); // наприклад: 5
// Для механіки version check тут достатньо одного поля:
// у повному reserve-flow змінювалися б і availableQuantity, і reservedQuantity
item.setAvailableQuantity(newQty);
// Примусово відправляємо SQL зараз, щоб побачити момент version check
entityManager.flush();
// Після успішного flush Hibernate вже збільшив версію в об’єкті
System.out.println("version(after) = " + item.getVersion()); // наприклад: 6
}
Так, flush тут спеціально — щоб побачити момент синхронізації (про нього докладно поговоримо в наступному розділі). Але вже зараз корисно зафіксувати: версія — частина persistence-механіки, яку ORM тримає в голові так само, як id.
3. SQL для version check: WHERE version = ...
Слова «перевірка версії» звучать так, ніби Hibernate робить якийсь if у Java і сам порівнює версії. Насправді найважливіша частина перевірки відбувається в SQL. Hibernate формує UPDATE так, щоб база оновила рядок лише якщо версія збіглася. І саме база повертає інформацію про те, скільки рядків було оновлено.
Спрощено, але дуже близько до реальності, це виглядає так:
-- UPDATE пройде лише якщо в БД і досі лежить та версія, яку ми читали раніше
update stock_item
set available_quantity = ?,
reserved_quantity = ?,
version = ? -- нове значення версії (зазвичай old + 1)
where id = ?
and version = ?; -- стара версія (version check)
Зверніть увагу на два місця, де бере участь версія. У SET version = ? Hibernate кладе нове значення версії (зазвичай old + 1). А в WHERE ... and version = ? він кладе стару версію, яку сутність мала в момент читання. Це і є version check.
Тепер найважливіше: якщо хтось інший устиг оновити цей рядок і збільшив version, то умова WHERE id = ? and version = oldVersion не спрацює. База даних чесно скаже: «я оновила 0 рядків». Hibernate дивиться на це і робить висновок: «Отже, об’єкт застарів. Це конфлікт optimistic locking».
Трохи більш «людська» мініілюстрація, щоб мозок не з’їжджав в абстракції:
long oldVersion = 5;
long newVersion = oldVersion + 1;
// Просто ілюстрація: Hibernate робить приблизно таку арифметику з версією
System.out.println("old = " + oldVersion); // old = 5
System.out.println("new = " + newVersion); // new = 6
Якщо транзакція A оновила рядок із version = 5 і записала version = 6, то транзакція B, яка все ще намагається оновити «версію 5», раптово виявить, що версії 5 більше немає. І це якраз те, чого ми хочемо: не дати їй тихо перезаписати чужі зміни.
Точно така сама перевірка робиться і для DELETE сутності з @Version: Hibernate додасть версію в WHERE, і видалення також буде умовним. Якщо рядок уже змінився, видалення не пройде мовчки.
4. flush: відмінність від commit і save
Слово flush звучить так, ніби зараз «усе змиє в унітаз», і подекуди це справді схоже на правду, якщо ви ввімкнули SQL-логи та побачили пачку UPDATE. Але технічно flush — це лише примусова синхронізація змін із persistence context до бази. Тобто Hibernate бере накопичені зміни керованих сутностей і надсилає відповідні SQL-команди.
Найкритичніше: flush не завершує транзакцію. Після flush() ви все ще можете виконати rollback, і база скасує зміни. Це дуже важливо для розуміння: flush — не «зберегти назавжди», flush — «надіслати SQL зараз, а остаточне рішення буде під час commit/rollback».
Зафіксуємо це в короткій таблиці, бо тут новачки плутаються найчастіше:
| Подія в коді | Що реально відбувається | Чи може з’явитися конфлікт optimistic lock |
|---|---|---|
| item.setAvailableQuantity(...) | Змінюємо Java-об’єкт, Hibernate просто позначає зміну | Ні, бо SQL ще не відправлено |
| entityManager.flush() | Hibernate надсилає UPDATE ... WHERE version = ? | Так, конфлікт стає видимим тут |
| commit (у кінці @Transactional) | Перед commit зазвичай відбувається flush + фіксація транзакції | Так, конфлікт може проявитися тут |
У Spring Boot ви найчастіше отримуєте EntityManager як залежність. У нашому проєкті це виглядає звично і не лякає:
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
@Service
public class InventoryService {
@PersistenceContext
private EntityManager entityManager; // Впроваджуємо EntityManager, щоб мати змогу робити flush вручну
// ...
}
А викликається flush так:
// Примусово синхронізуємо persistence context із БД (але транзакцію не закриваємо)
entityManager.flush();
І ще один важливий нюанс для життя: flush може відбуватися і автоматично, не лише коли ви викликали його вручну. Наприклад, під час завершення транзакції (commit) Spring/Hibernate в будь-якому разі мають синхронізувати зміни. Тому, якщо ви flush руками не робите, це не означає, що його не буде — найімовірніше, він просто станеться в кінці, коли ви вже подумки пішли пити чай.
5. Місце конфлікту: flush і commit у Spring
У звичайному коді ви викликаєте сервісний метод, і він повертається — усе, операція завершена. Але в Spring є важлива закулісна сцена: @Transactional працює через проксі, і commit транзакції відбувається після виходу з методу. Тобто технічно в момент, коли ви пишете return; або метод просто завершується, Spring лише починає завершувати транзакцію.
Звідси ефект, який новачки часто описують так: «Я поставив try/catch навколо коду, але виняток усе одно вилітає десь потім, і незрозуміло де». Це не змова, а логіка життєвого циклу транзакції: конфлікт optimistic locking часто проявляється саме під час flush, який стався на commit, а commit відбувся вже після виконання тіла методу.
Щоб побачити конфлікт у передбачуваній точці (і взагалі розуміти, де він виникає), іноді корисно викликати flush() вручну. Тоді момент відправлення SQL стає видимим просто всередині методу, у конкретному рядку.
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void updateQuantity(Long id, int newQty) {
// 1) Читаємо сутність (разом із version) у persistence context
StockItem item = repository.findById(id).orElseThrow();
// 2) Для механіки flush тут достатньо одного поля:
// у повному reserve-flow змінювалися б і availableQuantity, і reservedQuantity
item.setAvailableQuantity(newQty);
// 3) Робимо flush, щоб конфлікт проявився саме тут (а не «після виходу з методу»)
entityManager.flush(); // якщо конфлікт, він проявиться прямо тут
// 4) Цей рядок не виконається, якщо flush викинув optimistic lock exception
System.out.println("Flush пройшов");
}
А тепер — схема, яка з’єднує все в один таймлайн. Її зручно тримати в голові, коли ви дебажите «чому setter пройшов, а потім усе впало».
flowchart LR
%% Таймлайн однієї транзакції: де реально виникає SQL і де можливий конфлікт
A["findById(): завантажили StockItem (version = 5)"] --> B["setAvailableQuantity(): змінили об’єкт у пам'яті"]
B --> C["flush(): відправили UPDATE ... WHERE version = 5"]
C --> D["commit(): фіксуємо транзакцію"]
Якщо між A і C інша транзакція встигла встановити version = 6, то на кроці C база оновить 0 рядків, і Hibernate викине optimistic lock exception. Не на кроці B. Не в якомусь setter. А там, де був SQL.
6. Мінісценарій: SQL-логи і flush()
Зараз ми зберемо все в один мінісценарій саме в контексті нашого проєкту. Ми не будемо заглиблюватися в багатопотоковість Java і не будемо будувати складний стенд — це буде в практичній лекції дня. Але ми зробимо найголовніше: увімкнемо спостережуваність і навчимося розуміти за логами, коли саме відбувається version check.
Для початку важливо, щоб SQL було видно. У курсі ми вже вмикали SQL-логи в dev-профілі; нагадаю характерний мінімальний фрагмент (у вас він може бути трохи іншим, але зміст той самий):
logging:
level:
org.hibernate.SQL: DEBUG # Показує сам SQL (SELECT/UPDATE/INSERT/DELETE)
org.hibernate.orm.jdbc.bind: TRACE # Показує прив’язування параметрів (зокрема id і version)
Тепер зробімо в InventoryService метод, який буквально прибиває цвяхом момент SQL, щоб не шукати його очима по всьому застосунку. Він неідеальний як production-код (flush вручну потрібен не завжди), але як навчальний мікроскоп — ідеальний.
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void setAvailableQuantityWithFlush(Long id, int newQty) {
// Завантажуємо сутність: тут буде SELECT (і читання version)
StockItem item = repository.findById(id).orElseThrow();
// Змінюємо об’єкт у пам’яті: поки що SQL немає
item.setAvailableQuantity(newQty);
// Примусово відправляємо UPDATE з version check саме тут
entityManager.flush(); // UPDATE з version check піде сюди
}
Що ви побачите в логах за успішного оновлення? Приблизно таку картину: спочатку select (коли завантажували StockItem), потім update stock_item ... where id=? and version=?. Якщо конфлікту немає, оновиться 1 рядок, версія збільшиться, транзакція успішно закомітиться.
А що ви побачите в разі конфлікту (коли паралельно інший запит устиг змінити той самий StockItem)? У логах ви все одно побачите UPDATE ... WHERE version = old, але результат оновлення буде 0 рядків. І в цей момент Hibernate зрозуміє: «Я намагався оновити “стару реальність”, але вона вже не існує». Після цього буде викинуто виняток optimistic locking.
Щоб це стало зовсім «відчутним», корисно тримати в голові таку послідовність із двох конкурентних транзакцій:
sequenceDiagram
%% Дві конкурентні транзакції читають одну й ту саму версію, але оновити зможе лише перша
participant TxA as "Tx A (операція A)"
participant TxB as "Tx B (операція B)"
participant DB as PostgreSQL
TxA->>DB: SELECT stock_item (version=5)
TxB->>DB: SELECT stock_item (version=5)
TxA->>DB: UPDATE ... WHERE id=10 AND version=5 (успіх, version=6)
TxB->>DB: UPDATE ... WHERE id=10 AND version=5 (0 rows)
TxB-->>TxB: Виняток optimistic lock
Зверніть увагу, що тут немає жодної магії синхронізації. Базі даних не потрібно знати, що ви використовуєте Hibernate. Вона просто виконує чесний SQL із чесним WHERE. А Hibernate просто інтерпретує чесний результат «0 рядків оновлено» як конфлікт.
І ще одна практична деталь, яка часто рятує години часу. Якщо ви намагаєтеся «зловити» optimistic lock конфлікт усередині методу й написати try/catch, то без flush ви можете взагалі не побачити його там, де очікуєте, тому що виняток вилетить під час commit після виходу з методу. Тому в навчальних сценаріях ми й викликаємо flush явно: він робить момент перевірки версії передбачуваним і видимим. Цього вже достатньо, щоб перейти до наступного інженерного питання: як перетворити такий конфлікт на зрозумілий результат сервісної операції.
7. Типові помилки під час роботи з flush і optimistic locking
Тема flush і optimistic locking здається складною не тому, що там «багато коду», а тому, що там багато прихованих меж: межа транзакції, межа persistence context, межа «в пам’яті» vs «у базі». Тому помилки тут часто не компіляційні, а смислові — застосунок запускається, але поводиться дивно.
Помилка № 1: чекати на виняток на рядку setAvailableQuantity(...).
Це типовий ефект «мислення JDBC»: здається, що зміна поля одразу дорівнює SQL. У JPA це не так. Setter змінює лише об’єкт. Конфлікт виявляється під час відправлення SQL, тобто зазвичай на flush() або на commit. Якщо пам’ятати про dirty checking і відкладений SQL, очікування стають реалістичнішими.
Помилка № 2: сприймати flush() як «commit» або як «зберегти назавжди».
Flush справді надсилає SQL до бази, тому виглядає страшно. Але транзакція все ще може відкотитися. Якщо після flush станеться помилка і буде rollback, зміни зникнуть. Flush — це синхронізація, а не остаточне рішення щодо долі даних.
Помилка № 3: намагатися «лікувати» optimistic locking додаванням зайвих save().
Коли ви змінюєте керовану сутність, save часто не потрібен: dirty checking усе зробить сам. А конфлікт optimistic locking — це не «забули зберегти». Це виявлений факт, що дані вже змінилися в іншій транзакції. Додатковий save не зробить конфлікт менш конфліктним, він лише додасть вам плутанини.
Помилка № 4: загортати код у try/catch, але не розуміти, що виняток може вилетіти на commit після виходу з методу.
У Spring транзакція комітиться після завершення @Transactional методу. Тому optimistic lock exception може виникнути «після останнього рядка методу». Якщо ви хочете в навчальних цілях побачити конфлікт усередині методу, використовуйте flush(). Якщо хочете обробляти конфлікт архітектурно — це вже тема сервісної реакції (ми до неї йдемо в наступній лекції).
Помилка № 5: думати, що optimistic locking — це «про потоки Java», і намагатися лікувати його synchronized.
Optimistic locking — це про конкуренцію транзакцій у базі даних. Навіть якщо у вас один потік в одному JVM-процесі, поруч може бути другий інстанс застосунку, другий сервіс або просто другий запит до того самого сервісу. synchronized захищає лише ділянку пам’яті всередині одного процесу і не вирішує проблему на рівні БД.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ