JavaRush /Курси /Spring Test /Optimistic locking через v...

Optimistic locking через version

Spring Test
Рівень 17 , Лекція 4
Відкрита

1. Проблема lost update і optimistic locking

Дотепер нас цікавили коректність і ціна читання Article-графа. Але в тієї самої сутності є окремий ризик на етапі запису: дві людини змінюють один запис майже одночасно, і без захисту одне оновлення тихо стирає інше. Для цього фрагмента нам потрібен найпростіший optimistic locking на одній сутності — без занурення в повний зоопарк транзакційних стратегій.

Коли чуєш слово «конкурентність», мозок одразу малює серверну кімнату, тисячу потоків і людину, яка промовляє «race condition» з повагою, ніби це імʼя дракона. Насправді все починається набагато прозаїчніше: дві людини відкрили одну й ту саму статтю в адмінці, кожна змінила своє поле, і обидві натиснули «Зберегти». Якщо система наївна, переможе той, хто зберіг останнім, а зміни першого просто зникнуть. Жодного винятку, жодного «ой» — лише тиха втрата даних.

Уявімо типовий сценарій для ContentHub, навіть без мікросервісів і без «навантаження рівня Netflix»:

  • Адмін A відкрив статтю Article #42 і змінює status на PUBLISHED.
  • Адмін B відкрив ту саму статтю Article #42 і змінює title, бо помітив друкарську помилку.
  • Обидва зберігають, але порядок може бути будь-яким.

Якщо немає механізму виявлення конфлікту, то одне оновлення може перетерти інше. Найпідступніше тут те, що зовні все виглядає коректно: запити успішно пройшли, транзакції закомітилися, база задоволена життям. А через день ви знаходите «чому стаття опублікована, але заголовок знову з помилкою?» — і починаються розкопки.

Щоб візуально зафіксувати проблему, зручно тримати в голові просту схему:

sequenceDiagram
    participant A as Адмін A
    participant B as Адмін B
    participant DB as БД

    A->>DB: "SELECT Article(id=42) (version=7)"
    B->>DB: "SELECT Article(id=42) (version=7)"
    A->>DB: "UPDATE Article ... (status=PUBLISHED)"
    B->>DB: "UPDATE Article ... (title=\"Виправлено\")"

Якщо база виконує обидва UPDATE без додаткових умов, друге оновлення «перемагає» просто тому, що воно останнє. Це і є класичний lost update. І ось тут зʼявляються version та optimistic locking.

2. Optimistic locking у JPA: @Version

Optimistic locking у JPA — це історія не про «ми забороняємо всім чіпати запис», а про «ми дозволяємо, але якщо ви торкаєтеся застарілої копії — ми вас зупиняємо». Тобто це не шлагбаум на вʼїзді, а охоронець на виході, який звіряє штамп: «Ви виходите з тією самою версією документа, яку отримували? Якщо ні — вибачте, оновіть і спробуйте ще раз». Для цього JPA використовує спеціальне поле з анотацією @Version.

У Article така річ зазвичай виглядає як технічне поле, яке не несе бізнес-сенсу (користувач не має думати «чому в моєї статті версія 13?»), але воно критично важливе для коректності конкурентних оновлень:

import jakarta.persistence.Version;

// Технічне поле для optimistic locking: користувач його не редагує.
@Version
private Long version; // ORM буде порівнювати це значення під час UPDATE

З погляду бази даних це перетворюється на окрему колонку, умовно version BIGINT NOT NULL. А далі магія стає дуже прозаїчною: під час UPDATE Hibernate (або інший JPA-провайдер) додає версію до WHERE і водночас збільшує її в SET.

Спрощений зміст SQL виглядає так:

update article
set title = ?, version = version + 1 -- збільшуємо версію під час успішного запису
where id = ? and version = ?;        -- захищаємося від запису за застарілою версією

Якщо хтось уже оновив запис і version у таблиці став іншим, то where id=? and version=? не знайде рядок. Оновиться 0 рядків — і це для ORM сигнал: «Спробували оновити застарілу версію, конфлікт!».

Невелика табличка, щоб не втратити сенс:

Що бачимо в коді Що зберігається в таблиці Навіщо це потрібно
@Version Long version колонка version виявляти застарілі оновлення
версія була 7 version = 7 означає, що ми читали запис у стані “7”
версія стала 8 version = 8 означає, що хтось успішно оновив запис

Тут важливо не плутати optimistic locking із pessimistic locking. Pessimistic — це «я беру запис і тримаю його за комір (SELECT ... FOR UPDATE), доки не закінчу». Optimistic — «беремо копію вільно, але на запис ставимо умову “версія має збігтися”». Для більшості звичайних CRUD-панелей optimistic — хороший варіант за замовчуванням: він не блокує користувачів, але запобігає тихим перезаписам.

3. Версія зростає під час успішного оновлення

Перш ніж показувати конфлікт, корисно побачити базовий факт: версія справді існує, це не декорація, і вона змінюється там, де ми цього очікуємо. У data-тестах це особливо зручно, бо ми можемо змусити JPA виконати SQL у конкретний момент через flush() і відразу подивитися на результат, не гадати, коли ж Hibernate вирішить надіслати запит.

Почнемо з дуже простого сценарію. Ми створюємо статтю, фіксуємо version, змінюємо поле і робимо flush(). Потім перевіряємо, що версія зросла. Зверніть увагу: ми не привʼязуємося до того, чи починається версія з 0, чи з 1 — це часто залежить від провайдера та конкретних налаштувань. Ми перевіряємо відносну зміну, і такий тест переживає більше “дрібних зсувів” середовища.

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

@Test
void version_increments_on_update() {
    // Створюємо і зберігаємо нову сутність.
    Article article = entityManager.persist(new Article("JPA basics", testCategory()));

    // Важливо: примусово відправляємо INSERT/UPDATE до БД, інакше версія може не оновитися.
    entityManager.flush();

    Long before = article.getVersion(); // фіксуємо поточну версію з керованого обʼєкта

    article.setTitle("JPA basics (fixed)"); // змінюємо поле, яке потрапить у UPDATE
    entityManager.flush(); // примусово виконуємо UPDATE, щоб версія справді зросла

    assertThat(article.getVersion()).isGreaterThan(before);
}

Тут важливо памʼятати одну давню істину: без flush() ви можете дивитися на обʼєкт у памʼяті й думати, що «все вже збережено», але насправді SQL ще не пішов. А доки SQL не пішов, версія може не змінитися, і тест перевірятиме не те, що ви хотіли.

Якщо хочеться додати чесності (і заодно дотримуватися звички «перевіряємо базу, а не persistence context»), можна перечитати сутність після clear() і перевірити версію вже в перечитаній сутності. Це трохи довше, але іноді допомагає студентам психологічно: «ага, це справді з бази».

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

@Test
void reloaded_article_has_new_version_after_update() {
    Article article = entityManager.persist(new Article("A1", testCategory()));
    entityManager.flush(); // фіксуємо INSERT, щоб з'явилися id/version

    article.setTitle("A1 updated");
    entityManager.flush(); // фіксуємо UPDATE, щоб версія зросла в БД

    // Скидаємо persistence context: далі читаємо ніби "з нуля".
    entityManager.clear();

    Article reloaded = articleRepository.findById(article.getId()).orElseThrow();
    // Перевіряємо версію в перечитаної сутності (вже з БД).
    assertThat(reloaded.getVersion()).isEqualTo(article.getVersion());
}

Сенс цього блоку — зафіксувати: @Version справді працює навіть у найпростішому “однокористувацькому” сценарії.

4. Конфлікт: дві копії Article

Тепер робимо те, заради чого все це й починалося: створюємо дві копії одного й того самого запису і змушуємо одну з них застаріти. У реальному світі це стається, коли два користувачі одночасно прочитали запис. У тесті нам потрібно імітувати саме цей стан: два обʼєкти Article з однаковим id, однаковою версією, але такі, що існують незалежно.

Є один нюанс, який часто ламає перші спроби: якщо ви просто двічі викличете findById() в одному persistence context, то JPA з великою ймовірністю поверне один і той самий managed-обʼєкт, бо в першому рівні кешу вже лежить сутність із цим id. Тому, щоб отримати дві незалежні копії, найпростіше прочитати сутність, а потім зробити її detached (відʼєднати від persistence context), і лише після цього читати ще раз.

Логіка тесту буде такою:

1) Зберегли статтю (отримали id та початкову version)
2) Прочитали firstCopy і detachʼнули
3) Прочитали secondCopy і detachʼнули
4) Зберегли firstCopy — версія в базі зросла
5) Спробували зберегти secondCopy — у неї стара версія, очікуємо конфлікт

Фрагмент «створити статтю і взяти дві detached-копії»:

// 1) Створюємо запис і переконуємося, що він справді в БД.
Article article = entityManager.persist(new Article("A1", testCategory()));
entityManager.flush();
entityManager.clear(); // важливо: далі не хочемо отримувати обʼєкт із кешу першого рівня

// 2) Читаємо першу копію і "відʼєднуємо" її від persistence context.
Article firstCopy = articleRepository.findById(article.getId()).orElseThrow();
entityManager.detach(firstCopy); // тепер зміни не будуть автоматично синхронізуватися

// 3) Читаємо другу копію (це вже окремий обʼєкт з тією самою версією).
Article secondCopy = articleRepository.findById(article.getId()).orElseThrow();
entityManager.detach(secondCopy);

Далі перша копія успішно оновлює запис. Тут зручно використати saveAndFlush(), щоб конфлікт, якщо він буде, проявився прямо в цьому рядку, а не десь наприкінці тесту:

firstCopy.setTitle("A1 - first update");

// saveAndFlush: merge/save + негайний flush, щоб версія зросла просто зараз.
articleRepository.saveAndFlush(firstCopy); // version стане більшою

entityManager.clear(); // "забуваємо" все, що могло лишитися в контексті

І нарешті — спроба оновити другою, застарілою копією. Тут і має спрацювати optimistic locking:

import static org.assertj.core.api.Assertions.assertThatThrownBy;

secondCopy.setTitle("A1 - second update (stale)");

assertThatThrownBy(() -> articleRepository.saveAndFlush(secondCopy))
    // Залежно від рівня, де перехопили конфлікт, виняток може бути "чисто JPA"
    // або обгорткою Spring.
    .isInstanceOfAny(
        jakarta.persistence.OptimisticLockException.class,
        org.springframework.orm.ObjectOptimisticLockingFailureException.class
    );

Чому одразу isInstanceOfAny()? Бо JPA-виняток часто перетворюється Spring на більш дружній для застосунку виняток із сімейства OptimisticLockingFailureException. У data-slice тестах ви частіше бачите саме Spring-обгортку, але залежно від точки виникнення та конкретної конфігурації може спливти і вихідний JPA.

Щоб не втратити нитку, ось маленька таблиця станів — це майже як розкадровка:

Крок firstCopy.version secondCopy.version DB.version Що відбувається
обидві прочитали 7 7 7 усе узгоджено
зберегли firstCopy 7 → (у базі стане 8) 7 8 перший апдейт успішний
зберегли secondCopy 7 7 8 WHERE version=7 не знайшов рядок → конфлікт

І ось це — головний доказ для junior-аудиторії: optimistic locking не «робить швидше» і не «магічно лагодить конкурентність», він перетворює мовчазну втрату оновлень на явну помилку, з якою далі можна працювати.

5. flush, saveAndFlush і місце помилки

У новачків optimistic locking часто створює відчуття привида: «Я викликав save, усе пройшло, а тест упав… десь пізніше». Це нормально і навіть логічно, якщо памʼятати, як ORM надсилає SQL. Доки не стався flush (явний або неявний), UPDATE міг просто не піти в базу, а отже, і конфлікт версій не міг бути виявлений.

У @DataJpaTest це ще веселіше: тест зазвичай виконується в транзакції, яка наприкінці відкочується. І якщо ви не зробили flush усередині тесту, то деякі винятки можуть спливти вже на стадії завершення транзакції (або в момент, коли Spring примусово синхронізує стан). Для людини це виглядає як «я перевіряв одне, а впало в іншому місці».

Тому в тестах на optimistic locking корисно дотримуватися принципу: «ми хочемо бачити конфлікт там, де його провокуємо». Практично це означає або entityManager.flush(), або repository.saveAndFlush(...). Різниця між ними в тому, що saveAndFlush одночасно робить merge/save і відразу синхронізує все з базою. entityManager.flush() синхронізує те, що вже знаходиться в persistence context.

Невеликий приклад, що показує різницю за змістом:

Article a = articleRepository.findById(id).orElseThrow(); // managed-сутність
a.setTitle("updated"); // зміна накопичується в persistence context

entityManager.flush(); // відправить UPDATE за managed-сутністю

А ось так — через репозиторій (корисно для detached-копій):

Article detached = loadDetachedCopy(id); // detached-сутність
detached.setTitle("updated");

// Тут важливо: saveAndFlush зробить merge і відразу ж надішле SQL до БД.
articleRepository.saveAndFlush(detached); // merge + flush

Якщо тримати це в голові, optimistic locking перестає бути «магією» і перетворюється на дуже конкретну, спостережувану поведінку: або UPDATE ... WHERE version = ? оновив один рядок, або оновив нуль — і ви отримуєте виняток.

Користь для ContentHub на рівні домену

Optimistic locking може здаватися технічною дрібницею рівня «ще одна колонка», доки не уявити реальний бізнес-сенс. У ContentHub статті проходять workflow: чернетка, ревʼю, публікація, відхилення, архів. І навіть якщо у вас суворі правила статусів, конкурентні конфлікти все одно можливі, бо люди працюють паралельно, а мережа та затримки роблять своє.

Наприклад, два адміністратори одночасно працюють із чергою ревʼю. Один натискає approve, другий — reject (або архівування, або правку причини відмови). Без optimistic locking ви могли б отримати стан, який просто останній у часі, і він може бути бізнесово нелогічним з погляду очікувань команди: одна людина впевнена, що опублікувала статтю, інша впевнена, що відхилила, а в базі збереглося щось одне.

З optimistic locking друге оновлення не перезапише перше мовчки. Воно завершиться конфліктом версій. І це цінно навіть без обговорення того, як саме ви обробляєте помилку далі. Важливо, що data-layer дає вам чесний сигнал: «ваша копія застаріла». А вже на рівні сервісу можна вирішувати, що робити: просити користувача оновити сторінку, повторити дію, показати «хтось уже змінив статтю» тощо. Найголовніше — втрата оновлень перестає бути «тихою».

І ще один момент, який корисно проговорити вголос: version — це не «порядковий номер статті» і не «версія контенту». Це механізм узгодженості запису в базі. У доменних термінах він означає не «13-та редакція тексту», а «13-й успішний запис цього рядка».

6. Типові помилки в @DataJpaTest

Помилка № 1: конфлікт намагаються відтворити на одному managed-обʼєкті.
Поки ви працюєте з однією й тією самою керованою сутністю в одному persistence context, JPA тримає її версію актуальною. У результаті ви не створюєте застарілу копію, і конфлікт просто не виникає.

Помилка № 2: немає flush, і виняток прилітає «десь потім».
Якщо ви робите save, але не робите flush()/saveAndFlush(), то SQL може піти в базу лише наприкінці тесту або під час завершення транзакції. Тоді конфлікт версій спливає в несподіваному місці, і здається, що «впало не там, де ми перевіряли».

Помилка № 3: два findById() вважають «двома незалежними копіями».
JPA часто повертає той самий екземпляр із кешу першого рівня. У результаті замість двох копій ви отримуєте два посилання на один обʼєкт: змінюєте «перший», а «другий» змінюється разом із ним — і жодного конфлікту немає. Для тесту на конфлікт доречні detach і clear.

Помилка № 4: перевіряють абсолютні значення версії.
Сьогодні версія стартує з 0, завтра — з 1 (після оновлення залежностей, зміни діалекта чи провайдера), і тест падає без реальної зміни поведінки. Надійніше перевіряти відносні властивості: версія не null, версія зросла після оновлення, версія відрізняється між станами.

Помилка № 5: змішують optimistic locking із constraint-помилками схеми.
unique/not null ламаються як constraint violation, optimistic locking ламається як конфлікт версії. Якщо перевіряти все в одному тесті, легко отримати нечитабельне падіння «десь там усередині persistence exception». Краще тримати один тест — один головний ризик: або «зламали constraint», або «отримали конфлікт версії».

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