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

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

    Транзакції та ACID

    Зміст

    Уяви переказ грошей: списати 100 гривень з рахунку А і зарахувати їх на рахунок Б. Це два окремі UPDATE. А тепер уяви, що між ними впав сервер, обірвалася мережа або застосунок кинув виняток. Гроші зникли з рахунку А, але не зʼявилися на Б — сто гривень щойно розчинилися в повітрі. Саме щоб цього не сталося, у базах існують транзакції: механізм, який каже «або обидві операції, або жодна».

    Для QA це не абстракція з підручника, а живий клас багів і одне з найчастіших питань на співбесіді про бази. Половина дефектів цілісності даних («замовлення є, а товарів у ньому немає», «підписка списалась, а доступ не відкрився») — це історія про транзакцію, якої не було або яка була неправильною. А ще відкат транзакції (rollback) — робочий інструмент клінапу в автотестах, який повертає базу до чистого стану після кожного прогону. Тож розуміти транзакції треба з двох боків: як тестувати чужу логіку і як тримати в чистоті власні тести.

    Що таке транзакція: BEGIN, COMMIT, ROLLBACK

    Транзакція (transaction) — це група операцій із базою, яку СУБД трактує як одну неподільну одиницю роботи. Усередині може бути один INSERT, а може — десяток UPDATE, DELETE і SELECT разом. Ключова обіцянка: назовні видно або результат усіх операцій, або жодної. Проміжного стану для інших користувачів немає.

    Керують транзакцією трьома командами:

    BEGIN;                                    -- відкрити транзакцію
    UPDATE accounts SET balance = balance - 100 WHERE id = 1;
    UPDATE accounts SET balance = balance + 100 WHERE id = 2;
    COMMIT;                                   -- зафіксувати: зміни стають постійними

    BEGIN (у деяких діалектах START TRANSACTION) відкриває транзакцію. Далі йдуть будь-які запити. COMMIT каже базі «усе пройшло, закріпи зміни назавжди» — після нього дані на диску й видимі всім. Якщо ж щось пішло не так, замість COMMIT викликають ROLLBACK — і база відкочує всі зміни цієї транзакції, ніби їх ніколи не було.

    Так

    Помилка або скасування

    BEGIN

    Операції: INSERT / UPDATE / DELETE

    Усе успішно?

    COMMIT

    ROLLBACK

    Зміни постійні й видимі всім

    База повертається до стану до BEGIN

    Так

    Помилка або скасування

    BEGIN

    Операції: INSERT / UPDATE / DELETE

    Усе успішно?

    COMMIT

    ROLLBACK

    Зміни постійні й видимі всім

    База повертається до стану до BEGIN

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

    ACID по літерах

    ACID — це чотири гарантії, яких надійна СУБД дотримується для транзакцій. Абревіатура складається з перших літер: Atomicity, Consistency, Isolation, Durability.

    A — Atomicity (атомарність). Транзакція неподільна: виконуються або всі її операції, або жодна. Немає стану «списали з А, але не зарахували на Б». Якщо на другому кроці виникає помилка, ROLLBACK скасовує й перший. Це саме та властивість, що рятує переказ грошей із вступу.

    C — Consistency (узгодженість). Транзакція переводить базу з одного коректного стану в інший, не порушуючи оголошених правил цілісності: первинних і зовнішніх ключів, NOT NULL, CHECK, UNIQUE, тригерів. Якщо транзакція намагається лишити базу в невалідному стані (наприклад, вставити замовлення з посиланням на неіснуючого клієнта), вона відхиляється й відкочується. Важливий нюанс для співбесіди: база гарантує лише оголошені обмеження. Бізнес-інваріант, який ніде не описаний обмеженням (скажімо, «сума позицій має дорівнювати сумі замовлення»), — відповідальність застосунку, а не СУБД. Тому «C» вважають найслабшою й найбільш дискусійною літерою.

    I — Isolation (ізольованість). Паралельні транзакції не заважають одна одній: кожна працює так, ніби вона в базі сама. На найсуворішому рівні результат конкурентного виконання не відрізняється від того, якби транзакції виконались одна за одною. На практиці ізоляція має чотири стандартні рівні (від READ UNCOMMITTED до SERIALIZABLE), і слабші рівні свідомо допускають аномалії заради швидкодії — це окрема велика тема. Детально рівні ізоляції, блокування і гонки розбираємо у главі Рівні ізоляції, блокування і конкурентність.

    D — Durability (тривкість). Щойно транзакцію закомічено, її результат переживе будь-що: падіння процесу, збій живлення, перезапуск сервера. Досягається це журналом попереднього запису (write-ahead log, WAL у PostgreSQL; redo log у MySQL/InnoDB): база спершу записує зміни в журнал на диску й лише потім підтверджує COMMIT. Після краху вона програє журнал і відновлює всі підтверджені транзакції. Тому COMMIT — це обіцянка: «повернувся успіх — значить, дані вже переживуть перезавантаження».

    Для QA ці чотири літери — готовий чекліст перевірок цілісності: чи атомарний багатокроковий флоу (A), чи ловляться порушення обмежень (C), чи не б'ються паралельні дії (I), чи не зникають дані після рестарту (D).

    autocommit: чому «просто UPDATE» — вже транзакція

    Часте непорозуміння: «я ж не писав BEGIN, значить транзакції немає». Насправді транзакція є завжди. За замовчуванням більшість клієнтів і драйверів працюють у режимі autocommit: кожен окремий запит неявно загорнутий у власну транзакцію й комітиться одразу після виконання. Ти пишеш один UPDATE — база сама робить BEGIN ... UPDATE ... COMMIT під капотом.

    Так поводяться psql, консоль MySQL (autocommit=1), більшість драйверів (JDBC, node-postgres). Наслідок прямий і небезпечний: якщо тобі треба згрупувати кілька запитів у неподільне ціле, autocommit цього не зробить — кожен закомітиться окремо. Треба явно відкрити транзакцію через BEGIN.

    -- НЕБЕЗПЕЧНО в autocommit: два незалежні коміти
    UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- закомічено одразу
    UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- окремий коміт
    -- якщо між рядками впаде — перший уже незворотний

    Окрема пастка — GUI-клієнти. DBeaver, DataGrip і подібні мають перемикач «autocommit / manual commit». У ручному режимі клієнт не комітить сам: ти виконав UPDATE, побачив «1 row affected», пішов додому — а зміни висять у незакоміченій транзакції, тримають блокування і зникнуть, щойно клієнт закриє з'єднання. Тому на спільному стенді завжди варто знати, у якому режимі коміту працює твій клієнт.

    SAVEPOINT: часткові відкати всередині транзакції

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

    BEGIN;
    INSERT INTO orders (id, customer_id) VALUES (100, 42);
    
    SAVEPOINT before_items;
    INSERT INTO order_items (order_id, sku) VALUES (100, 'BAD-SKU');
    -- виявили, що SKU невалідний — відкочуємо лише вставку позиції
    ROLLBACK TO SAVEPOINT before_items;
    
    INSERT INTO order_items (order_id, sku) VALUES (100, 'GOOD-SKU');
    COMMIT;   -- замовлення і коректна позиція збережені

    ROLLBACK TO SAVEPOINT скасовує все, що сталося після точки, але лишає саму транзакцію відкритою й зберігає роботу до точки. Сам savepoint при цьому не зникає — до нього можна відкочуватися повторно; прибирає його RELEASE SAVEPOINT.

    Крім явного керування, savepoint рятує в одному специфічному сценарії PostgreSQL: там будь-яка помилка абортує всю транзакцію. Після невдалого запиту наступні падають з current transaction is aborted, аж поки не буде ROLLBACK. Якщо огорнути ризикований запит у savepoint, можна відкотитися лише до нього й продовжити транзакцію — саме так драйвери реалізують «спробувати, а при помилці піти іншим шляхом». У MySQL поведінка інша: помилка окремого запиту не валить усю транзакцію автоматично.

    Класичний баг без транзакції: частковий запис

    Це найчастіший «транзакційний» баг і улюблене питання співбесід. Симптом — частковий запис (partial write): багатокрокова операція виконалася наполовину, і база лишилася в суперечливому стані.

    У транзакції

    BEGIN

    INSERT замовлення

    INSERT позицій - ПАДІННЯ

    ROLLBACK

    База чиста, ніби нічого не було

    Без транзакції

    INSERT замовлення - OK

    INSERT позицій - ПАДІННЯ

    Замовлення-сирота
    без жодної позиції

    У транзакції

    BEGIN

    INSERT замовлення

    INSERT позицій - ПАДІННЯ

    ROLLBACK

    База чиста, ніби нічого не було

    Без транзакції

    INSERT замовлення - OK

    INSERT позицій - ПАДІННЯ

    Замовлення-сирота
    без жодної позиції

    Типові прояви, які QA бачить у даних:

    • замовлення створене, але рядків у order_items немає (впало на другому кроці);
    • з рахунку списано, на інший не зараховано (той самий переказ);
    • користувач створений, а його профіль чи стартові налаштування — ні;
    • оплата пройшла у платіжці, а статус замовлення лишився pending, бо колбек не закомітився.

    Механізм завжди один: розробник виконав кілька залежних записів без спільної транзакції (або з autocommit, де кожен закомітився окремо), і на півдорозі щось урвалося. З транзакцією помилка на будь-якому кроці веде до ROLLBACK, і база лишається цілою.

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

    Відкат як механізм клінапу в автотестах

    У транзакцій є друге життя — прибирання за тестами. Кожен автотест хоче стартувати з чистої, передбачуваної бази; інакше дані від попереднього прогону протікають у наступний і породжують флак (flaky). Один з найелегантніших прийомів: огорнути кожен тест у транзакцію й наприкінці зробити ROLLBACK замість COMMIT. База сама «забуває» все, що тест наробив, — без ручного DELETE, без TRUNCATE.

    // Кожен тест працює у власній транзакції, яку наприкінці відкочуємо
    let client: PoolClient;
    
    beforeEach(async () => {
      client = await pool.connect();
      await client.query('BEGIN');
    });
    
    afterEach(async () => {
      await client.query('ROLLBACK'); // база повертається до стану до тесту
      client.release();
    });

    Прийом швидкий (відкат дешевший за видалення рядків) і надійно ізолює тести. Але в нього є жорстке обмеження, яке треба розуміти: воно працює лише коли код під тестом ходить у базу тим самим з'єднанням. Незакомічена транзакція видима тільки всередині свого з'єднання. Якщо застосунок працює через власний пул з'єднань (а в e2e-тестах через Playwright — саме так), він твоєї відкритої транзакції не побачить взагалі, а свої зміни закомітить сам. Тому rollback-per-test — прийом переважно для інтеграційних тестів бекенду з однією базою, а не для наскрізних сценаріїв через застосунок.

    Друге застереження: відкат не тестує реальний COMMIT. Відкладені обмеження (deferred constraints), логіка тригерів на коміт, поведінка при реальному записі — усе це rollback-підхід пропускає. Тому для e2e частіше беруть інші стратегії клінапу: унікальні дані на кожен прогін, видалення створеного через API після тесту, окремий tenant або перестворення схеми. Детальніше про сіди, фікстури й ізоляцію бази в автотестах — у главі Тестові дані і БД в автотестах; загальна стратегія тестових даних живе в розділі про автоматизацію.

    Типові помилки

    Виглядає як «транзакція є», а насправді autocommit. Кілька UPDATE підряд без явного BEGIN — це не одна транзакція, а кілька окремих. Кожен комітиться сам, і збій посередині лишає частковий запис. Групування вимагає явного BEGIN ... COMMIT.

    Виглядає як «rollback усе прибрав», а насправді лишилися дірки в ID. Відкат повертає дані, але не значення послідовностей. У PostgreSQL SEQUENCEAUTO_INCREMENT у MySQL) не відкочуються: невдала вставка все одно «спалює» наступний ID. Тому в автоінкрементних ключах з'являються пропуски — це не баг, а нормальна ціна конкурентності. Тест, що чекає рівно id = 5, впаде дарма.

    Виглядає як «DDL теж відкотиться», а насправді в MySQL — ні. У MySQL більшість DDL-команд (CREATE TABLE, ALTER TABLE) викликають неявний коміт: вони закривають поточну транзакцію й відкотити їх не можна. PostgreSQL, навпаки, має транзакційний DDL — там CREATE TABLE всередині транзакції відкочується нормально. Не переноси очікування з однієї СУБД на іншу.

    Виглядає як «COMMIT точно спрацював», а насправді помилка при коміті проковтнута. COMMIT теж може впасти (порушення відкладеного обмеження, конфлікт серіалізації, обрив зв'язку). Код, що не перевіряє результат саме COMMIT, вважає транзакцію успішною, хоча вона відкотилась. На рев'ю варто дивитися, чи обробляється помилка коміту, а не лише помилки окремих запитів.

    Виглядає як «rollback у тесті ізолює все», а насправді застосунок закомітив своє. Rollback-per-test не бачать інші з'єднання. В e2e через застосунок твій ROLLBACK не скасує те, що бекенд записав власним пулом. Наслідок — дані просочуються між тестами, хоча «клінап начебто є».

    Підсумок

    • Транзакція — неподільна одиниця роботи: BEGIN відкриває, COMMIT закріплює назавжди, ROLLBACK скасовує все зроблене з моменту BEGIN.
    • ACID = Atomicity (усе або нічого), Consistency (не порушувати оголошені обмеження), Isolation (паралельні транзакції не заважають), Durability (закомічене переживе краш). «C» частково — відповідальність застосунку.
    • За замовчуванням клієнти працюють у autocommit: кожен запит комітиться окремо, тож групування кількох операцій вимагає явного BEGIN.
    • SAVEPOINT дає частковий відкат усередині транзакції; у PostgreSQL ще й рятує транзакцію, яку абортувала помилка одного запиту.
    • Частковий запис (замовлення без позицій, списання без зарахування) — класичний баг неатомарної операції; перевіряється імітацією збою посеред флоу.
    • Відкат — робочий клінап для інтеграційних тестів на одному з'єднанні, але не працює через застосунок з окремим пулом і не тестує реальний COMMIT.

    Що питають на співбесіді

    • «Що таке транзакція і навіщо вона?» — чекають означення (неподільна група операцій) і живого прикладу з переказом грошей або створенням замовлення з позиціями.
    • «Розкажи про ACID.» — треба впевнено розшифрувати всі чотири літери з прикладом на кожну, а не просто перелічити слова. Сильний кандидат згадає, що «C» частково лежить на застосунку, і що «I» має рівні.
    • «Чим COMMIT відрізняється від ROLLBACK? Що таке autocommit?» — перевіряють, чи розумієш, що транзакція є завжди, і чому кілька окремих UPDATE не є атомарними.
    • «Наведи баг, який стається без транзакції.» — очікують історію про частковий запис і те, як ти б його відтворив і перевірив SELECT-ом.
    • «Як прибираєш дані після автотестів?» — тут цінують, що ти знаєш rollback-per-test і його межу (не працює через застосунок з окремим з'єднанням), а не тільки TRUNCATE.

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

    Джерела

    Транзакції та ACID у CTFL 4.0 напряму не входять — це тема баз даних поза силабусом ISTQB, тож канонічне джерело тут не силабус, а документація СУБД і стандарт SQL (ISO/IEC 9075).