Рівні ізоляції, блокування і конкурентність
Зміст
Уяви двох касирів, які одночасно продають той самий останній квиток. Обидва бачать «1 у наявності», обидва проводять оплату, і в залі опиняються два власники одного місця. Це не помилка в SQL — це конкурентність (concurrency): кілька транзакцій торкаються тих самих даних в одну мить. База даних дає інструменти, щоб такі колізії не псували дані, і водночас дозволяє свідомо послабити суворість заради швидкодії. Уся ця глава — про той компроміс.
Для QA тема з подвійним дном. По-перше, серед найдорожчих і найважчих для відтворення продакшн-багів — саме конкурентні: подвійне списання, від'ємний залишок, «зникле» оновлення профілю. Вони не ловляться сценарієм «клік за кліком», бо виникають лише коли двоє діють одночасно. По-друге, на співбесіді senior-рівня рівні ізоляції — стандартне питання: інтерв'юер хоче почути, що ти розумієш, звідки береться флак (flaky) в інтеграційних тестах і як відтворити гонку навмисно. Ця глава спирається на попередню — Транзакції та ACID: літера «I» (isolation) якраз про те, наскільки паралельні транзакції бачать одна одну. Тут ми розкриваємо, що за нею ховається.
Чотири аномалії конкурентного доступу
Стандарт SQL описує рівні ізоляції не через механізми, а через аномалії — небажані ефекти, які транзакція може побачити через дії сусідів. Розуміти треба саме аномалії: рівні — це вже наслідок «які з них ми готові терпіти». Уяви таблицю accounts з колонкою balance і поглянь на чотири класичні збої.
Брудне читання (dirty read). Транзакція A зменшує баланс зі 100 до 50, але ще не зробила COMMIT. Транзакція B читає 50 і приймає рішення на його основі. Потім A відкочується (ROLLBACK) — балансу 50 не існувало ніколи. B діяла за даними-примарою.
Неповторюване читання (non-repeatable read). B читає баланс — 100. За мить A оновлює його до 50 і робить COMMIT. B читає той самий рядок удруге в межах своєї транзакції — і бачить 50. Один рядок, два різні значення в одній транзакції; звіт, що двічі звернувся до тих самих даних, суперечить сам собі.
Фантомне читання (phantom read). B рахує SELECT count(*) FROM orders WHERE status = 'new' — 10 рядків. A вставляє новий рядок зі status = 'new' і комітить. B повторює той самий запит — 11 рядків. Змінилося не значення в рядку, а склад вибірки: з'явився «фантом», якого не було мить тому.
Втрачене оновлення (lost update). Найпідступніше, бо ламає дані без жодного ROLLBACK. Дві транзакції читають баланс 100, кожна рахує нове значення у застосунку і записує його:
Обидві транзакції завершилися успішно, помилки не було — а гроші «намалювалися». Це і є lost update: запис однієї транзакції затирає запис іншої, бо обидві виходили зі застарілого прочитаного значення.
Чотири рівні ізоляції
Стандарт SQL визначає чотири рівні за тим, які аномалії на кожному дозволені. Що вищий рівень — то менше аномалій, але й то більше блокувань, відкатів і черг; конкурентність падає. Це прямий обмін строгості на пропускну здатність.
| Рівень ізоляції | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| READ UNCOMMITTED | можливе | можливе | можливе |
| READ COMMITTED | ні | можливе | можливе |
| REPEATABLE READ | ні | ні | можливе |
| SERIALIZABLE | ні | ні | ні |
SERIALIZABLE — золотий стандарт: результат гарантовано такий, ніби транзакції виконувалися по черзі, одна за одною, без жодного перекриття. За це платиш продуктивністю. READ COMMITTED — прагматична середина: бачиш лише закомічене, але між двома читаннями світ може змінитися. Зверни увагу: стандартна таблиця не згадує lost update окремо — це аномалія з класичної критики стандарту (Berenson та ін., 1995), і різні СУБД борються з нею по-різному, про що нижче.
Дефолти PostgreSQL і MySQL
Тут ховається пастка, на якій горять і розробники, і QA: однаковий SQL поводиться по-різному залежно від СУБД і рівня ізоляції за замовчуванням.
| PostgreSQL | MySQL (InnoDB) | |
|---|---|---|
| Рівень за замовчуванням | READ COMMITTED | REPEATABLE READ |
| READ UNCOMMITTED | немає (працює як READ COMMITTED, брудних читань не буває взагалі) | справжній, брудні читання можливі |
| Як реалізовано REPEATABLE READ | знімок (snapshot) — прибирає й фантоми теж | знімок для звичайних SELECT + next-key locks для блокувальних читань |
Кілька наслідків, які варто тримати в голові:
- PostgreSQL строгіший, ніж вимагає стандарт. Його
REPEATABLE READпобудований на знімку транзакції й прибирає навіть фантомні читання. Але за це на конфлікті він кидає помилкуcould not serialize access due to concurrent update(SQLSTATE 40001) — транзакцію треба повторити. Так Postgres і рятує від lost update: перемагає той, хто закомітив першим, другий отримує помилку замість тихого затирання. - MySQL за замовчуванням «вище». InnoDB стартує з
REPEATABLE READ: звичайнийSELECTбачить знімок від першого читання в транзакції, а блокувальні операції (UPDATE,DELETE,SELECT ... FOR UPDATE) читають актуальну версію й беруть next-key locks, які закривають більшість фантомів. - Тест має ганяти той самий рушій і рівень, що й прод. Якщо локально ти на SQLite чи на Postgres із дефолтом, а прод — MySQL на
REPEATABLE READ, конкурентний баг може відтворюватися лише в одному з середовищ. Це класична причина «на стейджі зелено, на проді валиться».
Одне речення глибини для senior: навіть знімкова ізоляція (REPEATABLE READ у Postgres) не ловить write skew — коли дві транзакції читають той самий набір, а пишуть у різні рядки, порушуючи спільний інваріант. Від цього рятує лише SERIALIZABLE.
MVCC на пальцях
Звідки Postgres і MySQL беруть «знімок» минулого? З механізму MVCC (Multi-Version Concurrency Control — багатоверсійне керування конкурентністю). Ідея проста: коли транзакція оновлює рядок, база не затирає старе значення, а створює нову версію рядка поряд. Стара версія живе, доки її ще хтось може читати.
Наслідок, який варто завчити дослівно: читачі не блокують письменників, письменники не блокують читачів. Поки транзакція A переписує рядок (створює нову версію), транзакція B спокійно читає стару — їй не треба чекати. Кожна транзакція бачить узгоджений знімок бази на певний момент, ніби фотографію, і зміни сусідів на цій фотографії не проявляються.
- У PostgreSQL кожен рядок має службові позначки
xmin/xmax— id транзакцій, що його створили й «застарили». Старі, вже нікому не потрібні версії — це «мертві» рядки, які згодом прибираєVACUUM. - У MySQL InnoDB старі версії зберігаються в undo-логах, а «знімок» реалізований через read view.
Для QA MVCC пояснює дві на позір дивні речі. Перша: довга транзакція, що просто читає, може роздувати базу мертвими версіями — бо VACUUM не має права прибрати те, що вона ще може побачити. Друга: два клієнти в один і той самий момент часу бачать різні дані — і обидва праві, просто дивляться на різні знімки.
Блокування рядків: спільні, ексклюзивні, deadlock
MVCC знімає конфлікт «читач проти письменника», але конфлікт «письменник проти письменника» лишається: два UPDATE того самого рядка одночасно неможливі. Тут вмикаються блокування (locks).
Два базові режими на рядок:
- Спільне (shared, S) — «я читаю, не чіпайте на запис». Кілька транзакцій можуть тримати S-блокування на одному рядку одночасно. Береться явно через
SELECT ... FOR SHARE. - Ексклюзивне (exclusive, X) — «я змінюю, стійте всі». Тільки одне на рядок; блокує і інші X, і S. Береться автоматично при
UPDATE/DELETEі явно черезSELECT ... FOR UPDATE.
Саме SELECT ... FOR UPDATE — правильні ліки від lost update у патерні «прочитай, порахуй, запиши»: транзакція A блокує рядок ексклюзивно ще на читанні, тож B чекає й читає вже оновлене значення, а не застаріле. Альтернатива — атомарний UPDATE balance = balance - 30 одним виразом, де база сама перечитує актуальне значення під блокуванням.
Плата за блокування — взаємне блокування (deadlock). Дві транзакції захопили по рядку й кожна чекає на рядок, який тримає інша:
СУБД сама виявляє цикл очікування й примусово відкочує одну з транзакцій, звільняючи іншу. Помилки дослівні й впізнавані: PostgreSQL кидає deadlock detected, MySQL — Deadlock found when trying to get lock; try restarting transaction (код 1213). Deadlock — не завжди баг: правильна реакція застосунку на нього — повторити транзакцію. Тому, побачивши такі помилки в логах під навантаженням, спершу перевір, чи є в коді ретрай, і лише потім заводь дефект.
Як ловити гонки тестами
Головна складність конкурентних багів: звичайний послідовний тест їх не бачить. POST /orders, потім GET /orders, перевірка — зелено, бо в один момент часу діяв один клієнт. Гонка виникає тільки за одночасності, тож її треба створити навмисно: вистрілити кількома запитами паралельно й перевірити інваріант, який має вціліти за будь-якого порядку.
У Playwright/TypeScript це Promise.all над кількома API-запитами до одного ресурсу:
import { test, expect } from '@playwright/test';
// Гонка: 20 паралельних спроб купити останній товар
test('останній товар не продається двічі', async ({ playwright }) => {
const api = await playwright.request.newContext({ baseURL: process.env.BASE_URL });
await api.post('/api/inventory/reset', { data: { itemId: 'X', stock: 1 } });
const attempts = Array.from({ length: 20 }, () =>
api.post('/api/orders', { data: { itemId: 'X', qty: 1 } }),
);
const responses = await Promise.all(attempts);
const created = responses.filter((r) => r.status() === 201);
expect(created).toHaveLength(1); // рівно одна купівля успішна
const stock = await (await api.get('/api/inventory/X')).json();
expect(stock.available).toBe(0); // залишок не пішов у мінус
});
Що тут важливо для чесного тесту гонок:
- Перевіряй інваріант, а не конкретний порядок. Не «перший запит виграв», а «виграв рівно один» і «залишок не від'ємний». Хто саме переміг — недетерміновано, і закладатися на це не можна.
- Гонка — імовірнісна. Один зелений прогін не доводить відсутності бага: колізія могла просто не збігтися в часі. Тому підвищуй конкурентність (більше паралельних запитів), повторюй тест у циклі, а якщо застосунок має тестові хуки із затримкою — використовуй їх, щоб розширити «вікно» гонки.
- Не плутай гонку в застосунку з флаком у тесті. Якщо тест то падає, то ні через
sleep-и й невдалу синхронізацію самого тесту — це його проблема (канон флакі-тестів — у розділі про стратегію автоматизації). А от нестабільний результат саме на інваріанті даних — це вже сигнал реального конкурентного бага в продукті. - Той самий підхід — на рівні API-ідемпотентності. Паралельні повтори одного запиту з
Idempotency-Keyмають дати один результат, а не N списань — це суміжна перевірка з розділу про API-тестування.
Типові помилки
Виглядає як баг застосунку, а насправді дефолт СУБД. Тест на Postgres проходить, на MySQL — ні (або навпаки). Причина не в коді, а в різних рівнях ізоляції за замовчуванням: READ COMMITTED проти REPEATABLE READ. Спершу звір рушій і рівень зі стейджем/продом.
Виглядає як «база загубила оновлення», а насправді lost update у застосунку. Дані зіпсувалися без жодної помилки, бо код робив «прочитай у застосунок → порахуй → запиши» без SELECT ... FOR UPDATE чи атомарного UPDATE. База відпрацювала чесно — інваріант порушив патерн доступу.
Виглядає як зависання, а насправді черга за блокуванням. Запит «висить» не тому, що повільний, а тому, що чекає на рядок, який тримає інша відкрита транзакція. Часто винний — забутий незакритий BEGIN у сусідній сесії або в клієнті на кшталт DBeaver з вимкненим autocommit.
Виглядає як no-cache баг, а насправді MVCC-знімок. Два клієнти в один момент бачать різні дані й обидва «праві»: кожен читає власний знімок транзакції. Це не розсинхрон кешу — це нормальна робота багатоверсійності.
Deadlock у логах сприймають як критичний збій. Насправді це очікуваний під навантаженням ефект; правильна реакція — ретрай транзакції. Баг тут — відсутність ретраю, а не сам deadlock.
Підсумок
- Рівні ізоляції визначають, які аномалії дозволені: dirty read, non-repeatable read, phantom read; вище — строгіше, але повільніше й з більшою кількістю відкатів.
- Дефолти різні й це джерело багів: PostgreSQL —
READ COMMITTED, MySQL/InnoDB —REPEATABLE READ; тестуй на тому ж рушії й рівні, що й прод. - MVCC означає «читачі не блокують письменників»: кожна транзакція бачить власний узгоджений знімок, а старі версії рядків живуть, доки їх хтось може читати.
- Lost update не ловиться сам собою на низьких рівнях — його закриває
SELECT ... FOR UPDATE, атомарнийUPDATE ... = ... - Nабо серіалізаційна помилка на високому рівні. - Конкурентні баги ловляться лише навмисною одночасністю (
Promise.all) з перевіркою інваріанта, і навіть тоді результат імовірнісний — зелений прогін не доводить відсутності гонки.
Що питають на співбесіді
- «Назвіть рівні ізоляції та які аномалії кожен допускає.» Очікують не зазубрену таблицю, а розуміння логіки «вище рівень — менше аномалій, менше конкурентності». Сильний кандидат ілюструє кожну аномалію коротким прикладом на балансі рахунку.
- «Який рівень ізоляції за замовчуванням у PostgreSQL і MySQL?» Правильно:
READ COMMITTEDіREPEATABLE READвідповідно. Інтерв'юер перевіряє, чи ти взагалі стикався з відмінностями рушіїв, а не читав абстрактний стандарт. - «Що таке lost update і як його не допустити?» Дивляться, чи відрізняєш його від dirty read і чи знаєш ліки:
SELECT ... FOR UPDATE, атомарнийUPDATE, оптимістичне блокування через версію рядка. - «Поясніть MVCC своїми словами.» Ключова фраза, яку хочуть почути: «читачі не блокують письменників». Плюс розуміння, що звідси беруться мертві версії й потреба у
VACUUM. - «Як би ви написали тест на race condition?» Перевіряють інженерну зрілість: паралельні запити, перевірка інваріанта (а не конкретного переможця), усвідомлення імовірнісної природи — і чесне «один зелений прогін нічого не доводить».
Джерела
- PostgreSQL: Transaction Isolation — рівні ізоляції, знімкова поведінка, серіалізаційні помилки.
- PostgreSQL: Explicit Locking — режими блокувань рядків і таблиць,
FOR UPDATE/FOR SHARE. - MySQL: InnoDB Transaction Isolation Levels — дефолт
REPEATABLE READ, consistent read і next-key locks. - MySQL: InnoDB Locking — типи блокувань рядків: shared/exclusive, record, gap і next-key locks.
- MySQL: Deadlocks in InnoDB — що таке deadlock, автоматична детекція й відкат транзакції-жертви.
- Рівні ізоляції формально задає стандарт ISO/IEC 9075 (SQL); класичний розбір їх нюансів — стаття «A Critique of ANSI SQL Isolation Levels» (Berenson та ін., 1995).
Що таке конкурентність (concurrency) і чому це болюча тема саме для QA?
Конкурентність — це ситуація, коли кілька транзакцій торкаються тих самих даних в одну мить. Класичний приклад: двоє касирів одночасно продають той самий останній квиток, обидва бачать «1 у наявності» й обидва проводять оплату. База даних дає інструменти, щоб такі колізії не псували дані, і водночас дозволяє свідомо послабити суворість заради швидкодії. Для QA тема з подвійним дном: по-перше, серед найдорожчих і найважчих для відтворення продакшн-багів — саме конкурентні (подвійне списання, від'ємний залишок, «зникле» оновлення профілю); по-друге, на senior-співбесіді рівні ізоляції — стандартне питання. Головна складність у тому, що ці баги не ловляться сценарієм «клік за кліком»: вони виникають лише коли двоє діють одночасно.
Назвіть аномалії конкурентного доступу.
Класичних аномалій конкурентного доступу чотири — це небажані ефекти, які транзакція може побачити через дії сусідів; стандарт SQL описує через перші три з них рівні ізоляції. Брудне читання (dirty read) — транзакція бачить чужі незакомічені зміни, які потім відкотяться. Неповторюване читання (non-repeatable read) — той самий рядок, прочитаний двічі в одній транзакції, дає різні значення, бо між читаннями його змінили й закомітили. Фантомне читання (phantom read) — повторний той самий запит повертає інший склад вибірки: з'явився або зник рядок, що підпадає під умову. Окремо стоїть втрачене оновлення (lost update) — це аномалія з класичної критики стандарту (Berenson та ін., 1995): запис однієї транзакції тихо затирає запис іншої. Розуміти треба саме аномалії, бо рівні ізоляції визначаються через них: рівень — це вже наслідок «які з них ми готові терпіти».
Чим брудне читання відрізняється від неповторюваного?
Різниця в тому, чи були зміни закомічені. При брудному читанні транзакція B читає значення, яке транзакція A ще не закомітила: A зменшила баланс зі 100 до 50 без COMMIT, B прочитала 50 і прийняла на ньому рішення, потім A зробила ROLLBACK — балансу 50 не існувало ніколи, B діяла за даними-примарою. При неповторюваному читанні всі зміни цілком легальні й закомічені: B читає баланс 100, за мить A оновлює його до 50 і робить COMMIT, B читає той самий рядок удруге й бачить 50. Тобто брудне читання — про читання «сміття», яке зникне, а неповторюване — про те, що навіть закомічена правда може змінитися посеред твоєї транзакції. Практичний наслідок: звіт, що двічі звернувся до тих самих даних, суперечить сам собі, хоча жодна транзакція не зробила нічого незаконного.
Що таке фантомне читання і чим воно відрізняється від неповторюваного?
Фантомне читання — це коли повторний запит з тією самою умовою повертає інший набір рядків, бо сусідня транзакція вставила або видалила рядок, що підпадає під фільтр. Приклад: B рахує SELECT count(*) FROM orders WHERE status = 'new' і бачить 10, потім A вставляє новий рядок зі status = 'new' і комітить, B повторює той самий запит і бачить 11. Ключова відмінність від неповторюваного читання: там змінюється значення в наявному рядку, а тут — склад вибірки, з'являється «фантом», якого не було мить тому. Саме тому фантоми чіпляються за запити з діапазоном чи предикатом (WHERE, count, агрегати), а не за читання конкретного рядка за id. Від них рятує рівень SERIALIZABLE, а в PostgreSQL — навіть знімковий REPEATABLE READ.
Назвіть рівні ізоляції та які аномалії кожен допускає.
Стандарт SQL визначає чотири рівні за тим, які аномалії на кожному дозволені. READ UNCOMMITTED — найслабший, допускає все, включно з брудним читанням. READ COMMITTED — прибирає брудні читання (бачиш лише закомічене), але допускає неповторюване й фантомне. REPEATABLE READ — додатково прибирає неповторюване читання, лишаючи можливими фантоми (за стандартом). SERIALIZABLE — золотий стандарт: результат гарантовано такий, ніби транзакції виконувалися по черзі, одна за одною, без жодного перекриття. Логіка проста: що вищий рівень — то менше аномалій, але й то більше блокувань, відкатів і черг, тобто конкурентність падає. Це прямий обмін строгості на пропускну здатність, і сильний кандидат ілюструє кожну аномалію коротким прикладом на балансі рахунку, а не переказує зазубрену таблицю.
Що таке втрачене оновлення (lost update) і чому воно найпідступніше?
Втрачене оновлення — це коли дві транзакції читають одне значення, кожна рахує нове у застосунку й записує, і другий запис затирає перший. Обидві транзакції читають баланс 100: A рахує 100 - 50 = 50 і комітить, B рахує 100 - 30 = 70 і комітить. Списання A зникло — мало бути 20, а стало 70. Найпідступніше воно тому, що ламає дані без жодного ROLLBACK і без помилки: обидві транзакції завершилися успішно, а гроші «намалювалися». Корінь проблеми — патерн «прочитай у застосунок → порахуй → запиши», де обидві сторони виходять зі застарілого прочитаного значення. Саме тому lost update плутають із «база загубила оновлення», хоча база відпрацювала чесно — інваріант порушив патерн доступу в коді.
Як не допустити lost update?
Є три робочі ліки, і кандидат має знати щонайменше два. Перший — песимістичне блокування: SELECT ... FOR UPDATE блокує рядок ексклюзивно ще на читанні, тож друга транзакція чекає й читає вже оновлене значення, а не застаріле. Другий — атомарний вираз: замість «прочитати-порахувати-записати» зробити UPDATE balance = balance - 30 одним запитом, де база сама перечитує актуальне значення під блокуванням. Третій — оптимістичне блокування через версію рядка: додати колонку version, оновлювати з умовою WHERE version = <прочитане> і на нуль оновлених рядків повторити транзакцію. На високому рівні ізоляції рятує ще серіалізаційна помилка: PostgreSQL на конфлікті кидає could not serialize access (SQLSTATE 40001), і той, хто пише другим, отримує помилку замість тихого затирання. Вибір між песимістичним і оптимістичним підходом залежить від очікуваної частоти конфліктів: часті конфлікти — блокуй, рідкісні — версіонуй.
Який рівень ізоляції за замовчуванням у PostgreSQL і MySQL?
PostgreSQL за замовчуванням працює на READ COMMITTED, MySQL (InnoDB) — на REPEATABLE READ. Це не педантизм, а джерело реальних багів: однаковий SQL поводиться по-різному залежно від рушія й рівня за замовчуванням. Причому PostgreSQL строгіший, ніж вимагає стандарт: його REPEATABLE READ побудований на знімку транзакції й прибирає навіть фантомні читання, а не лише неповторювані. Інтерв'юер цим питанням перевіряє, чи ти взагалі стикався з відмінностями рушіїв, а не читав абстрактний стандарт. Практичний висновок: тест має ганяти той самий рушій і рівень, що й прод, інакше конкурентний баг може відтворюватися лише в одному із середовищ.
Чому однаковий SQL може по-різному поводитися на Postgres і MySQL?
Бо в них різні рівні ізоляції за замовчуванням і різна реалізація цих рівнів. PostgreSQL стартує з READ COMMITTED, де між двома читаннями світ може змінитися; MySQL/InnoDB — з REPEATABLE READ, де звичайний SELECT бачить знімок від першого читання в транзакції. Ба більше, READ UNCOMMITTED у Postgres просто працює як READ COMMITTED (брудних читань не буває взагалі), а в MySQL це справжній рівень із можливими брудними читаннями. Тому сценарій «на стейджі зелено, на проді валиться» часто пояснюється не кодом, а тим, що локально ти на SQLite чи Postgres із дефолтом, а прод — MySQL на REPEATABLE READ. Перше, що робить QA, побачивши плаваючий конкурентний баг між середовищами — звіряє рушій і рівень ізоляції, а не лізе в код застосунку.
Поясніть MVCC своїми словами.
MVCC (Multi-Version Concurrency Control — багатоверсійне керування конкурентністю) — це механізм, завдяки якому база дає кожній транзакції узгоджений «знімок» даних без глобального блокування на читання. Ідея проста: коли транзакція оновлює рядок, база не затирає старе значення, а створює нову версію рядка поряд, і стара версія живе, доки її ще хтось може читати. Ключова фраза, яку хочуть почути на співбесіді: читачі не блокують письменників, письменники не блокують читачів. У PostgreSQL кожен рядок має службові позначки xmin/xmax (id транзакцій, що його створили й застарили), а непотрібні старі версії — це «мертві» рядки, які згодом прибирає VACUUM; у MySQL InnoDB старі версії лежать в undo-логах, а знімок реалізований через read view. Для QA MVCC пояснює дві дивні на позір речі: довга транзакція-читач роздуває базу мертвими версіями (бо VACUUM не має права прибрати те, що вона ще бачить), і два клієнти в один момент часу бачать різні дані — обидва праві, просто дивляться на різні знімки.
Що означає фраза «читачі не блокують письменників»?
Це головний наслідок MVCC: читання й запис того самого рядка не стають у чергу один за одним. Поки транзакція A переписує рядок (створює нову версію), транзакція B спокійно читає стару версію — їй не треба чекати завершення A. Кожна транзакція бачить узгоджений знімок бази на певний момент, ніби фотографію, і зміни сусідів на цій фотографії не проявляються. Це різко підвищує пропускну здатність порівняно зі старою схемою «читання бере блокування», де звіт міг заморозити півсистеми. Але важливо розуміти межу: MVCC знімає лише конфлікт «читач проти письменника»; конфлікт «письменник проти письменника» лишається, бо два UPDATE того самого рядка одночасно неможливі — там уже вмикаються блокування.
Чим відрізняються спільне (S) та ексклюзивне (X) блокування рядка?
Спільне блокування (shared, S) означає «я читаю, не чіпайте на запис»: кілька транзакцій можуть тримати S на одному рядку одночасно, воно береться явно через SELECT ... FOR SHARE. Ексклюзивне (exclusive, X) означає «я змінюю, стійте всі»: тільки одне на рядок, воно блокує і інші X, і S, береться автоматично при UPDATE/DELETE і явно через SELECT ... FOR UPDATE. Простими словами: S-блокування сумісні між собою (багато читачів), X-блокування ексклюзивне (один письменник, який блокує всіх). Ця сумісність і визначає, хто чекатиме в черзі: два SELECT ... FOR SHARE пройдуть паралельно, а SELECT ... FOR UPDATE на вже заблокованому на запис рядку зачекає. На практиці QA стикається з X-блокуваннями частіше, бо саме вони стоять за симптомом «запит завис» — насправді він чекає на рядок, який тримає інша відкрита транзакція.
Навіщо потрібен SELECT ... FOR UPDATE?
Це правильні ліки від lost update у патерні «прочитай, порахуй, запиши». Звичайний SELECT під MVCC не блокує рядок, тож дві транзакції можуть прочитати те саме значення й затерти одна одну. SELECT ... FOR UPDATE бере ексклюзивне блокування рядка ще на читанні: транзакція A блокує рядок, і транзакція B на своєму SELECT ... FOR UPDATE чекає, доки A завершиться, після чого читає вже оновлене значення. Тобто це спосіб перетворити небезпечну гонку «читання-запис» на впорядковану чергу. Альтернатива без явного блокування — атомарний UPDATE balance = balance - 30 одним виразом, де база сама перечитує актуальне значення під блокуванням; FOR UPDATE потрібен, коли між читанням і записом є нетривіальна логіка в застосунку, яку не виразити одним UPDATE.
Що таке deadlock і чи це завжди баг?
Deadlock (взаємне блокування) — це коли дві транзакції захопили по рядку й кожна чекає на рядок, який тримає інша: A заблокувала рядок 1 і чекає рядок 2, B заблокувала рядок 2 і чекає рядок 1. Утворюється цикл очікування, який сам не розв'яжеться. СУБД виявляє цей цикл і примусово відкочує одну з транзакцій-жертв, звільняючи іншу; помилки впізнавані — PostgreSQL кидає deadlock detected, MySQL — Deadlock found when trying to get lock; try restarting transaction (код 1213). Ні, deadlock не завжди баг: під навантаженням це очікуваний ефект, і правильна реакція застосунку на нього — повторити транзакцію. Тому, побачивши такі помилки в логах, QA спершу перевіряє, чи є в коді ретрай, і лише потім заводить дефект — бо баг тут зазвичай саме відсутність ретраю, а не сам deadlock. Знизити частоту deadlock-ів допомагає єдиний порядок захоплення рядків у всіх транзакціях.
Чому послідовний тест не ловить конкурентні баги і як писати тест на race condition?
Звичайний послідовний тест бачить світ очима одного клієнта: POST /orders, потім GET /orders, перевірка — зелено, бо в один момент часу діяв лише один клієнт. Гонка ж виникає тільки за одночасності, тож її треба створити навмисно: вистрілити кількома запитами паралельно (у Playwright/TypeScript це Promise.all над кількома API-запитами до одного ресурсу) і перевірити інваріант, який має вціліти за будь-якого порядку. Три дисципліни чесного тесту гонок: перевіряй інваріант, а не конкретний порядок («виграв рівно один» і «залишок не від'ємний», а не «перший запит виграв», бо переможець недетермінований); пам'ятай, що гонка імовірнісна — один зелений прогін нічого не доводить, бо колізія могла просто не збігтися в часі; підвищуй конкурентність і повторюй тест у циклі. І головне — не плутай гонку в застосунку з флаком у самому тесті: нестабільність через sleep-и й криву синхронізацію тесту — це його проблема, а нестабільний результат саме на інваріанті даних — сигнал реального конкурентного бага.
Що таке write skew і який рівень ізоляції від нього рятує?
Write skew — це аномалія, коли дві транзакції читають той самий набір даних, а пишуть у різні рядки, разом порушуючи спільний інваріант. Класика: правило «хоча б один лікар має бути на чергуванні», двоє чергових одночасно читають «нас двоє, можна піти», кожен знімає себе — і на чергуванні не лишається нікого. Підступність у тому, що навіть знімкова ізоляція (REPEATABLE READ у PostgreSQL) її не ловить: кожна транзакція бачить консистентний знімок і пише в свій рядок, формального конфлікту запису немає. Від write skew рятує лише SERIALIZABLE: у Postgres він відстежує залежності читання-запису й кидає серіалізаційну помилку на небезпечній парі транзакцій. Це питання senior-рівня: воно показує, що кандидат розуміє межу знімкової ізоляції й не вважає REPEATABLE READ панацеєю.
Чим оптимістичне блокування відрізняється від песимістичного?
Песимістичне блокування виходить із припущення «конфлікт імовірний, тож захопимо рядок наперед»: транзакція бере SELECT ... FOR UPDATE, і решта чекають у черзі, доки вона завершиться. Оптимістичне виходить із «конфлікт рідкісний, тож не блокуємо, а перевіримо на записі»: читаємо рядок разом із версією (version чи updated_at), а на оновленні ставимо умову WHERE version = <прочитане>; якщо хтось устиг змінити рядок, оновиться нуль рядків — і транзакцію треба повторити. Песимістичний підхід простіший і надійніший при частих конфліктах, але тримає блокування й ризикує deadlock-ами та чергами; оптимістичний майже не має накладних витрат при рідкісних конфліктах, але вимагає логіки ретраю в застосунку. Вибір — це компроміс: гарячий рядок, який усі оновлюють (лічильник, залишок останнього товару), краще блокувати, а рідко конкурентні сутності (профіль користувача) — версіонувати. Для QA це означає різні тест-сценарії: для песимістичного перевіряємо, що друга транзакція коректно дочекалася, для оптимістичного — що конфлікт дав очікувану помилку чи ретрай, а не тихе затирання.
Три кейси, де конкурентність вирішує, зелений тест чи полювання за примарою: відтворення lost update двома сесіями psql (і як FOR UPDATE його лікує), тест на race condition у Playwright з перевіркою інваріанта, і тріаж симптому «запит завис / на проді валиться» за таблицею рішень. Скрізь — що дивитися і чому.
Кейс 1. Відтворити lost update руками і полагодити його
Найкраще відчути аномалію — відтворити її у двох сесіях бази вручну. Відкрий два термінали psql до однієї БД і програй патерн «прочитай — порахуй — запиши» з перекриттям. Нехай стартовий баланс — 100.
-- Сесія A -- Сесія B
BEGIN; BEGIN;
SELECT balance FROM accounts SELECT balance FROM accounts
WHERE id = 1; -- бачить 100 WHERE id = 1; -- теж бачить 100
-- застосунок рахує 100 - 50 = 50 -- застосунок рахує 100 - 30 = 70
UPDATE accounts SET balance = 50
WHERE id = 1;
COMMIT;
UPDATE accounts SET balance = 70
WHERE id = 1;
COMMIT;
-- Підсумок: balance = 70. Списання A зникло, мало бути 20.
Обидві транзакції завершилися успішно, помилки не було — а гроші «намалювалися». Це і є lost update: запис B вийшов зі застарілого прочитаного значення й затер запис A. Зверни увагу, що на дефолтному READ COMMITTED у PostgreSQL база тут нічого не порушила — інваріант зламав патерн доступу в застосунку, а не СУБД.
Тепер увімкни блокування — заміни звичайний SELECT на блокувальний:
-- Сесія A -- Сесія B
BEGIN; BEGIN;
SELECT balance FROM accounts
WHERE id = 1 FOR UPDATE; -- 100, рядок заблоковано ексклюзивно
SELECT balance FROM accounts
WHERE id = 1 FOR UPDATE; -- ЧЕКАЄ на A
UPDATE accounts SET balance = 50
WHERE id = 1;
COMMIT; -- A відпустила блокування
-- лише тепер B прочитала 50, рахує 50 - 30 = 20
UPDATE accounts SET balance = 20
WHERE id = 1;
COMMIT;
-- Підсумок: balance = 20. Правильно.
Що дивитися і чому:
FOR UPDATEперетворив гонку на чергу. Сесія B зависла наSELECT ... FOR UPDATE, доки A не закомітила; після цього B прочитала вже оновлене значення 50, а не застаріле 100. Так само працює атомарнийUPDATE accounts SET balance = balance - 30 WHERE id = 1— база сама перечитує актуальне значення під блокуванням.- Той самий сценарій на
REPEATABLE READу Postgres лікується інакше. Там друга транзакція вже на своємуUPDATEотримає помилкуcould not serialize access due to concurrent update(SQLSTATE 40001) — і застосунок має її повторити, а не впасти. Перемагає той, хто закомітив першим. - Це і є оракул для баг-репорту. Якщо в застосунку баланс «губиться» без помилок у логах — відтвори цей мінімальний двосесійний сценарій проти того самого рушія й рівня, що на проді. Відтворилося — дефект у патерні доступу коду, а не «база загубила дані».
Кейс 2. Playwright: спровокувати гонку й перевірити інваріант
Послідовний тест POST → GET гонку не побачить, бо в один момент діє один клієнт. Щоб зловити подвійне списання останнього товару, конкурентність треба створити навмисно — вистрілити пачкою паралельних запитів і перевірити інваріант, що має вціліти за будь-якого порядку.
import { test, expect } from '@playwright/test';
// Гонка: 20 паралельних спроб купити останній товар
test('останній товар не продається двічі', async ({ playwright }) => {
const api = await playwright.request.newContext({ baseURL: process.env.BASE_URL });
await api.post('/api/inventory/reset', { data: { itemId: 'X', stock: 1 } });
const attempts = Array.from({ length: 20 }, () =>
api.post('/api/orders', { data: { itemId: 'X', qty: 1 } }),
);
const responses = await Promise.all(attempts);
const created = responses.filter((r) => r.status() === 201);
expect(created).toHaveLength(1); // рівно одна купівля успішна
const stock = await (await api.get('/api/inventory/X')).json();
expect(stock.available).toBe(0); // залишок не пішов у мінус
});
Що дивитися і чому:
- Перевіряй інваріант, а не переможця. Асерт — «створено рівно один» і «залишок не від'ємний», а не «перший запит виграв». Хто саме переміг — недетерміновано, і закладатися на це не можна.
- Один зелений прогін нічого не доводить. Гонка імовірнісна: колізія могла просто не збігтися в часі. Підвищуй конкурентність (більше паралельних запитів), заганяй тест у цикл повторів, а якщо застосунок має тестові хуки із затримкою — використовуй їх, щоб розширити «вікно» гонки.
- Той самий підхід — на ідемпотентності API. Паралельні повтори одного запиту з
Idempotency-Keyмають дати один результат, а не N списань — суміжна перевірка тим самимPromise.all. - Не сплутай сигнали. Якщо тест то падає, то ні через власні
sleep-и й криву синхронізацію — це флак самого тесту. А нестабільний результат саме на інваріанті даних (created.lengthто 1, то 2) — це вже сигнал реального конкурентного бага в продукті.
Кейс 3. Тріаж: «запит завис» і «на проді валиться»
Конкурентні симптоми легко сплутати з іншими причинами. Перш ніж заводити дефект, прожени симптом через таблицю рішень — вона економить години на хибних баг-репортах.
| Симптом | Виглядає як | Найімовірніша причина | Що перевірити першим |
|---|---|---|---|
| Запит «висить» без помилки | Повільний запит | Черга за блокуванням: чекає на рядок, який тримає інша відкрита транзакція | Незакритий BEGIN у сусідній сесії (напр. DBeaver з вимкненим autocommit); pg_locks / SHOW ENGINE INNODB STATUS |
| Тест зелений на стейджі, червоний на проді | Баг застосунку | Різні дефолти СУБД: READ COMMITTED vs REPEATABLE READ | Звірити рушій і рівень ізоляції стейджа й прода |
| Два клієнти бачать різні дані «одночасно» | Розсинхрон кешу | Нормальна робота MVCC: кожен читає власний знімок транзакції | Чи це справді розбіжність, чи різні знімки відкритих транзакцій |
| Баланс зіпсувався без помилок у логах | «База загубила оновлення» | Lost update: read-modify-write без FOR UPDATE/атомарного UPDATE | Відтворити двосесійний сценарій із Кейсу 1 |
deadlock detected у логах під навантаженням | Критичний збій бекенда | Очікуваний під конкурентністю ефект | Чи є в коді ретрай транзакції; баг — саме його відсутність |
Що дивитися і чому:
- «Висить» — це майже завжди чужа транзакція, а не повільний запит. Запит чекає на ексклюзивне блокування рядка. Класичний винуватець — забутий незакритий
BEGINу клієнті на кшталт DBeaver, де вимкнено autocommit. Перевіряється через системні представлення блокувань, а не черезEXPLAIN. - «На проді валиться» — спершу рушій і рівень, потім код.
READ COMMITTEDу Postgres іREPEATABLE READу MySQL дають різну видимість між читаннями, тож той самий код відтворює баг лише в одному середовищі. Тестуй на тому ж рушії й рівні, що й прод. - Deadlock у логах — не привід для паніки. Правильна реакція застосунку на нього — повторити транзакцію. Тому баг тут — відсутність ретраю, а не сам deadlock; знизити частоту допомагає єдиний порядок захоплення рядків.
- Дисципліна одна на всі рядки: не заявляй причину без доказу. «Схоже на lost update, двосесійний сценарій відтворює втрату» коштує рівно стільки, скільки під ним відтворення й логів.
Аномалії конкурентного доступу
- Розумію, що рівні ізоляції описують не механізми, а аномалії — небажані ефекти від дій сусідніх транзакцій, і саме аномалії треба знати першими.
- Можу пояснити брудне читання (dirty read) і чому воно про читання незакоміченого «сміття», яке потім відкотиться.
- Знаю різницю між неповторюваним читанням (non-repeatable read) і фантомним (phantom read): перше — змінилося значення в рядку, друге — змінився склад вибірки.
- Можу пояснити втрачене оновлення (lost update) — псує дані без жодного
ROLLBACKі без помилки — і що це аномалія з класичної критики стандарту (Berenson та ін.), а не з базової таблиці рівнів.
Рівні ізоляції
- Знаю чотири рівні за зростанням строгості (
READ UNCOMMITTED,READ COMMITTED,REPEATABLE READ,SERIALIZABLE) і які аномалії кожен дозволяє, виводячи це з логіки «вище — строгіше», а не зазубрюючи таблицю. - Розумію компроміс: що вищий рівень, то менше аномалій, але то більше блокувань, відкатів і черг — конкурентність падає.
- Можу пояснити, що
SERIALIZABLEгарантує результат, ніби транзакції виконувалися по черзі, і чому за це платять продуктивністю.
Дефолти й відмінності СУБД
- Знаю рівні за замовчуванням: PostgreSQL —
READ COMMITTED, MySQL/InnoDB —REPEATABLE READ. - Розумію, що PostgreSQL строгіший за стандарт (його
REPEATABLE READна знімку прибирає навіть фантоми), аREAD UNCOMMITTEDу Postgres працює якREAD COMMITTED— тоді як у MySQL це справжній рівень із брудними читаннями. - Розумію, чому тест має ганяти той самий рушій і рівень, що й прод, і чому інакше виникає «на стейджі зелено, на проді валиться».
MVCC
- Можу пояснити MVCC своїми словами (оновлення не затирає рядок, а створює нову версію поряд) і знаю ключову фразу «читачі не блокують письменників, письменники не блокують читачів».
- Розумію, що кожна транзакція бачить власний узгоджений знімок, тож два клієнти в один момент можуть бачити різні дані — і обидва праві.
- Можу пояснити, чому довга транзакція-читач роздуває базу мертвими версіями і навіщо потрібен
VACUUM(undo-логи в InnoDB).
Блокування і deadlock
- Знаю різницю між спільним (S,
FOR SHARE) та ексклюзивним (X,FOR UPDATE, автоматично приUPDATE/DELETE) блокуванням рядка. - Розумію, що MVCC знімає конфлікт «читач проти письменника», але «письменник проти письменника» лишається, і що
SELECT ... FOR UPDATEчи атомарнийUPDATE ... = ... - N— ліки від lost update. - Знаю, що таке deadlock, що СУБД сама відкочує транзакцію-жертву, і що правильна реакція — ретрай, а баг — це відсутність ретраю.
- Впізнаю повідомлення:
deadlock detected(Postgres),Deadlock found ... try restarting transactionкод 1213 (MySQL),could not serialize accessSQLSTATE 40001.
Тестування гонок
- Розумію, чому послідовний тест не ловить конкурентні баги і що гонку треба створювати навмисно через паралельні запити (
Promise.all) з перевіркою інваріанта («виграв рівно один»), а не конкретного переможця. - Розумію імовірнісну природу гонки: один зелений прогін не доводить її відсутності, тому потрібні висока конкурентність і повтори.
- Не плутаю гонку в застосунку з флаком у самому тесті (
sleep-и й крива синхронізація — проблема тесту, нестабільний інваріант даних — сигнал реального бага), а «баг застосунку» — з «дефолтом СУБД»/«чергою за блокуванням»: спершу звіряю рушій, рівень і відкриті транзакції, а вже потім заводжу дефект.
Що таке брудне читання (dirty read)?
Питання
Що таке конкурентність (concurrency) у контексті бази даних?