vyvchy
    Теми розділу

    05 · Бази даних і SQL для QA

    Рівні ізоляції, блокування і конкурентність

    Зміст

    Уяви двох касирів, які одночасно продають той самий останній квиток. Обидва бачать «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, кожна рахує нове значення у застосунку і записує його:

    Транзакція BРядок balance=100Транзакція AТранзакція BРядок balance=100Транзакція AСписання A зникло. Мало бути 20, а стало 70SELECT balance (бачить 100)SELECT balance (бачить 100)UPDATE balance = 100 - 50 = 50, COMMITUPDATE balance = 100 - 30 = 70, COMMITТранзакція BРядок balance=100Транзакція AТранзакція BРядок balance=100Транзакція AСписання A зникло. Мало бути 20, а стало 70SELECT balance (бачить 100)SELECT balance (бачить 100)UPDATE balance = 100 - 50 = 50, COMMITUPDATE balance = 100 - 30 = 70, COMMIT

    Обидві транзакції завершилися успішно, помилки не було — а гроші «намалювалися». Це і є lost update: запис однієї транзакції затирає запис іншої, бо обидві виходили зі застарілого прочитаного значення.

    Чотири рівні ізоляції

    Стандарт SQL визначає чотири рівні за тим, які аномалії на кожному дозволені. Що вищий рівень — то менше аномалій, але й то більше блокувань, відкатів і черг; конкурентність падає. Це прямий обмін строгості на .

    Рівень ізоляціїDirty readNon-repeatable readPhantom read
    READ UNCOMMITTEDможливеможливеможливе
    READ COMMITTEDніможливеможливе
    REPEATABLE READнініможливе
    SERIALIZABLEнініні

    SERIALIZABLE — золотий стандарт: результат гарантовано такий, ніби транзакції виконувалися по черзі, одна за одною, без жодного перекриття. За це платиш продуктивністю. READ COMMITTED — прагматична середина: бачиш лише закомічене, але між двома читаннями світ може змінитися. Зверни увагу: стандартна таблиця не згадує lost update окремо — це аномалія з класичної критики стандарту (Berenson та ін., 1995), і різні СУБД борються з нею по-різному, про що нижче.

    Дефолти PostgreSQL і MySQL

    Тут ховається пастка, на якій горять і розробники, і QA: однаковий SQL поводиться по-різному залежно від СУБД і рівня ізоляції за замовчуванням.

    PostgreSQLMySQL (InnoDB)
    Рівень за замовчуваннямREAD COMMITTEDREPEATABLE 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. Дві транзакції захопили по рядку й кожна чекає на рядок, який тримає інша:

    Транзакція BТранзакція AТранзакція BТранзакція AВзаємне очікування. СУБД детектує цикл і вбиває одну транзакціюзаблокувала рядок 1заблокувала рядок 2чекає рядок 2 (його тримає B)чекає рядок 1 (його тримає A)Транзакція BТранзакція AТранзакція BТранзакція AВзаємне очікування. СУБД детектує цикл і вбиває одну транзакціюзаблокувала рядок 1заблокувала рядок 2чекає рядок 2 (його тримає B)чекає рядок 1 (його тримає A)

    СУБД сама виявляє цикл очікування й примусово відкочує одну з транзакцій, звільняючи іншу. Помилки дослівні й впізнавані: 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 і MySQL

    MVCC на пальцях

    Блокування рядків: спільні, ексклюзивні, 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 — імовірнісна природа конкурентних збоїв і вимога проєктувати застосунок під повтор транзакції.

    Пояснення

    «Поясни» працює з власним API-ключем Anthropic: запит іде з вашого браузера прямо до Anthropic.

    Свій API-ключ (BYOK)

    Вставте власний ключ Anthropic — пояснення працюватиме на реальній моделі. Ключ зберігається лише у вашому браузері (localStorage), нікуди не надсилається, крім api.anthropic.com, і не логується.