JavaRush /Курси /Spring Data JPA /flush і version check...

flush і version check: конфлікт

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

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 захищає лише ділянку пам’яті всередині одного процесу і не вирішує проблему на рівні БД.

1
Задача
Spring Data JPA, 25 рівень, 2 лекція
Недоступна
`flush()` надсилає update одразу
`flush()` надсилає update одразу
1
Задача
Spring Data JPA, 25 рівень, 2 лекція
Недоступна
Конфлікт виникає саме на `flush()`
Конфлікт виникає саме на `flush()`
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ