Рівні ізоляції, блокування і конкурентність
Зміст
Уяви двох касирів, які одночасно продають той самий останній квиток. Обидва бачать «1 у наявності», обидва проводять оплату, і в залі опиняються два власники одного місця. Це не помилка в SQL — це конкурентність (concurrency): кілька торкаються тих самих даних в одну мить. База даних дає інструменти, щоб такі колізії не псували дані, і водночас дозволяє свідомо послабити суворість заради швидкодії. Уся ця глава — про той компроміс.
Для QA тема з подвійним дном. По-перше, серед найдорожчих і найважчих для відтворення продакшн-багів — саме конкурентні: подвійне списання, від'ємний залишок, «зникле» оновлення профілю. Вони не ловляться сценарієм «клік за кліком», бо виникають лише коли двоє діють одночасно. По-друге, саме звідси береться частина (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 на READ COMMITTED два послідовні SELECT в одній транзакції цілком можуть побачити різні дані, бо кожен запит бачить знімок на момент свого старту. В InnoDB на REPEATABLE READ узгоджені читання в межах транзакції беруть знімок, установлений першим читанням, тож обидва SELECT покажуть те саме. Обидві поведінки правильні — просто це різні бази.
Кілька наслідків, які варто тримати в голові:
- 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, які закривають більшість фантомів. - Тест має ганяти ту саму СУБД і рівень, що й прод. Якщо локально ти на чи на 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 18 — 13.2. Transaction Isolation — дослівні означення трьох явищ стандарту: брудного, неповторюваного й фантомного читання.
- Berenson та ін. — A Critique of ANSI SQL Isolation Levels (SIGMOD 1995) —
lost updateяк явище з іншої таксономії: у переліку стандарту його немає, і саме звідси воно походить.
- PostgreSQL 18 — 13.2. Transaction Isolation — таблиця чотирьох рівнів і того, які явища кожен ; там же ціна високих рівнів і те, що реалізації легально бувають строгішими за стандарт.
- MySQL 8.0 — Transaction Isolation Levels (InnoDB) — ті самі чотири рівні з боку InnoDB і що саме бачить кожен.
- PostgreSQL 18 — SET TRANSACTION — як рівень задають і в який момент це робити вже пізно.
- Berenson та ін. — A Critique of ANSI SQL Isolation Levels (SIGMOD 1995) — першоджерело критики: чому переліку явищ у стандарті бракує, зокрема для
lost update.
- PostgreSQL 18 — 13.2. Transaction Isolation — бік PostgreSQL:
Read Committedяк дефолт,Read Uncommittedповодиться якRead Committed,Repeatable Readна знімку прибирає й фантоми, а конфлікт віддається помилкою серіалізації, яку треба обробити повтором. - MySQL 8.0 — Transaction Isolation Levels (InnoDB) — бік MySQL: дефолт
REPEATABLE READ, знімок від першого читання в транзакції й next-key locks для блокувальних операцій. - MySQL 8.0 — START TRANSACTION, COMMIT, and ROLLBACK Statements — де саме дефолтний рівень оголошено з боку MySQL.
- Berenson та ін. — A Critique of ANSI SQL Isolation Levels (SIGMOD 1995) — звідки взагалі поняття знімкової ізоляції й чого вона не ловить.
- PostgreSQL 18 — 13.1. Introduction (Concurrency Control) — MVCC як механізм знімків і принцип «читання не блокує запис, запис не блокує читання».
- PostgreSQL 18 — 13.2. Transaction Isolation — що саме бачить транзакція у своєму знімку й чому два клієнти одночасно бачать різне.
- MySQL 8.0 — Transaction Isolation Levels (InnoDB) — реалізація знімка в InnoDB через узгоджені неблокувальні читання.
Блокування рядків: спільні, ексклюзивні, deadlock
- MySQL 8.0 — InnoDB Locking — спільні й ексклюзивні блокування, матриця сумісності й решта різновидів InnoDB (record, gap, next-key).
- MySQL 8.4 — 17.7.2.4 Locking Reads — навіщо потрібне блокувальне читання:
SELECT … FOR UPDATE/FOR SHAREі патерн «прочитай, порахуй, запиши». - PostgreSQL 18 — 13.3. Explicit Locking — режими блокувань у PostgreSQL і
deadlock-и: база сама виявляє цикл очікування й знімає одну з транзакцій. - MySQL 8.0 — Deadlocks in InnoDB — автоматична детекція, вибір жертви й пряма вимога джерела бути готовим повторити транзакцію.
- PostgreSQL 18 — SELECT — синтаксис блокувальних читань разом із
NOWAITіSKIP LOCKED.
- MySQL 8.4 — 17.7.2.4 Locking Reads — джерельне пояснення, чому послідовний сценарій гонки не бачить: конфлікт виникає лише за одночасних блокувальних читань.
- PostgreSQL 18 — 13.2. Transaction Isolation — чому перевіряти треба інваріант, а не переможця: за конфлікту транзакцію просто відкидають, і хто саме виграє — не визначено.
- MySQL 8.0 — Deadlocks in InnoDB — імовірнісна природа конкурентних збоїв і вимога проєктувати застосунок під повтор транзакції.
Що таке конкурентність (concurrency) і чому це болюча тема саме для QA?
Конкурентність — це ситуація, коли кілька торкаються тих самих даних в одну мить. Класичний приклад: двоє касирів одночасно продають той самий останній квиток, обидва бачать «1 у наявності» й обидва проводять оплату. База даних дає інструменти, щоб такі колізії не псували дані, і водночас дозволяє свідомо послабити суворість заради швидкодії. Для QA тема з подвійним дном: по-перше, серед найдорожчих і найважчих для відтворення продакшн-багів — саме конкурентні (подвійне списання, від'ємний залишок, «зникле» оновлення профілю); по-друге, з росте частина в . Головна складність у тому, що ці баги не ловляться сценарієм «клік за кліком»: вони виникають лише коли двоє діють одночасно.
Назвіть аномалії конкурентного доступу.
Класичних конкурентного доступу чотири — це небажані ефекти, які транзакція може побачити через дії сусідів; стандарт 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 це справжній рівень із можливими брудними читаннями. Тому сценарій «на стейджі зелено, на проді валиться» часто пояснюється не кодом, а тим, що локально ти на чи 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-и й крива синхронізація — проблема тесту, нестабільний інваріант даних — сигнал реального бага), а «баг застосунку» — з «дефолтом СУБД»/«чергою за блокуванням»: спершу звіряю СУБД, рівень і відкриті транзакції, а вже потім заводжу дефект.
Квіз
Перед стартом
- Питань: 13
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
Що таке конкурентність (concurrency) у контексті бази даних?