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», або «отримали конфлікт версії».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ