Темна сторона транзакцій. Частина 1: ACID, BASE і чому isolation level — це не просто налаштування

💡 Усі статті, обговорення, новини про Java — в одному місці. Приєднуйтесь до Java спільноти!

Привіт, DOU!

Я Валентин, CEO компанії Versatile Development. Раніше я вже писав про Java Memory Model, Garbage Collectors, оптимізацію JVM та вибір бази даних. А найбільше вам сподобалась моя стаття «Повний огляд REST».

Тож цього разу хочу поговорити про тему, яку багато backend-розробників ніби знають, але часто недооцінюють.

Про транзакції.

Вступ

На перший погляд усе просто.

Є операція.
Вона читає дані.
Перевіряє бізнес-правило.
Змінює кілька записів.
І комітить результат.

Наприклад, переказ грошей між двома рахунками:

BEGIN;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

UPDATE accounts
SET balance = balance + 100
WHERE id = 2;

COMMIT;

Якщо щось пішло не так — робимо ROLLBACK.
Якщо все добре — COMMIT.

І на рівні простого CRUD цього часто достатньо.

Але проблеми починаються тоді, коли система стає живою: з паралельними запитами, фоновими jobʼами, кількома сервісами, ретраями, чергами, кешами, репліками і різними базами даних.

Наприклад:

  • два користувачі одночасно списують гроші з одного рахунку;
  • дві фонові jobʼи беруть в роботу один і той самий запис;
  • один запит читає дані двічі і бачить різний результат;
  • система перевіряє бізнес-правило, воно проходить, але після двох паралельних транзакцій дані все одно стають некоректними;
  • звіт показує стан, якого ніколи не існувало як цілісної картини;
  • простий довгий SELECT не змінює жодного рядка, але все одно заважає базі очищати старі дані.

У кожної з цих ситуацій є своя технічна назва. Частину з них ми знаємо як lost update, non-repeatable read, phantom read, write skew, проблеми MVCC або дедлоки.

Але я не хочу починати статтю зі списку термінів.
Бо терміни самі по собі мало що дають.

Важливіше зрозуміти інше:

  • Яку саме проблему ми намагаємося вирішити;
  • Яку гарантію дає база даних;
  • Яку гарантію вона не дає;
  • Чому однакова назва рівня ізоляції в різних базах може означати різну поведінку.

Саме тому транзакція — це не просто механізм rollback.

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

І тут починається найцікавіше.

  • PostgreSQL за замовчуванням працює в Read Committed, але його Repeatable Read на практиці сильніший, ніж мінімум, який описує SQL standard.
  • MySQL InnoDB за замовчуванням використовує Repeatable Read, але через next-key locks і gap locks його поведінка може здивувати тих, хто прийшов з PostgreSQL.
  • SQL Server може мати різну поведінку Read Committed залежно від того, чи увімкнений READ_COMMITTED_SNAPSHOT.
  • MongoDB підтримує multi-document transactions, але це не означає, що її треба використовувати як PostgreSQL.
  • Redis має MULTI/EXEC і WATCH, але це не транзакції в класичному SQL-сенсі: там немає rollback так, як його очікують багато backend-розробників.
  • Cassandra взагалі змушує думати не в термінах «звичайної транзакції», а в термінах доступності, консистентності, latency і partition tolerance.

Тому в цій статті ми не будемо просто повторювати визначення ACID з Вікіпедії.

Ми розберемо:

  • що насправді означають ACID і BASE;
  • чому COMMIT і ROLLBACK не гарантують коректність самі по собі;
  • які проблеми виникають при паралельних транзакціях;
  • як працюють рівні ізоляції;
  • чому Repeatable Read і Snapshot Isolation — не завжди одне й те саме;
  • що таке MVCC і чому він одночасно рятує продуктивність і створює нові проблеми;
  • коли потрібні locks, SELECT FOR UPDATE, savepoints і retry;
  • чому дедлоки — це не завжди ознака поганого коду;

і чим відрізняється поведінка PostgreSQL, MySQL, SQL Server, Oracle, MongoDB, Redis, Cassandra та Neo4j.

CAP і PACELC ми теж зачепимо, але коротко. Вони важливі для розуміння розподілених систем, але заслуговують окремої статті. Інакше цей матеріал перетвориться не на deep dive по транзакціях, а на маленьку книжку про всі компроміси баз даних одразу.

Головна ідея статті проста:

Транзакції — це не магія, яка робить дані правильними.
Це набір гарантій, які треба вміти читати, порівнювати і правильно застосовувати.

Невелика примітка для Java/Spring-розробників

Якщо ви працюєте зі Spring, то найчастіше бачите транзакції через @Transactional.
Це зручна абстракція, але ця стаття не про Spring transaction management.

Ми не будемо глибоко розбирати Propagation.REQUIRED, REQUIRES_NEW, rollback rules, proxy-based transactions чи self-invocation problem.

Це важливі теми, але вони заслуговують окремої статті.

Тут @Transactional можна сприймати лише як один зі способів сказати базі даних:

«Відкрий транзакцію, виконай ці операції, а потім зроби commit або rollback».

Але самі гарантії — atomicity, isolation, durability, locking, MVCC, deadlocks, snapshot visibility — забезпечує не анотація.
Їх забезпечує база даних.
І саме про це ця стаття.

Як читати цю статтю

Ця стаття поступово вводить терміни.

Якщо ви вже знаєте, що таке phantom read, write skew, MVCC або Serializable Snapshot Isolation, частина пояснень може здатися базовою.

Але я навмисно починаю не з формальних визначень, а з практичних ситуацій.

Бо в реальній роботі ми зазвичай не починаємо з питання:

«Який у мене isolation level?»

Ми починаємо з іншого:

«Чому баланс неправильний?»

«Чому job виконалась двічі?»

«Чому транзакція зависла?»

«Чому база почала роздуватися?»

«Чому на PostgreSQL це працює так, а на MySQL інакше?»

А вже після цього приходимо до термінів.
Спочатку проблеми, потім терміни

У більшості пояснень транзакцій є одна проблема: вони починаються з визначень.

  • Atomicity.
  • Consistency.
  • Isolation.
  • Durability.

Формально це правильно.
Але для практичного розуміння краще почати з того, що саме може піти не так.
Уявімо кілька простих ситуацій.

Ситуація 1. Два оновлення одного балансу

На рахунку є 100 доларів.
Дві транзакції одночасно читають цей баланс.

Перша хоче списати 30.

Друга теж хоче списати 30.

Обидві бачать 100.
Обидві рахують новий баланс: 70.
Обидві записують 70.
Фінальний баланс — 70, хоча мав би бути 40.

Цю проблему пізніше ми назвемо lost update.
Але поки важлива не назва.

Важливо те, що кожна транзакція окремо виглядала правильною.
Проблема виникла саме через паралельність.

Тобто ROLLBACK тут не допоможе.

Обидві транзакції можуть успішно закомітитись.
Жодна з них не впаде.
База не обовʼязково побачить це як помилку.
А результат все одно буде неправильним для бізнесу.

Transaction A                Transaction B
-------------                -------------
READ balance = 100
                           READ balance = 100
calculate 100 — 30 = 70
                         calculate 100 — 30 = 70
WRITE balance = 70
                           WRITE balance = 70
COMMIT                           COMMIT

Final balance = 70
Expected balance = 40

Ситуація 2. Один і той самий запит дає різні результати

Транзакція читає профіль користувача.
Потім виконує ще кілька операцій.
Потім знову читає той самий профіль.
І раптом бачить інші дані, бо паралельна транзакція встигла їх змінити і закомітити.

Це не завжди баг.

У деяких рівнях ізоляції це нормальна поведінка.
Пізніше ми назвемо це non-repeatable read.

Але головне питання тут інше:

чи ваш бізнес-кейс очікує, що дані всередині транзакції залишатимуться стабільними?

Для одного сценарію це нормально.
Наприклад, якщо ви просто показуєте актуальний статус користувача.

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

Ситуація 3. Рядка ще не було, а потім він зʼявився

Транзакція перевіряє:

SELECT COUNT(*)
FROM bookings
WHERE room_id = 10
  AND booking_date = '2026-05-25';

Результат — 0.
Значить, кімната вільна.

Але паралельно інша транзакція теж перевіряє ту саму умову, теж бачить 0 і теж створює бронювання.
У результаті маємо два бронювання на одну кімнату.

Тут проблема не в тому, що хтось змінив уже прочитаний рядок.
Проблема в тому, що зʼявився новий рядок, який підпадає під попередню умову.

Пізніше ми назвемо це phantom read або близькою до цього проблемою, залежно від конкретного сценарію, обмежень і поведінки бази даних.

Але для прикладного розуміння достатньо запамʼятати:

Іноді треба захищати не тільки конкретні рядки, а й саму умову пошуку.

Саме тому в реальних системах недостатньо просто зробити:

SELECT COUNT(*)

а потім окремо:

INSERT INTO bookings ...

Між цими двома діями завжди є простір для іншої транзакції.

Ситуація 4. Обидві транзакції праві, але система зламана

Є правило: у лікарні на чергуванні завжди має залишатися хоча б один лікар.

Зараз чергують два лікарі: Анна і Богдан.
Анна відкриває транзакцію і перевіряє:

«Чи є ще хтось на чергуванні?»

Бачить Богдана.
Значить, може зняти себе з чергування.

Богдан паралельно робить те саме.
Бачить Анну.
Значить, теж може зняти себе з чергування.

Обидві транзакції проходять перевірку.
Обидві комітяться.
У результаті на чергуванні не залишається нікого.

Це один із класичних прикладів проблеми, яку пізніше ми назвемо write skew.
Вона особливо неприємна тим, що тут немає простого конфлікту:

Два потоки оновили один і той самий рядок.

Кожна транзакція оновила свій рядок.

Анна оновила Анну.

Богдан оновив Богдана.

Але разом вони зламали бізнес-інваріант.
І це дуже важливий момент.

База даних може чесно виконати обидві транзакції.
Код кожної транзакції може виглядати правильним.
А система в цілому отримає некоректний стан.

Ситуація 5. Нічого не змінювали, але база страждає

Інколи проблема навіть не в неправильних даних.

Наприклад, у PostgreSQL довга транзакція може тримати старий snapshot даних.

Вона просто читає.
Не робить UPDATE.
Не робить DELETE.
Не змінює жодного рядка.

Але через це база не може нормально прибрати старі версії рядків, які вже не потрібні іншим транзакціям.
У результаті таблиці та індекси ростуть, VACUUM не може ефективно все почистити, а продуктивність поступово деградує.

Тобто транзакції впливають не тільки на коректність.
Вони впливають і на storage, latency, locks, cleanup, replication lag та загальне здоровʼя бази.

Коротко

Перед тим як говорити про ACID, важливо побачити самі проблеми:

  • паралельні транзакції можуть перезаписувати результат одна одної;
  • один і той самий запит може бачити різні дані в межах однієї транзакції;
  • нові рядки можуть зʼявлятися там, де ви вже «перевірили, що їх немає»;
  • бізнес-правило може зламатися навіть тоді, коли кожна транзакція окремо виглядала правильною;
  • довгі транзакції можуть шкодити базі, навіть якщо нічого не змінюють.

Назви цих проблем ми введемо поступово.
Але спочатку важливо зрозуміти головне:

Транзакції існують не для того, щоб красиво обгорнути код у BEGIN і COMMIT.
Вони існують, бо паралельний світ складний.

Що таке транзакція насправді

Після всіх прикладів вище може здатися, що транзакція — це просто спосіб захиститися від помилок.
Але це лише частина правди.

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

Не обовʼязково одну SQL-команду.
Не обовʼязково один рядок.
Не обовʼязково одну таблицю.
А саме одну логічну дію з точки зору системи.

Наприклад:

BEGIN;

UPDATE accounts
SET balance = balance — 100
WHERE id = 1;

UPDATE accounts
SET balance = balance + 100
WHERE id = 2;

INSERT INTO transactions_log(account_from, account_to, amount)
VALUES (1, 2, 100);

COMMIT;

Для бази це три окремі SQL-команди.
Для бізнесу — один переказ грошей.

І саме транзакція має зробити так, щоб система не опинилась у дивному проміжному стані:

  • гроші списали, але не зарахували;
  • гроші зарахували, але не записали лог;
  • лог створили, але баланс не змінився;
  • частина операцій виконалась, а частина загубилась після помилки.

Без транзакції вам довелося б вручну думати, як відкотити кожен проміжний крок.
Але транзакція вирішує не тільки це.
Вона також відповідає на кілька набагато складніших питань.

Що бачить транзакція?

Коли транзакція читає дані, вона не завжди бачить «абсолютно актуальний стан бази».
Це може звучати дивно.

Але в реальній базі даних одночасно можуть виконуватися десятки, сотні або тисячі інших транзакцій.
Одні вже закомітили зміни.
Інші ще працюють.
Треті відкотяться.
Четверті тримають locks.
Пʼяті читають старі версії рядків.

Тому база має вирішити:

Яку саме версію даних показати цій транзакції?

Наприклад, транзакція A читає баланс користувача.
У цей момент транзакція B змінює цей баланс, але ще не робить COMMIT.
Чи має транзакція A побачити цю зміну?
У більшості нормальних сценаріїв — ні.

Бо якщо транзакція B потім зробить ROLLBACK, то транзакція A вже встигне прийняти рішення на основі даних, яких фактично ніколи не існувало.

Саме тут зʼявляється тема isolation.
Isolation — це не абстрактна академічна властивість.

Це відповідь на дуже практичне питання:

Які зміни інших транзакцій я можу бачити, а які ні?

Що відбувається, якщо транзакція падає посередині?

Уявімо, що ми списали гроші з одного рахунку, але не встигли зарахувати їх на інший.

Наприклад:

BEGIN;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
-- application crashed here

UPDATE accounts
SET balance = balance + 100
WHERE id = 2;

COMMIT;

Без транзакції це була б катастрофа.

Перший UPDATE уже змінив би дані.
Другий не виконався б.
Система втратила б гроші.
Але якщо це частина транзакції, база може відкотити незавершені зміни.

Тобто транзакція дає нам гарантію:

Або всі зміни всередині транзакції стануть видимими, або жодна з них не стане видимою.

Це те, що ми пізніше назвемо Atomicity.

Але важливо не плутати.

Atomicity захищає від часткового виконання.
Вона не гарантує, що ваша бізнес-логіка правильна.
Вона не гарантує, що паралельні транзакції не створять конфлікт.
Вона не гарантує, що два користувачі не спишуть гроші одночасно неправильним способом.

Вона відповідає тільки на одне питання:

Чи може транзакція виконатися наполовину?

Що відбувається після COMMIT?

COMMIT — це момент, коли база каже:

«Окей, ця транзакція успішна. Її результат має залишитись.»

Але навіть тут є нюанси.

У нормальній transactional database COMMIT не означає просто «ми змінили кілька рядків у файлі таблиці».
Зазвичай база спочатку записує інформацію в transaction log, WAL, redo log або схожий механізм відновлення.

Це потрібно, щоб після падіння процесу, сервера або живлення база могла зрозуміти:

  • які транзакції були закомічені;
  • які не були завершені;
  • які зміни треба відновити;
  • які зміни треба відкотити.

Тобто durability — це не про те, що «дані магічно не зникають».
Це про здатність бази відновити коректний стан після збою.
І тут теж є компроміси.

Бо durability може залежати від налаштувань fsync, synchronous commit, replication, journal, storage engine, cloud disk guarantees та інших деталей.

Але базова ідея проста:

Якщо база підтвердила COMMIT, вона має мати спосіб не загубити цю транзакцію після crash.

Чому транзакція — це не тільки BEGIN і COMMIT

Найпростіший спосіб думати про транзакцію:

BEGIN;
-- do something
COMMIT;

Але в реальності база в цей момент робить набагато більше.

Вона має:

  • визначити, які дані бачить транзакція;
  • захистити незакомічені зміни від інших транзакцій;
  • виявити або запобігти частині конфліктів;
  • взяти потрібні locks;
  • створити або прочитати потрібні версії рядків;
  • записати зміни так, щоб їх можна було відновити після падіння;
  • звільнити ресурси після commit або rollback;
  • іноді змусити одну з транзакцій впасти, щоб не допустити некоректного стану.

Тому транзакція — це не просто «обгортка» навколо кількох SQL-команд.
Це механізм координації між вашим кодом, storage engine, transaction log, locks, MVCC і паралельними операціями.

Саме тому дві бази даних можуть підтримувати SQL-транзакції, але поводитись по-різному.

PostgreSQL, MySQL InnoDB, SQL Server, Oracle, MongoDB, Redis, Cassandra і Neo4j мають різні моделі зберігання, ізоляції, блокувань і recovery.

І якщо ми цього не враховуємо, то легко потрапити в ситуацію:

«На локальній базі все працювало.
На staging теж.
А в production під навантаженням дані стали дивними.»

Коротко

Транзакція — це не просто BEGIN, кілька запитів і COMMIT.

Це межа, яка визначає:

  • які операції мають виконатись як одна логічна дія;
  • що станеться, якщо частина операцій впаде;
  • які зміни інших транзакцій ми бачимо;
  • коли наші зміни стають видимими іншим;
  • як база відновиться після crash;
  • які компроміси між коректністю, паралельністю і швидкістю ми приймаємо.

Якщо дуже спростити:

Транзакція — це спосіб сказати базі: «Оці зміни належать до однієї історії.
Зроби так, щоб ця історія не розвалила дані».

Але щоб зрозуміти, які саме гарантії ми отримуємо, треба перейти до ACID.
ACID: чотири властивості, які часто розуміють занадто спрощено

Коли говорять про транзакції, майже завжди згадують ACID.

  • Atomicity.
  • Consistency.
  • Isolation.
  • Durability.

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

Тому розберемо кожну властивість не як визначення з підручника, а як практичне питання.

Atomicity: чи може транзакція виконатися наполовину?

Atomicity означає: або всі зміни всередині транзакції застосовуються, або жодна.

Наприклад:

BEGIN;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

UPDATE accounts
SET balance = balance + 100
WHERE id = 2;
COMMIT;

якщо друга операція падає, база не має залишити систему в стані:

«З першого рахунку списали, на другий не зарахували».

Має бути або повний переказ, або жодного переказу.

Для цього існує ROLLBACK.

BEGIN;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
-- щось пішло не так

ROLLBACK;

Після ROLLBACK зміни цієї транзакції не мають стати видимими як закінчений результат.

Але тут важливо не переоцінювати atomicity.

Atomicity не означає, що операція логічно правильна.
Atomicity не перевіряє, чи користувач мав право робити переказ.
Atomicity не знає, чи не порушили ви бізнес-правило.
Atomicity не захищає автоматично від ситуації, коли дві паралельні транзакції прочитали старий баланс і записали неправильний результат.

Вона вирішує тільки проблему часткового виконання.

Тому якщо повернутись до прикладу з двома одночасними списаннями:

Transaction A reads balance = 100
Transaction B reads balance = 100

Transaction A writes balance = 70
Transaction B writes balance = 70

Atomicity тут може бути виконана ідеально.
Обидві транзакції виконались повністю.
Обидві закомітились.
Жодна не виконалась наполовину.
Але фінальний результат все одно неправильний.

Тобто atomicity — дуже важлива гарантія.
Але вона не замінює isolation, locking, constraints і правильну модель оновлення даних.

Consistency: база не знає всіх ваших бізнес-правил

Consistency — найпідступніша літера в ACID.

Її часто пояснюють так:

«База переходить з одного валідного стану в інший валідний стан».

Звучить добре.

Але виникає питання:

Що таке валідний стан?

Для бази валідний стан — це те, що вона може перевірити своїми механізмами.

Наприклад:

CREATE TABLE accounts (
 id BIGINT PRIMARY KEY,
 balance NUMERIC NOT NULL CHECK (balance >= 0)
);

Тут база може гарантувати, що balance не буде меншим за нуль.
Якщо хтось спробує записати -100, база відхилить операцію.

Так само база може перевірити:

  • PRIMARY KEY;
  • FOREIGN KEY;
  • UNIQUE;
  • NOT NULL;
  • CHECK;
  • exclusion constraints;
  • частину правил через triggers.

Але база не завжди знає ваш бізнес-контекст.

Наприклад:

  • у лікарні завжди має залишатися хоча б один лікар на чергуванні;
  • premium-клієнт не може залишитися без активного payment method;
  • order не можна підтвердити, якщо fraud score змінився після перевірки;
  • payout не можна відправити, якщо compliance check ще не завершився;
  • користувач не може мати два активні trial-періоди в різних продуктах.

Частину цих правил можна перенести в constraints.
І якщо можна — часто краще так і зробити.
Бо constraint у базі складніше обійти, ніж перевірку в application code.

Але не все легко виразити через простий constraint.
Особливо якщо правило залежить від кількох рядків, кількох таблиць, часу, зовнішнього сервісу або складного workflow.

І тут зʼявляється важливий момент:

Consistency в ACID не означає, що база автоматично захистить усі ваші бізнес-інваріанти.

Вона захистить ті правила, які ви явно задали і які вона здатна перевірити.
Решта — це вже комбінація правильного моделювання, isolation level, locks, retry logic, constraints, application code і іноді зміни архітектури.

Саме тому приклад з лікарями на чергуванні такий неприємний.
Кожна транзакція окремо перевіряє правило.
Кожна бачить, що інший лікар ще на зміні.
Кожна робить коректний локальний висновок.
Але разом вони залишають зміну порожньою.

З точки зору кожної транзакції все виглядало нормально.
З точки зору системи — інваріант зламаний.

Isolation: що відбувається, коли транзакції виконуються одночасно?

Isolation — це серце всієї теми.

Бо більшість неприємних багів з транзакціями виникає не тоді, коли один запит падає.
Вони виникають тоді, коли кілька правильних запитів виконуються одночасно.

Isolation відповідає на питання:

«Наскільки сильно паралельні транзакції мають бути ізольовані одна від одної?»

Найпростіша ментальна модель — уявити, що транзакції виконуються по черзі.
Спочатку повністю A.
Потім повністю B.
Потім повністю C.

Якщо результат паралельного виконання такий самий, ніби транзакції виконались у якомусь послідовному порядку, це дуже сильна гарантія.

Її ми пізніше будемо називати Serializable.

Але в реальних системах завжди є компроміс.
Якщо змусити всі транзакції фактично виконуватись одна за одною, система буде коректною, але повільною.
Якщо дозволити їм виконуватись максимально паралельно, система буде швидшою, але можуть зʼявитися аномалії.

Тому бази даних пропонують різні рівні ізоляції.

Наприклад:

  • Read Uncommitted;
  • Read Committed;
  • Repeatable Read;
  • Serializable.

Але не поспішайте запамʼятовувати цю таблицю як істину.
У різних базах ці рівні можуть поводитись по-різному.

До того ж класична SQL-таблиця з dirty read, non-repeatable read і phantom read не пояснює всі реальні проблеми.

Вона погано описує такі речі, як snapshot isolation, lost update, write skew, predicate locks, MVCC і retry-based concurrency control.

Тому isolation ми будемо розбирати окремо й дуже уважно.

Поки достатньо зафіксувати головне:

Isolation — це не про rollback.
Це про те, як паралельні транзакції бачать і впливають одна на одну.

Durability: що означає «дані збережені»?

Durability означає: якщо база підтвердила COMMIT, результат транзакції має пережити збій.

Наприклад:

COMMIT;

Після цього application отримує відповідь:

“успішно”.

А через секунду падає процес бази.
Або сервер.
Або зникає живлення.
Або рестартується контейнер.

Після відновлення база має повернутися до стану, де закомічена транзакція не загубилась.
Для цього бази використовують transaction log, WAL, redo log, journal або схожі механізми.

Дані не обовʼязково одразу фізично лежать у фінальному місці у файлі таблиці.
Але база має мати достатньо інформації, щоб відновити закомічені зміни після crash.

Тут теж є важливий нюанс.

Durability в межах однієї бази не завжди означає:

  • що зміна вже є на всіх репліках;
  • що вона переживе втрату цілого дата-центру;
  • що зовнішній сервіс теж виконав свою частину;
  • що повідомлення в черзі вже доставлене;
  • що distributed transaction між кількома системами завершилась успішно.

Наприклад, якщо ви в одній транзакції оновили payment record у базі, а потім окремо викликали зовнішній payment provider, база не може магічно гарантувати atomicity для обох систем.

Це вже інша тема: distributed transactions, outbox pattern, sagas, idempotency, message delivery guarantees.

Ми коротко зачепимо ці компроміси ближче до CAP/PACELC, але не будемо перетворювати цю статтю на окремий deep dive по distributed systems.

Для цієї частини важливо запамʼятати:

Durability гарантує виживання закомічених змін у межах конкретної системи зберігання, але не вирішує автоматично всі проблеми розподіленої архітектури.

Коротко

ACID — це не магічний штамп «дані будуть правильними».

Це набір конкретних гарантій:

  • Atomicity — транзакція не має виконатися наполовину.
  • Consistency — база зберігає ті правила цілісності, які вона знає і може перевірити.
  • Isolation — паралельні транзакції мають не ламати одна одну понад дозволений рівень.
  • Durability — після COMMIT база має мати спосіб відновити зміни після збою.

Найчастіше проблеми виникають не тому, що ACID «не працює».
А тому, що ми очікуємо від ACID більше, ніж він реально гарантує.

Особливо від літер C та I.
Consistency не знає всіх бізнес-правил.
Isolation залежить від конкретного рівня ізоляції та конкретної бази даних.

Саме тому наступний важливий крок — розібрати BASE.
Бо не всі системи прагнуть до сильних ACID-гарантій у кожній операції.
І це не завжди помилка.

BASE: не «поганий ACID», а інша модель компромісів

Після ACID легко зробити неправильний висновок:
«ACID — це правильно. BASE — це коли база не вміє нормально працювати з даними.»

Але це занадто спрощене бачення.

BASE — це не «зламаний ACID».
BASE — це інша модель компромісів.

Якщо ACID намагається дати сильні гарантії для транзакцій, то BASE частіше зʼявляється там, де система ставить на перше місце доступність, масштабування, latency і роботу в розподіленому середовищі.

BASE зазвичай розшифровують як:

  • Basically Available;
  • Soft state;
  • Eventually consistent.

Звучить трохи абстрактно, тому розберемо по-людськи.

Basically Available: система намагається відповідати навіть тоді, коли не все ідеально

У класичній ACID-транзакції ми часто очікуємо чітку відповідь:

Або зміна успішно застосована, або ні.

У BASE-системах підхід може бути іншим.

Система може прийняти запит, записати його локально, поставити подію в чергу, оновити частину реплік, а решту підтягнути пізніше.

Головна ідея:

Краще дати користувачу доступну систему з тимчасово неповною консистентністю, ніж повністю відмовити в роботі.

Наприклад, уявімо соціальну мережу.

Користувач лайкає пост.
Один сервіс уже записав лайк.
Інший ще не оновив лічильник.
Третій ще не перебудував рекомендації.
Четвертий ще не оновив feed.
Кілька секунд різні частини системи можуть показувати трохи різну картину.

Для лайків це зазвичай нормально.
Для банківського балансу — ні.
І в цьому вся суть.

BASE не каже: «Консистентність не потрібна».

BASE каже: «Не кожна операція в системі потребує однаково сильної консистентності».

Soft state: стан системи може змінюватися не тільки через прямий запит користувача

У простій CRUD-моделі ми часто думаємо так:

користувач зробив запит → база змінила стан.

Але в розподілених системах стан може змінюватися асинхронно.

Наприклад:

  • прийшла подія з черги;
  • оновилася репліка;
  • пройшов background job;
  • кеш інваліднувся;
  • projection перебудувався;
  • аналітичний агрегат дорахував нові значення;
  • search index підтягнув останні зміни.

Тобто система може бути в «мʼякому» стані.
Не в сенсі «хаотичному».

А в сенсі: її різні частини можуть тимчасово не збігатися, але поступово сходяться до правильного результату.

Наприклад, користувач оновив імʼя профілю.
У головній базі імʼя вже нове.
У кеші ще старе.
У search index ще старе.
В email template вже нове.
У billing-системі ще старе.
Це не обовʼязково катастрофа.

Якщо така тимчасова розбіжність допустима для бізнесу — BASE-підхід може бути нормальним.

Але якщо мова про списання грошей, доступ до акаунту, compliance check або унікальність критичного запису — такий підхід може бути небезпечним.

Eventually Consistent: дані стануть узгодженими, але не обовʼязково одразу

Eventually consistent означає:

Якщо нових змін не буде, система з часом прийде до узгодженого стану.

Ключові слова тут — з часом.

Не обовʼязково зараз.
Не обовʼязково в межах поточного запиту.
Не обовʼязково в усіх репліках одночасно.

Наприклад:

користувач створив пост;
пост одразу зʼявився в його профілі;
у feed підписників він зʼявиться через кілька секунд;
у пошуку — через хвилину;
в аналітиці — після наступної обробки batch job.

Це може бути абсолютно прийнятно.

Але якщо користувач щойно оплатив підписку, а система ще 10 хвилин вважає його free-userʼом — це вже може бути проблемою.

Тому питання не в тому, чи eventual consistency «добра» або «погана».

Питання в іншому:

Де саме у вашій системі допустима затримка консистентності, а де потрібна сильна гарантія одразу?

ACID і BASE — це не SQL проти NoSQL

Ще одна популярна помилка:

SQL-бази — це ACID.

NoSQL-бази — це BASE.

У реальності все складніше.
PostgreSQL, MySQL InnoDB, SQL Server і Oracle справді мають сильну транзакційну модель.

Але це не означає, що кожна операція у вашій архітектурі автоматично ACID.

Якщо ви оновили рядок у PostgreSQL, потім відправили повідомлення в Kafka, потім викликали Stripe, потім оновили Redis-кеш — це вже не одна проста ACID-транзакція.

З іншого боку, деякі NoSQL-бази також мають транзакційні механізми.
MongoDB підтримує multi-document transactions.
Redis має MULTI/EXEC і WATCH.
Cassandra має lightweight transactions для окремих сценаріїв compare-and-set.
Neo4j, як graph database, теж підтримує ACID-транзакції, але в зовсім іншій моделі даних.

Неправильно думати категоріями: «SQL — серйозно, NoSQL — приблизно.»

Правильніше ставити конкретні питання:

  • яка саме операція виконується?
  • вона змінює один рядок, один документ, одну partition чи багато різних сутностей?
  • чи є паралельні записи?
  • чи є бізнес-інваріант?
  • що буде при retry?
  • що буде при network partition?
  • чи потрібна миттєва консистентність?
  • чи достатньо eventual consistency?
  • чи можна компенсувати помилку пізніше?
  • чи можна зробити операцію idempotent?

Тільки після цього можна чесно вибирати модель.

Приклад: де BASE підходить добре

Уявімо систему рекомендацій для FinTech-додатку.

Користувач виконав кілька дій:

  • відкрив savings account;
  • кілька разів переглянув investment products;
  • поповнив баланс;
  • відхилив кредитну пропозицію.

На основі цих подій система має оновити рекомендації.
Чи потрібно робити це в одній синхронній ACID-транзакції разом з основною операцією?
Скоріше за все, ні.

Можна записати основну подію надійно, а рекомендації перебудувати асинхронно.

Якщо нова рекомендація зʼявиться через 10 секунд або навіть через кілька хвилин — це не зламає фінансову цілісність системи.

Тут BASE-підхід нормальний.

Ба більше, він може бути кращим.
Бо ми не блокуємо основну операцію через допоміжну логіку.
Не змушуємо користувача чекати, поки оновиться аналітика.
Не створюємо зайву звʼязаність між transactional core і recommendation engine.

Приклад: де BASE може бути небезпечним

А тепер інший сценарій.

Користувач хоче вивести гроші.

Система має перевірити:

  • чи достатньо балансу;
  • чи не перевищено ліміт;
  • чи пройдено compliance check;
  • чи немає hold на акаунті;
  • чи не виконується вже інший payout.

Тут eventual consistency може бути небезпечною.

Якщо один сервіс бачить старий баланс, інший ще не знає про hold, а третій ще не отримав результат compliance check — система може дозволити операцію, яку не мала дозволити.

У таких місцях зазвичай потрібна сильніша модель:

  • транзакція;
  • constraint;
  • lock;
  • idempotency key;
  • optimistic або pessimistic locking;
  • retry logic;
  • чітка межа consistency boundary.

Тобто BASE не є проблемою сам по собі.

Проблема починається тоді, коли BASE-підхід випадково застосовують там, де бізнес очікує ACID-гарантій.

Найважливіше питання: де ваша consistency boundary?

У будь-якій складній системі треба розуміти, де проходить межа консистентності.

Consistency boundary — це частина системи, всередині якої дані мають залишатися узгодженими одразу.

Наприклад, для банківського переказу consistency boundary може включати:

  • balance;
  • ledger entries;
  • transaction status;
  • idempotency key;
  • audit trail.

Ці речі мають змінюватися як єдина логічна операція або через дуже чітко контрольований workflow.

А от recommendation model, email notification, analytics event, search projection чи dashboard metric можуть оновитися пізніше.

Їх не обовʼязково тягнути в ту саму транзакцію.

Це важлива архітектурна думка:

Не все в системі має бути strongly consistent, але ви повинні точно знати, що саме має бути strongly consistent.

Якщо цього не визначити, система поступово перетворюється на набір випадкових компромісів.
Десь ми блокуємо занадто багато.
Десь дозволяємо занадто багато паралельності.
Десь довіряємо кешу, де не можна.
Десь робимо distributed workflow без idempotency.
Десь очікуємо від бази гарантій, яких вона не давала.

Коротко

BASE — це не «поганий ACID».

Це інша модель компромісів:

  • Basically Available — система намагається залишатися доступною;
  • Soft state — різні частини системи можуть тимчасово бачити різний стан;
  • Eventually consistent — з часом дані мають зійтися до узгодженого стану.

BASE добре підходить для:

  • кешів;
  • рекомендацій;
  • лічильників;
  • feedʼів;
  • аналітики;
  • search index;
  • projections;
  • не критичних read models.

BASE може бути небезпечним для:

  • балансів;
  • платежів;
  • payout;
  • compliance-рішень;
  • унікальних бізнес-обмежень;
  • доступів і permissions;
  • операцій, де помилку складно компенсувати.

Головне питання не «ACID чи BASE?».

Головне питання:

Які гарантії потрібні саме цій операції?

І щоб відповісти на це питання, треба глибше розібрати isolation.

Бо саме isolation найчастіше визначає, що станеться, коли кілька транзакцій одночасно працюють з одними й тими самими даними.

Рівні ізоляції: чому класична таблиця не розповідає всю правду

Коли розробники вперше вивчають isolation levels, вони зазвичай бачать приблизно таку таблицю:

Isolation levelDirty readNon-repeatable readPhantom read
Read Uncommittedpossiblepossiblepossible
Read Committedpreventedpossiblepossible
Repeatable Readpreventedpreventedpossible
Serializablepreventedpreventedprevented

Ця таблиця корисна.
Але вона небезпечна, якщо сприймати її як повну картину.
Бо в реальних базах даних все складніше.

По-перше, різні бази можуть по-різному реалізовувати рівні з однаковими назвами.
Repeatable Read у PostgreSQL — це не те саме, що Repeatable Read у MySQL InnoDB.
Read Committed у PostgreSQL — не те саме, що Read Committed у SQL Server з увімкненим READ_COMMITTED_SNAPSHOT.
Serializable у PostgreSQL — це не просто «поставити locks на все», а Serializable Snapshot Isolation.

По-друге, класична таблиця говорить лише про три явища:

  • dirty read;
  • non-repeatable read;
  • phantom read.

Але в реальних системах нас цікавлять також:

  • lost update;
  • write skew;
  • read skew;
  • serialization anomalies;
  • lock contention;
  • deadlocks;
  • retry behavior;
  • visibility rules;
  • MVCC snapshots;
  • predicate locks;
  • gap locks;
  • next-key locks.

По-третє, сама назва isolation level не відповідає на питання:

Чи безпечно мені реалізувати конкретний бізнес-сценарій саме так?

Вона лише задає приблизну модель того, що транзакція може бачити.
Тому ми підемо не від таблиці до життя, а навпаки.
Спочатку розберемо проблеми, які виникають при паралельних транзакціях.
Потім подивимось, які isolation levels їх дозволяють або забороняють.
А вже після цього порівняємо, як різні бази даних реалізують ці гарантії.

Dirty read: читання даних, яких може ніколи не існувати

Dirty read — це ситуація, коли одна транзакція читає зміни іншої транзакції, які ще не були закомічені.

Уявімо:

Transaction A                         Transaction B
-------------                         -------------
BEGIN;
UPDATE accounts
SET balance = 0
WHERE id = 1;
                                      BEGIN;
                                      SELECT balance
                                      FROM accounts
                                      WHERE id = 1;
                                      -- бачить 0
ROLLBACK;

Транзакція B побачила баланс 0.
Але транзакція A зробила ROLLBACK.

Тобто з точки зору фінального стану бази балансу 0 ніколи не існувало.
Якщо B прийняла бізнес-рішення на основі цього значення, вона використала «брудні» дані.
Звідси і назва — dirty read.

У нормальних transactional systems dirty read майже ніколи не є тим, чого ви хочете.
Для фінансових операцій це очевидно небезпечно.

Але навіть у звичайному CRUD це може створити дивні баги:

  • користувач бачить статус, який потім зникає;
  • сервіс відправляє notification про зміну, яку відкотили;
  • background job бере в роботу запис, який фактично не був створений;
  • аналітика рахує подію, якої не сталося.

Саме тому багато баз даних або взагалі не дозволяють dirty reads, або роблять Read Uncommitted фактично сильнішим, ніж звучить з назви.

Наприклад, PostgreSQL формально приймає рівень Read Uncommitted, але поводиться як Read Committed.

Тобто навіть якщо ви попросите найслабший рівень, PostgreSQL все одно не дасть вам читати незакомічені зміни інших транзакцій.

Non-repeatable read: один і той самий рядок змінився всередині транзакції

Non-repeatable read виникає тоді, коли транзакція двічі читає один і той самий рядок і отримує різні результати, бо між читаннями інша транзакція закомітила зміну.

Наприклад:

Transaction A                         Transaction B
-------------                         -------------
BEGIN;
SELECT balance
FROM accounts
WHERE id = 1;
-- balance = 100
                                      BEGIN;
                                      UPDATE accounts
                                      SET balance = 50
                                      WHERE id = 1;
                                      COMMIT;
SELECT balance
FROM accounts
WHERE id = 1;
-- balance = 50
COMMIT;

Транзакція A в межах однієї транзакції побачила два різні значення одного й того самого рядка.
Це може бути нормально.
А може бути дуже погано.

Якщо ви просто показуєте користувачу актуальні дані — можливо, все окей.
Але якщо ви робите розрахунок, який має базуватися на стабільному стані, це вже ризик.

Наприклад:

  • перший запит читає balance;
  • другий читає pending operations;
  • третій знову читає balance;
  • application збирає з цього фінальне рішення.

Якщо дані між читаннями змінились, ви можете прийняти рішення на основі суміші різних моментів часу.
Це особливо неприємно для звітів, фінансових розрахунків, reconciliation, risk scoring і складних workflow.
Read Committed зазвичай дозволяє таку поведінку.

Бо його базова гарантія проста:

Кожен запит бачить тільки закомічені дані, але різні запити всередині однієї транзакції можуть бачити різні закомічені стани.

Тобто Read Committed захищає від dirty read.
Але не гарантує, що snapshot буде стабільним протягом усієї транзакції.

Phantom read: змінився не рядок, а результат умови

Phantom read — це ситуація, коли транзакція двічі виконує один і той самий запит за умовою, але вдруге бачить нові рядки, які зʼявились через паралельну транзакцію.

Наприклад:

Transaction A                         Transaction B
-------------                         -------------
BEGIN;
SELECT *
FROM bookings
WHERE room_id = 10
  AND booking_date = '2026-05-25';
-- 0 rows
                                      BEGIN;
                                      INSERT INTO bookings(room_id, booking_date)
                                      VALUES (10, '2026-05-25');
                                      COMMIT;
SELECT *
FROM bookings
WHERE room_id = 10
  AND booking_date = '2026-05-25';
-- 1 row
COMMIT;

Транзакція A не побачила зміну конкретного рядка.
Бо рядка спочатку взагалі не було.
Вона побачила, що результат умови змінився.
Зʼявився «фантомний» рядок.
Це дуже важливо.

Бо багато бізнес-правил звучать не як:

«Онови конкретний рядок з id = 123».

А як:

«перевір, що не існує активного бронювання на цю кімнату»;

«перевір, що немає іншого активного trial»;

«перевір, що немає payout у статусі processing»;

«перевір, що кількість активних сесій менша за ліміт»;

«перевір, що на цю дату ще немає запису».

Тобто ми перевіряємо не один рядок.
Ми перевіряємо умову.
І якщо база не захищає цю умову від паралельних вставок, у нас може виникнути баг.

Інколи правильне рішення — не піднімати isolation level, а змінити модель даних.

Наприклад, замість перевірки COUNT(*) = 0 можна додати UNIQUE constraint:
CREATE UNIQUE INDEX unique_room_bookingON bookings(room_id, booking_date);

Тоді навіть якщо дві транзакції одночасно спробують створити бронювання, база не дозволить записати дубль.
Одна транзакція виграє.
Інша отримає помилку.
А application має правильно її обробити.
Це часто краще, ніж покладатися лише на перевірку в коді.

Коротко

Класична таблиця isolation levels корисна, але вона не пояснює все.

На цьому етапі важливо запамʼятати три базові проблеми:

  • Dirty read — ми прочитали незакомічені дані, які можуть бути відкочені.
  • Non-repeatable read — ми двічі прочитали той самий рядок і побачили різні значення.
  • Phantom read — ми двічі виконали запит за умовою і побачили нові рядки.

Але це ще не всі небезпечні аномалії.

Найчастіше в реальному backend нас боляче бʼють не тільки dirty/non-repeatable/phantom reads.

Нас бʼють:

  • lost update;
  • write skew;
  • неправильні read-modify-write операції;
  • відсутність constraints;
  • неправильний retry;
  • сліпа віра в default isolation level.

Тому далі треба розібрати аномалії, які особливо часто виникають у production-системах.

Продовжуємо з блоку про аномалії, які найчастіше болять у production: lost update, read-modify-write, write skew, read skew.

Аномалії, які частіше за все болять у реальних системах

Dirty read, non-repeatable read і phantom read — це хороша база для розуміння isolation levels.
Але в реальних backend-системах часто болять інші сценарії.

Особливо ті, де application робить щось на кшталт:

read → calculate → write

Тобто:

  1. прочитали поточний стан;
  2. прийняли рішення в коді;
  3. записали новий стан назад.

На папері це виглядає нормально.
Але при паралельному виконанні така модель легко ламається.

Lost update: коли одна транзакція непомітно затирає результат іншої

Повернемось до прикладу з балансом.
На рахунку є 100.
Дві транзакції одночасно хочуть списати по 30.

Transaction A                         Transaction B
-------------                         -------------
BEGIN;                                BEGIN;
SELECT balance                        SELECT balance
FROM accounts                         FROM accounts
WHERE id = 1;                         WHERE id = 1;
-- 100                                -- 100
calculate 100 - 30 = 70               calculate 100 - 30 = 70
UPDATE accounts                       UPDATE accounts
SET balance = 70                      SET balance = 70
WHERE id = 1;                         WHERE id = 1;
COMMIT;                               COMMIT;

Фінальний баланс — 70.
А мав би бути 40.

Одна зміна фактично загубилася.
Звідси й назва — lost update.

Проблема тут не в тому, що база не вміє робити UPDATE.
Проблема в тому, що application прочитав значення, порахував нове значення у себе в коді, а потім записав результат назад.

Тобто ми зробили операцію не як атомарну зміну в базі, а як read-modify-write цикл між application і database.

Чому UPDATE balance = balance - 30 часто краще за read-modify-write

Поганий варіант виглядає так:

SELECT balance
FROM accounts
WHERE id = 1;
-- application рахує newBalance = balance - 30

UPDATE accounts
SET balance = :newBalance
WHERE id = 1;

Тут між SELECT і UPDATE є вікно, в яке може зайти інша транзакція.

Кращий варіант:

UPDATE accounts SET balance = balance - 30 WHERE id = 1;

Тут зміна відбувається всередині самої бази.
База не просто записує готове значення 70.
Вона застосовує операцію до поточного значення рядка.

Для багатьох сценаріїв це вже сильно зменшує ризик lost update.
Але цього все одно може бути недостатньо.

Наприклад, якщо треба перевірити, що баланс не стане негативним, краще не робити окремий SELECT, а включити умову прямо в UPDATE:

UPDATE accounts SET balance = balance - 30 WHERE id = 1   AND balance >= 30;

Після цього application перевіряє, скільки рядків було оновлено.
Якщо 1 — списання успішне.
Якщо 0 — грошей недостатньо або запис не знайдено.
Це дуже важливий патерн.

Ми не питаємо базу:

«Скільки там грошей? Я зараз сам порахую.»

Ми кажемо:

«Спробуй списати 30, але тільки якщо баланс дозволяє.»

Це ближче до того, як треба думати про транзакції в конкурентному середовищі.
Не просто читати стан і вірити, що він не змінився.
А формулювати зміну так, щоб база могла перевірити умову в момент оновлення.

Lost update — це не тільки про гроші

Баланс — зручний приклад, але проблема набагато ширша.
Lost update може зʼявитися майже будь-де.

Наприклад, лічильник:

views = 100
User A reads 100
User B reads 100
User A writes 101
User B writes 101

Мало бути 102.
Стало 101.

Або налаштування користувача:

Transaction A змінює email notifications.
Transaction B змінює language preference.
Обидві читають старий profile object.
Обидві записують весь profile object назад.
Одна зміна затирає іншу.

Або inventory:

На складі 5 одиниць товару.
Два замовлення одночасно читають stock = 5.
Обидва вирішують, що товар є.
Обидва записують stock = 4.
Фактично продали 2 одиниці, але склад зменшився тільки на 1.

Або job processing:

Дві воркер-ноди одночасно бачать job у статусі NEW.
Обидві беруть її в роботу.
Обидві виконують зовнішній side effect.

Тому lost update — це не академічна проблема з підручника.

Це один із найпрактичніших багів у системах, де є паралельні запити.

Як зазвичай захищаються від lost update

Є кілька підходів.

Перший — атомарний UPDATE з умовою, як у прикладі з балансом:
UPDATE accounts SET balance = balance - 30 WHERE id = 1   AND balance >= 30;

Другий — constraint у базі.

Наприклад:
ALTER TABLE accounts ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);
Constraint не замінює всю бізнес-логіку, але добре захищає базові інваріанти.

Третій — pessimistic locking.
Тобто ми явно блокуємо рядок перед тим, як приймати рішення:
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;

Ідея проста:

«Я зараз буду приймати рішення на основі цього рядка.
Не давайте іншій транзакції змінити його, поки я не закінчу.»

Це може бути правильним рішенням для критичних операцій.
Але воно зменшує паралельність і може створити contention або deadlocks, якщо використовувати його бездумно.

Четвертий — optimistic locking.

Наприклад, у таблиці є колонка version:

UPDATE accounts SET balance = 70,     version = version + 1 WHERE id = 1   AND version = 5;

Якщо інша транзакція вже змінила цей рядок і підняла version, наш UPDATE оновить 0 рядків.

Це означає:

«Я працював зі старою версією даних. Треба перечитати і спробувати ще раз або повернути помилку.»

Optimistic locking добре працює там, де конфлікти рідкі.
Pessimistic locking — там, де конфлікти часті або помилка занадто дорога.
Ми ще повернемось до цього окремо в розділі про locking.

Поки важливо зафіксувати:

Isolation level — це не єдиний інструмент захисту від lost update.

Часто правильна відповідь — це комбінація:

  • правильного SQL;
  • constraints;
  • locks;
  • optimistic locking;
  • retry logic;
  • і нормального моделювання даних.

Write skew: коли транзакції не конфліктують напряму, але ламають правило

Lost update ще відносно легко зрозуміти.
Дві транзакції працюють з одним і тим самим рядком.
Одна затирає результат іншої.
Але є більш підступна проблема — write skew.

Вона виникає тоді, коли дві транзакції читають спільний набір даних, приймають рішення, а потім змінюють різні рядки.
Тобто прямого конфлікту по одному рядку немає.
Але бізнес-правило все одно ламається.

Класичний приклад — лікарі на чергуванні.

Є таблиця:

CREATE TABLE doctors_on_call (     doctor_id BIGINT PRIMARY KEY,     is_on_call BOOLEAN NOT NULL );

Дані:

doctor_id | is_on_call
----------|-----------
1         | true
2         | true

Правило:

На чергуванні має залишатися хоча б один лікар.

Транзакція A:

BEGIN;

SELECT COUNT(*)
FROM doctors_on_call
WHERE is_on_call = true;
-- бачить 2

UPDATE doctors_on_call
SET is_on_call = false
WHERE doctor_id = 1;

COMMIT;

Транзакція B одночасно:

BEGIN;

SELECT COUNT(*)
FROM doctors_on_call
WHERE is_on_call = true;
-- теж бачить 2

UPDATE doctors_on_call
SET is_on_call = false
WHERE doctor_id = 2;

COMMIT;

Фінальний стан:

doctor_id | is_on_call
----------|-----------
1         | false
2         | false

Кожна транзакція окремо виглядала правильною.
Кожна бачила, що є ще один лікар.
Кожна оновила свій рядок.
Ніхто не перезаписав чужий update.
Але бізнес-інваріант зламаний.

Це і є write skew.

Чому write skew складніший за lost update

При lost update ми часто можемо сказати:

«Окей, треба захистити конкретний рядок.»

Але при write skew проблема не в одному конкретному рядку.
Проблема в правилі, яке залежить від набору рядків.

У прикладі з лікарями треба захистити не просто doctor_id = 1 або doctor_id = 2.

Треба захистити умову:

WHERE is_on_call = true

Або навіть ширше:

«У цій групі має залишатися хоча б один активний запис.»

Саме тому write skew часто проявляється в сценаріях:

  • «має залишитися хоча б один active admin»;
  • «має бути не більше N активних сесій»;
  • «не можна мати два активні subscription plans»;
  • «не можна запустити payout, якщо вже є payout у processing»;
  • «у кімнаті не може бути двох бронювань на той самий слот»;
  • «ліміт не має бути перевищений сумою кількох операцій».

І це вже не завжди лікується простим row lock.
Бо ви можете заблокувати рядок, який оновлюєте.
Але інша транзакція оновлює інший рядок.
З точки зору бази, прямого конфлікту може не бути.

Як захищатися від write skew

Є кілька підходів.
Перший — підняти isolation level до Serializable, якщо база справді дає serializable-гарантії для такого сценарію.

Ідея serializable в тому, що результат паралельного виконання має бути таким, ніби транзакції виконались у якомусь послідовному порядку.

У прикладі з лікарями це означає, що база має не дозволити обом транзакціям успішно закомітитись.
Одна може пройти.
Інша має отримати serialization failure і бути повторена або відхилена.

Але тут важливо:
Serializable — це не кнопка «зробити все правильно безкоштовно».
Він може вимагати retry logic.
Може зменшити throughput.
Може створити більше abort/conflict ситуацій.
І різні бази реалізують його по-різному.

Другий підхід — змінити модель даних і додати constraint.

Наприклад, для бронювання кімнати краще мати унікальний індекс:

CREATE UNIQUE INDEX unique_room_booking ON bookings(room_id, booking_date);
Тоді база сама не дозволить два бронювання на один слот.

Для «один active subscription» можна зробити partial unique index, якщо база це підтримує:
CREATE UNIQUE INDEX unique_active_subscription ON subscriptions(user_id) WHERE status = 'ACTIVE';

Це дуже сильний підхід.
Бо ми переносимо інваріант ближче до даних.
Не просто перевіряємо правило в application code.
А змушуємо базу фізично не дозволити некоректний стан.

Третій підхід — явно блокувати спільний ресурс.

Наприклад, якщо правило стосується групи, можна мати окремий рядок-групу і блокувати його:
SELECT * FROM hospital_shifts WHERE shift_id = 123 FOR UPDATE;

Після цього всі зміни лікарів у цій зміні проходять через один locked parent row.
Це зменшує паралельність, але робить правило контрольованим.

Четвертий підхід — advisory locks, якщо база їх підтримує.

Наприклад, можна блокувати логічний ключ:
lock("shift:123")

Це вже більш прикладний механізм, і ним легко зловживати.
Але в деяких сценаріях він зручний, коли треба захистити не один конкретний рядок, а логічну бізнес-сутність.

Read skew: коли ми бачимо суміш різних моментів часу

Є ще одна проблема, яку часто недооцінюють, — read skew.

Вона виникає тоді, коли транзакція або набір запитів читає дані, які логічно мають бути узгодженими між собою, але фактично бачить суміш різних станів.

Уявімо переказ між двома рахунками.

Спочатку:
Account A = 100 Account B = 100 Total = 200

Інша транзакція переказує 50 з A на B:
Account A = 50 Account B = 150 Total = 200

Якщо наш звіт читає A до переказу, а B після переказу, він може побачити:
Account A = 100 Account B = 150 Total = 250

Такого цілісного стану системи ніколи не було.
Але звіт його побачив.

Це особливо небезпечно для:

  • фінансових звітів;
  • reconciliation;
  • risk calculations;
  • аналітичних snapshotʼів;
  • consistency checks;
  • batch processing.

Тут не обовʼязково є dirty read.
Ми можемо читати тільки закомічені дані.
Проблема в тому, що різні частини читання відповідають різним моментам часу.
Саме тому для звітів часто потрібен стабільний snapshot.

Тобто не просто:

«Читай тільки закомічене».

А:

«Читай узгоджену картину бази на певний момент часу».

Serialization anomaly: коли результат неможливо пояснити жодним послідовним порядком

Найзагальніший клас проблем — serialization anomalies.

Простими словами:

Паралельні транзакції дали результат, який неможливо отримати, якщо виконати ці транзакції одну за одною в будь-якому порядку.

Це вже більш формальна тема, але ідея важлива.

Serializable isolation намагається гарантувати саме це:
результат має бути таким, ніби транзакції виконались послідовно.
Не обовʼязково фізично послідовно.
База може виконувати їх паралельно.
Але фінальний результат має бути еквівалентний якомусь serial order.

Наприклад:

  • спочатку A, потім B;
  • або спочатку B, потім A.

Якщо ж результат не відповідає жодному з таких порядків, це serialization anomaly.
Багато складних багів з транзакціями — це саме такі ситуації.
Ми не завжди називаємо їх цим терміном у роботі.

Зазвичай кажемо простіше:

«Дані якось дивно розʼїхались».

Але під капотом часто проблема саме в тому, що паралельне виконання не було еквівалентне коректному послідовному виконанню.

Чому retry — це частина дизайну, а не «костиль»

Коли база виявляє конфлікт, вона не завжди може «розрулити» його автоматично.

Іноді правильне рішення — скасувати одну транзакцію.

Наприклад:

  • через deadlock;
  • через serialization failure;
  • через optimistic locking conflict;
  • через unique constraint violation при конкурентній вставці;
  • через compare-and-set failure.

Для application це виглядає як помилка.

Але не кожна така помилка означає, що система зламалась.

Іноді це нормальна частина concurrency control.

Наприклад:

Transaction A успішно закомітилась.
Transaction B отримала serialization failure.
Application повторила Transaction B.
Transaction B перечитала актуальні дані і успішно завершилась.

У такій моделі retry — це не сором.
Це частина контракту.

Особливо при Serializable, optimistic locking, Redis WATCH, Cassandra lightweight transactions або будь-яких CAS-подібних механізмах.

Але retry має бути продуманим.

Не можна просто безкінечно повторювати будь-яку операцію.

Треба враховувати:

  • чи операція idempotent;
  • чи були зовнішні side effects;
  • чи можна безпечно повторити запит;
  • скільки разів retry допустимий;
  • чи потрібен backoff;
  • що показати користувачу після кількох невдалих спроб.

Наприклад, повторити чистий database update часто можна.
А повторити виклик payment provider без idempotency key — дуже погана ідея.
Тому retry logic має проектуватись разом із транзакційною моделлю.

Коротко

Окрім dirty read, non-repeatable read і phantom read, у реальних системах часто болять інші аномалії:

  • Lost update — одна транзакція затирає результат іншої.
  • Write skew — транзакції змінюють різні рядки, але разом ламають бізнес-правило.
  • Read skew — ми читаємо суміш даних з різних моментів часу.
  • Serialization anomaly — результат паралельного виконання неможливо пояснити жодним послідовним порядком.

Важлива думка:

Не всі проблеми транзакцій лікуються одним isolation level.

Іноді краще допомагають:

  • атомарний UPDATE;
  • WHERE-умова прямо в update;
  • UNIQUE або CHECK constraint;
  • pessimistic locking;
  • optimistic locking;
  • serializable isolation;
  • retry;
  • idempotency;
  • зміна моделі даних.

Далі можна повернутися до isolation levels і вже нормально розібрати, що саме гарантує кожен рівень.
Рівні ізоляції: що насправді гарантує кожен рівень
Тепер, коли ми розібрали основні аномалії, можна повернутися до isolation levels.

Бо самі назви рівнів мало що дають.

Read Committed звучить безпечно.

Repeatable Read звучить ще безпечніше.

Serializable звучить як «максимальний захист».

Але в реальності важливо не те, як рівень називається, а що саме він дозволяє і забороняє.

Почнемо з класичних рівнів:

  • Read Uncommitted;
  • Read Committed;
  • Repeatable Read;
  • Serializable.

І будемо дивитись на них не як на абстрактну таблицю, а як на набір відповідей на практичні питання:

  • чи можна читати незакомічені дані?
  • чи може один і той самий рядок змінитись всередині транзакції?
  • чи можуть зʼявитися нові рядки, які підпадають під уже перевірену умову?
  • чи можна втратити update?
  • чи можна зламати бізнес-інваріант через write skew?
  • чи потрібні retry?
  • як це поводиться в різних базах?

Read Uncommitted: рівень, який майже ніколи не варто використовувати

Read Uncommitted — найслабший рівень ізоляції в класичній SQL-моделі.

Його ідея проста:

Транзакція може бачити зміни інших транзакцій ще до того, як вони зробили COMMIT.

Тобто dirty read тут дозволений.

На практиці це означає, що ви можете прочитати дані, які через секунду будуть відкочені.

Наприклад:

Transaction A                         Transaction B
-------------                         -------------
BEGIN;
UPDATE accounts
SET balance = 0
WHERE id = 1;
                                      BEGIN;
                                      SELECT balance
                                      FROM accounts
                                      WHERE id = 1;
                                      -- бачить 0
ROLLBACK;

Транзакція B побачила 0.

Але після ROLLBACK цього значення в базі більше немає.
З точки зору закінченої історії системи його ніколи не існувало.
Це небезпечно майже для будь-якої бізнес-логіки.

Можна уявити сценарії, де Read Uncommitted використовують для дуже приблизної аналітики або debug-запитів, але для нормального application flow це зазвичай поганий вибір.

Особливо для:

  • фінансових операцій;
  • inventory;
  • прав доступу;
  • workflow-статусів;
  • compliance-рішень;
  • job processing;
  • будь-яких рішень, які потім мають side effects.

Ще один важливий нюанс: не всі бази реально дають dirty reads навіть якщо ви попросите Read Uncommitted.

Наприклад, PostgreSQL приймає цей рівень, але фактично поводиться як Read Committed.
Тобто назва рівня в SQL-команді ще не гарантує, що база реалізує його буквально.
І це перший урок isolation levels:

завжди треба дивитися на документацію конкретної бази, а не тільки на назву рівня.

Read Committed: без dirty read, але без стабільної картини світу

Read Committed — дуже популярний рівень ізоляції.

У PostgreSQL це рівень за замовчуванням.
У багатьох системах саме з ним розробники працюють роками, навіть якщо не думають про це явно.

Його базова гарантія:

Кожен запит бачить тільки ті дані, які були закомічені до початку цього запиту.

Звучить добре.
І це справді сильно краще, ніж Read Uncommitted.
Ви не читаєте незакомічені зміни, які можуть бути відкочені.

Але є важливий нюанс:

у Read Committed кожен окремий statement може бачити свій snapshot.

Тобто два SELECT всередині однієї транзакції можуть побачити різну картину бази.

Наприклад:

Transaction A                         Transaction B
-------------                         -------------
BEGIN;
SELECT balance
FROM accounts
WHERE id = 1;
-- 100
                                      BEGIN;
                                      UPDATE accounts
                                      SET balance = 50
                                      WHERE id = 1;
                                      COMMIT;
SELECT balance
FROM accounts
WHERE id = 1;
-- 50
COMMIT;

Це нормальна поведінка для Read Committed.

Транзакція A не бачила dirty data.
Вона бачила тільки закомічені дані.
Просто між двома її запитами інша транзакція встигла закомітитись.
Для простого CRUD це часто нормально.

Наприклад:

  • прочитати користувача;
  • оновити його last_login;
  • зберегти зміни.

Але для складних сценаріїв це може бути небезпечно.

Наприклад:

  • фінансовий розрахунок з кількох таблиць;
  • reconciliation;
  • перевірка лімітів;
  • формування звіту;
  • складний workflow, де рішення залежить від стабільного набору даних.

У таких випадках Read Committed може дати вам не «цілісну картину системи», а кілька різних картин у межах однієї транзакції.

Read Committed і read-modify-write

Окрема пастка — read-modify-write.

Наприклад:

BEGIN;

SELECT balance
FROM accounts
WHERE id = 1;
-- application рахує новий баланс

UPDATE accounts
SET balance = :newBalance
WHERE id = 1;

COMMIT;

При Read Committed це не автоматично безпечно.

Якщо паралельно інша транзакція теж читає і оновлює той самий рядок, ви можете отримати lost update або конфлікт залежно від конкретної бази, типу UPDATE, locks і умов.

Тому при Read Committed краще мислити так:

не просто: SELECT → calculate in application → UPDATE
а UPDATE ... SET value = value + delta WHERE condition
або UPDATE ... WHERE id = ? AND version = ?
або UPDATE ... WHERE id = ? AND version = ?

Тобто треба або перенести зміну ближче до бази, або явно захистити дані, на основі яких ви приймаєте рішення.

Read Committed — це хороший default для багатьох систем.

Але це не режим «можна не думати про паралельність».

Repeatable Read: стабільніші читання, але не завжди повна безпека

Repeatable Read звучить так, ніби головна гарантія проста:

Якщо я прочитав рядок один раз, то в межах цієї транзакції я побачу його таким самим при наступному читанні.

У класичному поясненні це означає, що non-repeatable read має бути заборонений.

Тобто якщо транзакція прочитала balance = 100, то повторне читання того самого рядка не має раптом показати balance = 50 через commit іншої транзакції.

Але тут починається найцікавіше.

Repeatable Read — один із тих рівнів, де різниця між базами стає дуже помітною.

У PostgreSQL Repeatable Read працює як snapshot isolation.
Транзакція бачить snapshot бази на момент початку транзакції або першого statement, залежно від деталей старту транзакції.

Це означає, що всі читання всередині транзакції бачать стабільну картину.
Якщо інша транзакція закомітить зміни після старту нашої транзакції, ми їх не побачимо в звичайних SELECT.

Наприклад:

Transaction A                         Transaction B
-------------                         -------------
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance
FROM accounts
WHERE id = 1;
-- 100
                                      BEGIN;
                                      UPDATE accounts
                                      SET balance = 50
                                      WHERE id = 1;
                                      COMMIT;
SELECT balance
FROM accounts
WHERE id = 1;
-- все ще 100
COMMIT;

Для читання це дуже зручно.

Звіт, reconciliation або складний розрахунок можуть працювати зі стабільною картиною даних.

Але стабільне читання не означає, що всі бізнес-інваріанти автоматично захищені.
Особливо якщо кілька транзакцій читають однаковий snapshot, а потім оновлюють різні рядки.
Саме тут може зʼявитися write skew.

Repeatable Read у PostgreSQL і MySQL — це не одне й те саме

Це дуже важливий момент.
Якщо розробник звик до PostgreSQL, а потім переходить на MySQL InnoDB, він може очікувати схожої поведінки.
Але Repeatable Read у MySQL InnoDB має свої особливості.

MySQL InnoDB за замовчуванням використовує Repeatable Read.
Для consistent reads він теж дає snapshot-поведінку.
Але для locking reads, updates і range conditions у гру вступають next-key locks і gap locks.

Це означає, що InnoDB може блокувати не тільки конкретні існуючі рядки, а й проміжки між індексними значеннями, щоб не допустити появи phantom rows у певних сценаріях.

Наприклад, якщо транзакція робить range-запит з lock:

SELECT *
FROM bookings
WHERE room_id = 10
  AND booking_date BETWEEN '2026-05-01' AND '2026-05-31'
FOR UPDATE;

InnoDB може заблокувати не тільки знайдені рядки, а й gaps у відповідному індексному діапазоні.
Це може бути корисно для захисту від phantom inserts.

Але це також може здивувати:

  • чому insert в «сусідній» діапазон чекає;
  • чому транзакції блокують більше, ніж здається;
  • чому під навантаженням зʼявився lock contention;
  • чому поведінка відрізняється від PostgreSQL.

Тому фраза «у нас Repeatable Read» сама по собі не достатня.

Треба питати:

«У якій базі? Для яких типів запитів? Це plain SELECT, locking SELECT, UPDATE чи range scan? Які індекси є?»

Serializable: результат як при послідовному виконанні

Serializable — найсильніший класичний рівень ізоляції.

Serializable: Результат паралельного виконання транзакцій має бути таким, ніби ці транзакції виконались одна за одною в якомусь порядку.

Не обовʼязково фізично одна за одною.
База може виконувати їх паралельно.
Але фінальний результат має бути еквівалентний одному з послідовних сценаріїв.

Наприклад:

  • спочатку A, потім B;
  • або спочатку B, потім A.

Якщо результат неможливо пояснити жодним таким порядком, база має не допустити цей результат.
Часто це означає, що одна з транзакцій отримає помилку і application має її повторити.

Наприклад, у сценарії з лікарями:

Transaction A бачить двох лікарів і знімає Анну.

Transaction B бачить двох лікарів і знімає Богдана.

При слабшій ізоляції обидві можуть закомітитись.
При правильному Serializable база має зрозуміти, що разом ці транзакції створюють несеріалізований результат.
Тоді одна з них має впасти з serialization failure.

Application перечитує актуальний стан і бачить:

«На чергуванні залишився тільки один лікар, його вже не можна зняти».

Це звучить ідеально.
Але є ціна.

Serializable не означає «без retry»

Одна з найчастіших помилок:

«Ми поставимо Serializable, і все буде працювати правильно без додаткової логіки.»

Ні.

Serializable часто означає:

База буде активніше виявляти небезпечні конфлікти і скасовувати частину транзакцій.

Тобто application має бути готовий до retry.

Це не bug.

Це частина моделі.

Наприклад:

try transaction
if serialization failure:
    retry transaction

Але retry треба робити обережно.
Безпечний retry можливий тоді, коли транзакція не встигла зробити незворотні зовнішні side effects.

Наприклад, якщо транзакція тільки читала і писала в базу — повторити її зазвичай можна.

Але якщо всередині ви вже викликали payment provider, відправили email або відправили повідомлення в зовнішню систему, retry може створити дублікати.

Тому при сильній ізоляції треба думати не тільки про базу.
Треба думати про межу транзакції в application flow.

Добрий патерн:

  • всередині database transaction змінюємо тільки стан у базі;
  • зовнішні side effects робимо через outbox/event після commit;
  • всі зовнішні операції робимо idempotent;
  • serialization failures і deadlocks обробляємо через контрольований retry.

Тобто Serializable — це сильний інструмент.

Але не магічний режим.

Класична таблиця isolation levels, але з обережністю

Тепер можна повернутися до таблиці.
У спрощеному вигляді вона виглядає так:

Isolation levelDirty readNon-repeatable readPhantom read
Read Uncommittedможливийможливийможливий
Read Committedніможливийможливий
Repeatable Readнінізалежить від БД
Serializableнініні

Але для реального життя я б додав ще одну, чеснішу таблицю:

ПитанняRead CommittedRepeatable Read / SnapshotSerializable
Чи бачу я тільки закомічені дані?тактактак
Чи стабільний snapshot протягом транзакції?зазвичай нізазвичай тактак
Чи можливий lost update?залежить від запиту і БДзалежить від БДзазвичай має бути попереджений або транзакція впаде
Чи можливий write skew?такчасто так при Snapshot Isolationні, якщо Serializable справжній
Чи потрібні retry?інодіінодічасто
Чи можна не думати про locks/constraints?нінітеж ні

Останній рядок найважливіший.

Навіть Serializable не означає, що можна забути про constraints, idempotency, retry і нормальну модель даних.

Він просто дає сильнішу гарантію щодо паралельного виконання транзакцій.

Як вибирати isolation level на практиці

Я б не радив починати з питання:

«Який isolation level найкращий?»

Краще починати з іншого:

«Яку аномалію я не можу дозволити в цьому сценарії?»

Наприклад.

Якщо ви просто читаєте профіль користувача для UI, Read Committed може бути достатньо.
Якщо ви формуєте фінансовий звіт, вам може знадобитися стабільний snapshot.
Якщо ви оновлюєте баланс, краще використовувати атомарний UPDATE з умовою або lock.
Якщо ви перевіряєте унікальність бізнес-правила, краще додати constraint або unique index.
Якщо ви захищаєте інваріант, який залежить від кількох рядків, можливо, потрібен Serializable, explicit lock або зміна моделі даних.
Якщо конфлікти рідкі, optimistic locking може бути кращим.
Якщо конфлікти часті і помилка дорога, pessimistic locking може бути надійнішим.

Тобто isolation level — це тільки один шар.

Повна відповідь часто складається з кількох шарів:

data model
+ constraints
+ transaction boundary
+ isolation level
+ locks
+ retry
+ idempotency
+ monitoring

І саме тому транзакції — це не «поставити правильний рівень ізоляції».
Це дизайн конкурентного доступу до даних.

Коротко

Рівні ізоляції — це спосіб керувати тим, як транзакції бачать одна одну.

  • Read Uncommitted дозволяє dirty reads і майже ніколи не потрібен у нормальній бізнес-логіці.
  • Read Committed читає тільки закомічені дані, але не гарантує стабільний snapshot на всю транзакцію.
  • Repeatable Read дає стабільніші читання, але його поведінка сильно залежить від конкретної бази.
  • Serializable прагне до результату, еквівалентного послідовному виконанню, але часто вимагає retry.

Головна пастка:

Однакова назва isolation level не означає однакову поведінку в різних базах даних.

Що буде в другій частині

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

паралельність.

Ми почнемо з Repeatable Read vs Snapshot Isolation.
Бо саме тут багато розробників плутаються.
Repeatable Read звучить як просте правило:

«Я прочитав рядок один раз — і далі бачу його так само».

Але в реальних базах все складніше.

У PostgreSQL Repeatable Read фактично працює як snapshot isolation.
У MySQL InnoDB Repeatable Read має іншу поведінку через locking reads, next-key locks і gap locks.
У SQL Server поведінка Read Committed може змінюватися залежно від READ_COMMITTED_SNAPSHOT.
У Oracle read consistency побудована навколо undo і snapshot-моделі.
А Redis, MongoDB, Cassandra чи Neo4j взагалі змушують думати про транзакції в інших термінах.

Тому в другій частині розберемо:

  • Repeatable Read vs Snapshot Isolation;
  • MVCC;
  • lost update;
  • write skew;
  • optimistic locking;
  • pessimistic locking;
  • SELECT FOR UPDATE;
  • deadlocks;
  • savepoints;
  • retry strategy;
  • і те, чому однакові назви isolation levels у різних базах не означають однакову поведінку.

Це буде вже не вступ до транзакцій, а практичний розбір того, що відбувається, коли кілька транзакцій одночасно працюють з одними й тими самими даними.

Висновок

Транзакції часто здаються простішими, ніж вони є насправді.

Ми бачимо:

BEGIN;
-- do something
COMMIT;

і думаємо, що цього достатньо.

Але реальна складність починається не там, де один запит падає.
Вона починається там, де кілька правильних операцій виконуються одночасно.

Одна транзакція читає старий стан.

Інша встигає змінити рядок.

Третя вставляє новий запис, який підпадає під уже перевірену умову.

Четверта проходить бізнес-перевірку, яка через секунду вже не буде правдивою.

Пʼята нічого не змінює, але тримає старий snapshot і заважає базі очищати дані.

І все це може відбуватися без очевидної помилки в коді.
Саме тому транзакції треба розуміти не як синтаксис, а як набір гарантій і компромісів.

Не:

«Я використовую транзакцію, значить усе безпечно».

А:

«Яку саме гарантію я отримую?»

«Яку аномалію я хочу заборонити?»

«Що моя база реально робить на цьому isolation level?»

«Що має бути захищено constraintʼом?»

«Де потрібен lock?»

«Де потрібен retry?»

«А де краще взагалі змінити модель даних?»

ACID дає важливий фундамент.
BASE показує, що не кожна частина системи потребує однаково сильної консистентності.
Isolation levels дозволяють керувати тим, як транзакції бачать одна одну.
Але жоден із цих термінів не замінює головного питання:

Які гарантії потрібні саме цій операції?

Для вподобайок, рекомендацій, кешів, search index або аналітики eventual consistency може бути нормальною.
Для балансів, payout, compliance-рішень, permissions і критичних workflow — часто ні.
Тому хороша транзакційна модель починається не з вибору рівня ізоляції.

Вона починається з розуміння:

де проходить consistency boundary;

які бізнес-інваріанти не можна порушити;

які помилки можна компенсувати;

які операції можна повторити;

і що саме ваша база гарантує насправді.

У першій частині ми заклали фундамент.

У другій — підемо глибше в concurrency, MVCC, locks і реальну поведінку різних баз даних.

👍ПодобаєтьсяСподобалось16
До обраногоВ обраному9
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

когда то, очень давно, еще в фидо, была такая Лиля Хафф
Человек, лучше которого в транзакциях и всём что с этим связано, не разбирался никто
И мало того — Лиля могла всё это пояснить простым доступным языком.
За всё время я ни разу не встречал человека круче неё в плане транзакций.

Есть архив с её письмами?

увы. разве что в архивах эхи поискать
и (ненадолго) она заскакивала на скуль ру, но это было крайне мимолётно и очень давно.

Валентин, як розуміння рівнів ізоляції допомогло вам у якомусь конкретному випадку на вашому проекті? Ну от щоб якась фіча коректно працювала лише з рівнем ізоляції, відмінним від дефолтного в БД.

Дивно бачити зміни балансу у ACID транзакціі, бо людство давно все збагнуло і фінтек працює на event-sourcing як правило

Хороший материал и тема, есть удачные примеры «на пальцах», полезно и для новичков да и для не новичков тоже. Но все таки, после генерации аи-шкой, стоит перечитывать и редактировать. Повторы по три-четыре раза одного и того же. Ну и видно же что, как минимум, некоторые куски сгенерированы Chat GPT, и просто вставлены как есть, без учета того что уже написано выше.

В цілому сподобалось, але легше було б читати кілька вдвічі коротших статей.

Непогано. На перший погляд. Але є деякі неточності.

Які? Я завжди відкритий до конструктивної критики)

Чудова стаття!

Круто розписано типові проблеми, про які багато хто знає, але не всі можуть пояснити :-)
Окремо дякую, що вийшли за рамки «класичної» таблиці аномалії. Бо таблиця наче є, а на практиці купа кейсів, які таблиця не покриває.
Особливо стосовно Race condition та як з цим боротися.

Вау, технічна стаття на ДОУ.

Тема потрібна і важлива, але як її розкривати — я би особисто не взявся.
Якщо коротко і тезисно — буде малозрозуміло, якщо детально — вийде підручник з CS.

Слушно зауважено, що транзакція — це більше широке поняття, ніж декілька операції з БД, що виконуються атомарно, і доречно згадано, що в різних БД дещо своє розуміння класичних isolation levels.

Наприклад, чи гарантує JOIN захист від Read Skew? Відповідь, як дізнався з деяким подивом, depends on.

Питання до автора та спільноти.
«Дякуючи» домінуванню Spring Data JPA на протязі останніх десяти чи скільки там років — це не критика Hibernate, я навпаки полюбляю цей фреймворк, але Data JPA != Hibernate, хоча і побудований поверх нього — середньостатистичний розробник не знає про транзакції приблизно нічого. Навіть, що вони взагалі є. І пише приблизно так.

void pay(Long userId, BigDecimal amount) {
   var account = accountRepository.findByUserId(userId);
   if (account.getBalance().compareTo(amount) >= 0) {
       httpPaymentGatewayClient.pay(amount);
       
       account.setBalance(account.getBalance().subtract(amount));
       accountRepository.save(account);
   } else {
      throw new IllegalStateException("Not enough money");
   } 
}
До певних меж це працює нормально. А згодом починаються оті згадані проблеми — баланси не сходяться. Причому вони не вирішуються наявністю чи відсутністю анотації @Transactional.

Де шановний автор та компанія, в який він працює, взагалі знаходять розробників, які здатні розуміти, що не так з наведеним вище кодом?

Напрочуд гарна в технічному плані стаття. Тут рідко таку зустрінеш. Є що перечитати разів три.

Щодо сусідніх коментарів: про AI не згоден, тут якщо він і був, то дуже частково і локально. Як автор статей я бачу багато слідів «ручного» написання, повторів думки, і все таке, але це не суцільна творчість якоїсь LLM. Саме людська редактура тут «підвела»: деякі тези повторюються разів по три по-різному.

Головне ж, що зміст показує реальне розуміння теми, а не копіювання підручників без реального досвіду.

У мене ж такі зауваження:

1. Як вже сказав, багато повторів. Виглядає, наче структурної редактури майже не було. Чи це навмисний елемент стилю. Але структура заплутує.

2. Змістове заплутування — наприклад, я знав, що Serializable за цільовим розумінням цього терміну це сильніше, ніж Serializable за ISO, і про write skew, але навіть при цьому був не впевнений, що це гарно написано в статті. Її дійсно треба перечитати разів три, щоб зрозуміти такі деталі.

Можливо, це тут і неминуче. Саме про цю тему я не писав:)

3. При такому обʼємі краще було б таки розбити щонайменше на дві частини і публікувати окремо. Навіть при навичці читати довге — тут матеріалу більше, ніж зазвичай норма для такого сайту. Це і щодо повторень: вони краще б пішли, якщо частина була б введенням в контекст у другій частині.

4. Логіка банківських операцій зараз може бути не зовсім ACID. Особливо це має місце при карткових розрахунках. Банк може не показувати, але декілька днів транзакції знаходятся в напів-підвішеному стані, коли ще невідомо, чим вони закінчаться. У мене регулярно проплата через сервіс заказу в продовольчому магазині частково повертається, і тоді банк показує фінальну операцію без тих подробиць, що є в початковому знятті — тобто вони практично скасували першу операцію і замінили її відкоректованою, а опис не доробили. Або вони ж іноді знімали на деякі операції суму аж двічі, потім друге зняття відкочували. І навіть некартові перекази мають ще бути підтверджені банком-отримувачем. Фактично це все BASE з якимись таймаутами в дні.

Не знаю, як краще ввернути це уточнення в статтю, але я б так різко не розділяв.

Я дуже хотів зробити пояснення більш зрозумілим, тому часом тези повторюються, але схоже це мене і погубило)

Я планував зробити одну статтю по транзакціям, але коли написав це все зрозумів, що треба ділити, бо то ще навіть не половина)

По тексту, я ще можу відредагувати, певно трохи поспішив з постингом, дякую за зауваження, підкоректою обовʼязково!

AI работал во всю. Много воды и повторений.
Тема интересная, классическая, но преподнесено в не совсем удачной форме

можна було-б зробити у вигляді коміксу з сіськами і котиками.

О, то чекаю від вас адаптацію статті в комікс, і читабельність підніметься і картіночки цікаві можуть бути)

можна було-б зробити у вигляді коміксу

Есть такой manga guide серия, там и про БД есть
у меня такая есть :)

www.ohmsha.co.jp/english/manga.htm

Не зміг осилити до кінця, після декількох повторень, таке відчуття ніби те що на генерував АІ навіть не рев’ювали

А що саме вам не зрозуміло?

раздел «Класична таблиця isolation levels, але з обережністю» вот прямо в чистом виде ответ чатгпт на вопрос explain isolation levels

Підписатись на коментарі