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

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

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

    Зміст

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

    Транзакція 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 строгіший, ніж вимагає стандарт. Його 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). Дві транзакції захопили по рядку й кожна чекає на рядок, який тримає інша:

    Транзакція 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: 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).