Транзакції та 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 — і база відкочує всі зміни цієї транзакції, ніби їх ніколи не було.
Поки транзакція не закомічена, її зміни бачить лише вона сама. Інша сесія, що читає ті самі рядки, побачить старі значення — доки не буде 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): багатокрокова операція виконалася наполовину, і база лишилася в суперечливому стані.
Типові прояви, які 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 SEQUENCE (і AUTO_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.
Інтерв'юер дивиться не на завчену абревіатуру, а на те, чи вмієш пов'язати транзакції з реальними дефектами цілісності й з дисципліною власних тестів.
Джерела
- PostgreSQL Documentation — Transactions — базова механіка
BEGIN/COMMIT/ROLLBACKі savepoints у прикладах. - PostgreSQL Documentation — SAVEPOINT — точний синтаксис і семантика часткових відкатів.
- MySQL Reference Manual — START TRANSACTION, COMMIT, and ROLLBACK — autocommit і керування транзакціями в MySQL.
- MySQL Reference Manual — Statements That Cause an Implicit Commit — перелік DDL-команд, що викликають неявний коміт і не відкочуються.
- PostgreSQL Documentation — Reliability and the Write-Ahead Log — як WAL забезпечує тривкість (durability) після збоїв.
Транзакції та ACID у CTFL 4.0 напряму не входять — це тема баз даних поза силабусом ISTQB, тож канонічне джерело тут не силабус, а документація СУБД і стандарт SQL (ISO/IEC 9075).
Що таке транзакція і навіщо вона потрібна?
Транзакція — це кілька операцій з базою, зібраних в одне неподільне ціле: ззовні стає видно або результат усіх одразу, або не видно жодного. Класичний приклад — переказ грошей: списати 100 гривень з одного рахунку і зарахувати на інший — це два окремі UPDATE, і якщо між ними впаде сервер, гроші зникнуть. Транзакція дає обіцянку «або обидві операції, або жодна»: помилка на будь-якому кроці веде до ROLLBACK, і база лишається цілою. Для QA це не абстракція, а живий клас багів: половина дефектів цілісності («замовлення є, а позицій немає», «підписку списано, а доступ не відкрито») — це історія про транзакцію, якої не було. Тому питання «а це загорнуто в транзакцію?» варто ставити до будь-якого багатокрокового запису.
Чим відрізняються COMMIT і ROLLBACK?
COMMIT закріплює зміни транзакції назавжди, ROLLBACK скасовує все зроблене з моменту BEGIN. Після COMMIT дані записані на диск, видимі всім іншим сесіям і переживуть краш; до нього зміни бачить лише сама транзакція, а інша сесія читає старі значення. ROLLBACK повертає базу до стану, який був до BEGIN, ніби операцій не існувало. Обидві команди завершують транзакцію: після них потрібен новий BEGIN, щоб почати наступну. Практичний наслідок для тестування: якщо серед кроків флоу трапилася помилка, коректна реакція коду — саме ROLLBACK, а не «закомітити, що встигли».
Розкажи, що таке ACID.
ACID — чотири гарантії надійної СУБД для транзакцій: Atomicity, Consistency, Isolation, Durability. Atomicity (атомарність) — транзакція неподільна: виконуються або всі операції, або жодна; помилка на другому кроці скасовує й перший. Consistency (узгодженість) — після завершення транзакції база лишається у валідному стані: жодне з оголошених обмежень (ключі, NOT NULL, CHECK, UNIQUE, тригери) не порушене. Isolation (ізольованість) — паралельні транзакції не заважають одна одній, ніби кожна в базі сама; на практиці ізоляція має рівні, і слабші свідомо допускають аномалії заради швидкодії. Durability (тривкість) — щойно транзакцію закомічено, її результат переживе падіння процесу, збій живлення чи рестарт сервера. Сильна відповідь дає приклад на кожну літеру й згадує, що «C» частково лежить на застосунку, а «I» має рівні, — а не просто перелічує слова.
Що таке autocommit і чому «просто UPDATE» — вже транзакція?
Транзакція є завжди — навіть коли ти не писав BEGIN. За замовчуванням більшість клієнтів і драйверів працюють у режимі autocommit: кожен запит сам по собі стає мінітранзакцією, яку база фіксує одразу після виконання. Ти пишеш один UPDATE — під капотом він проходить як BEGIN ... UPDATE ... COMMIT. Так поводяться psql, консоль MySQL, драйвери JDBC і node-postgres. Прямий і небезпечний наслідок: якщо треба згрупувати кілька запитів у неподільне ціле, autocommit цього не зробить — кожен закомітиться окремо, і збій посередині лишить частковий запис. Групування вимагає явного BEGIN ... COMMIT.
Чому кілька UPDATE підряд без BEGIN — це не одна транзакція?
Бо в режимі autocommit кожен запит — окрема транзакція з власним комітом. Два UPDATE підряд без BEGIN закомітяться незалежно: перший стане постійним одразу, ще до того, як виконається другий. Якщо між ними впаде процес, обірветься мережа чи другий запит кине помилку — перший уже незворотний, і база лишається в суперечливому стані (списали з одного рахунку, не зарахували на інший). Щоб операції були атомарними, їх треба явно огорнути в BEGIN ... COMMIT, і тоді помилка на будь-якому кроці веде до відкату всього. Це найчастіша причина «транзакційних» багів на практиці.
Що таке частковий запис (partial write) і чому це класичний баг?
Частковий запис — це коли багатокрокова операція виконалася наполовину й лишила базу в суперечливому стані. Механізм завжди один: кілька залежних записів зробили без спільної транзакції (або в autocommit, де кожен закомітився окремо), і на півдорозі щось урвалося. У даних це виглядає так: замовлення створене, але рядків у order_items немає; з рахунку списано, на інший не зараховано; користувач створений, а профіль — ні; оплата пройшла у платіжці, а статус замовлення лишився pending. З транзакцією помилка на будь-якому кроці веде до ROLLBACK, і база лишається цілою. Це улюблене питання співбесід, бо перевіряє, чи бачиш звʼязок між відсутньою транзакцією і реальним дефектом цілісності.
Як цілеспрямовано перевірити, що багатокроковий флоу атомарний?
Взяти флоу, що змінює кілька таблиць, зімітувати збій посеред нього, а потім SELECT-ом перевірити, що не лишилося напівстворених сутностей. Способи зімітувати збій: обірвати мережу між кроками, надіслати невалідні дані на другому кроці, спровокувати помилку унікальності чи зовнішнього ключа. Після штучного збою робиш SELECT по всіх задіяних таблицях: якщо висить замовлення без позицій або списання без зарахування — операція неатомарна. Важливий нюанс атрибуції: якщо після збою база чиста — атомарність працює; якщо лишилися «дірки» — це баг застосунку, а не тесту. Такий негативний сценарій цінніший за щасливий шлях, бо саме він ловить відсутню транзакцію.
Чому «C» (Consistency) в ACID вважають найслабшою й найдискусійнішою літерою?
Бо база гарантує лише оголошені обмеження, а не будь-який бізнес-інваріант. Consistency означає, що транзакція не лишить базу в стані, який порушує первинні й зовнішні ключі, NOT NULL, CHECK, UNIQUE чи тригери — усе, що явно описано в схемі. Але правило на кшталт «сума позицій має дорівнювати сумі замовлення», якщо воно ніде не виражене обмеженням, — відповідальність застосунку, а не СУБД. Тому багато хто вважає, що «C» частково спирається на інші літери й тримається на коректності самого коду. Для QA практичний висновок: перевіряти треба і те, чи ловляться порушення оголошених обмежень, і те, чи застосунок сам стежить за неописаними інваріантами.
Що таке SAVEPOINT і навіщо він потрібен?
SAVEPOINT ставить іменовану позначку всередині відкритої транзакції — своєрідний чекпоінт, до якого можна повернутися. ROLLBACK TO SAVEPOINT скасовує лише те, що сталося після позначки: транзакція лишається відкритою, зроблене до позначки — на місці; сам savepoint не зникає (його прибирає RELEASE SAVEPOINT). Це дає частковий відкат: скасувати не всю транзакцію, а лише її останній невдалий шматок. Окремо savepoint рятує в PostgreSQL, де будь-яка помилка абортує всю транзакцію, — огорнувши ризикований запит у savepoint, можна відкотитися лише до нього й продовжити. Саме так драйвери реалізують «спробувати, а при помилці піти іншим шляхом».
Як технічно забезпечується durability?
Через журнал попереднього запису (write-ahead log): перш ніж підтвердити COMMIT, база фіксує зміни в журналі на диску. У PostgreSQL це WAL, у MySQL/InnoDB — redo log. Логіка проста: перш ніж сказати клієнту «успіх», зміни вже мають лежати в надійному журналі. Після краху база програє журнал і відновлює всі підтверджені транзакції, тому COMMIT — це обіцянка «повернувся успіх — значить, дані переживуть перезавантаження». Для QA це основа тесту на тривкість: закомітити дані, зімітувати рестарт сервісу й переконатися, що вони на місці.
Що таке ізоляція транзакцій і чому в неї є рівні?
Isolation означає, що паралельні транзакції не заважають одна одній: в ідеалі кожна працює так, ніби вона в базі сама, а результат конкурентного виконання не відрізняється від послідовного. Але сувора ізоляція коштує швидкодії — доводиться більше блокувати, і паралелізм падає. Тому стандарт SQL визначає чотири рівні ізоляції (READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE), і слабші рівні свідомо допускають певні аномалії (брудне читання, неповторюване читання, фантоми) заради продуктивності. Вибір рівня — компроміс між коректністю під конкурентністю і швидкістю. Це велика окрема тема: блокування, гонки й аномалії розбираються у главі про рівні ізоляції.
Як використати ROLLBACK для клінапу в автотестах?
Огорнути кожен тест у транзакцію й наприкінці зробити ROLLBACK замість COMMIT — база сама «забуває» все, що тест наробив, без ручного DELETE чи TRUNCATE. У beforeEach відкриваєш транзакцію (BEGIN), у afterEach — ROLLBACK і повертаєш зʼєднання в пул. Прийом швидкий (відкат дешевший за видалення рядків) і надійно ізолює тести один від одного. Але в нього жорстке обмеження: він працює лише коли код під тестом ходить у базу тим самим зʼєднанням, бо незакомічена транзакція видима тільки всередині свого конекшена. Тому це прийом переважно для інтеграційних тестів бекенду з однією базою.
Чому rollback-per-test не працює в e2e через застосунок?
Бо незакомічену транзакцію видно лише всередині того зʼєднання, у якому вона відкрита. У e2e-тесті (наприклад, через Playwright) застосунок працює через власний пул зʼєднань, окремий від того, де тест відкрив BEGIN. Він твоєї відкритої транзакції не побачить взагалі, а свої зміни закомітить сам своїм конекшеном. Наслідок — твій ROLLBACK не скасує те, що записав бекенд, і дані просочуються між тестами, хоча «клінап начебто є». Тому для e2e беруть інші стратегії: унікальні дані на прогін, видалення створеного через API після тесту, окремий tenant або перестворення схеми.
Після відкоту транзакції в автоінкрементному ключі зʼявилися пропуски в ID. Це баг?
Ні, це нормальна поведінка. Відкат стосується даних, а лічильники живуть окремим життям: SEQUENCE у PostgreSQL і AUTO_INCREMENT у MySQL не відкочуються. Невдала вставка все одно «спалює» наступний ID, тому в автоінкрементних ключах зʼявляються пропуски — це свідома ціна конкурентності, щоб послідовність могла роздавати ID паралельним транзакціям без блокування. Практичний наслідок для тестів: не можна асертити конкретне значення на кшталт id = 5 — воно залежить від історії відкатів і паралельних вставок, і такий тест впаде дарма. Перевіряй факт створення й звʼязки, а не точне число ключа.
Чи відкочуються DDL-команди в транзакції?
Залежить від СУБД. У PostgreSQL DDL транзакційний: CREATE TABLE чи ALTER TABLE всередині транзакції відкочуються нормально разом з рештою. У MySQL більшість DDL-команд викликають неявний коміт: вони закривають поточну транзакцію, і відкотити їх не можна — те, що ти створив, лишиться навіть після ROLLBACK. Це часта пастка при переносі очікувань з однієї бази на іншу: сценарій «створю таблицю в транзакції й відкочу» у Postgres чистий, а в MySQL лишить сміття. Тому перед тестами зі схемою треба знати, як конкретна СУБД поводиться з DDL.
Чи може COMMIT впасти, і що з цього випливає для рев'ю коду?
Так — COMMIT теж може завершитися помилкою: порушення відкладеного обмеження (deferred constraint), конфлікт серіалізації на рівні SERIALIZABLE, обрив звʼязку. Небезпека в тому, що код, який не перевіряє результат саме COMMIT, вважає транзакцію успішною, хоча вона насправді відкотилась. Це тихий баг: користувач бачить «збережено», а даних немає. На рев'ю варто дивитися, чи обробляється помилка коміту, а не лише помилки окремих запитів усередині транзакції. Для QA це підказка, де копати: якщо дані «іноді не зберігаються без жодної помилки в UI» — перевір, чи не проковтнута помилка на COMMIT.
У GUI-клієнті ти зробив UPDATE, а колега на іншому зʼєднанні змін не бачить. Чому?
Найімовірніше, клієнт працює в режимі ручного коміту (manual commit), і твій UPDATE висить у незакоміченій транзакції. DBeaver, DataGrip і подібні мають перемикач autocommit / manual commit; у ручному режимі клієнт не комітить сам — ти виконав запит, побачив «1 row affected», але доки не натиснув Commit, зміни бачить лише твоя сесія. Гірше: незакомічена транзакція тримає блокування, і якщо закрити клієнт, вона просто зникне разом зі змінами. Тому на спільному стенді завжди варто знати, у якому режимі коміту працює твій клієнт, — це рятує від «я ж усе зберіг, а на стенді нічого немає».
У Postgres після однієї помилки всі наступні запити падають з «current transaction is aborted». Чому і як бути?
Бо в PostgreSQL будь-яка помилка запиту абортує всю транзакцію: після невдалого запиту наступні відхиляються з current transaction is aborted, commands ignored until end of transaction block, аж поки не буде ROLLBACK. Це свідома поведінка — база не дає продовжувати роботу в потенційно неузгодженому стані. Вихід залежить від наміру: якщо помилка фатальна — робиш ROLLBACK і починаєш заново; якщо запит був ризикований і хочеш продовжити транзакцію — заздалегідь огортаєш його в SAVEPOINT і після помилки робиш ROLLBACK TO SAVEPOINT. У MySQL поведінка інакша: помилка окремого запиту не валить усю транзакцію автоматично, тому цей симптом специфічний саме для Postgres.
Три кейси, де транзакція вирішує, чи зелений тест: відтворити частковий запис і довести, що баг у застосунку, а не в тесті; налаштувати rollback-per-test для інтеграційних тестів і побачити його межу; і таблиця відмінностей Postgres проти MySQL, на якій легко зловити хибне очікування. Скрізь — що дивитися і чому.
Кейс 1. Відтворити частковий запис і атрибутувати баг
Флоу «створити замовлення з позиціями» пише у дві таблиці — orders і order_items. У даних зрідка трапляються замовлення без жодної позиції. Питання не «як почистити», а «чи операція атомарна». Спершу подивимось, як цей баг народжується без транзакції в режимі autocommit:
-- Два незалежні коміти: перший стане постійним ще до падіння другого
INSERT INTO orders (customer_id) VALUES (42); -- закомічено одразу
INSERT INTO order_items (order_id, sku) VALUES (999, 'X'); -- падає на FK: order_id 999 немає
-- результат: замовлення-сирота вже в базі, відкотити нічим
Тепер відтворюємо збій навмисно й перевіряємо стан SELECT-ом:
-- 1) зафіксувати стан до
SELECT count(*) FROM orders;
-- 2) прогнати флоу зі збоєм на другому кроці (невалідний SKU, обрив, дубль унікального)
-- 3) шукати сироту: замовлення без жодної позиції
SELECT o.id
FROM orders o
LEFT JOIN order_items i ON i.order_id = o.id
WHERE i.order_id IS NULL; -- рядки тут = неатомарна операція
Ту саму перевірку зручно зробити через API замість ручного SQL — надіслати завідомо биту другу частину й переконатися, що сирота не лишилась:
test('замовлення не створюється частково при збої на кроці позицій', async ({ request }) => {
// валідна шапка, невалідні позиції — сервер має відхилити ВЕСЬ запит
const res = await request.post('/api/orders', {
data: { customerId: 42, items: [{ sku: 'DOES-NOT-EXIST' }] },
});
expect(res.status()).toBe(422);
// головна перевірка: жодного замовлення-сироти
const orphans = await request.get('/api/orders?customerId=42&withoutItems=true');
expect(await orphans.json()).toEqual([]);
});
Що дивитися і чому:
- Сирота після штучного збою = баг застосунку, а не тесту. Якщо
LEFT JOINпоказав замовлення без позицій, значить, кроки не загорнуті в спільну транзакцію (або кожен закомітився окремо в autocommit). Фікс — на боці коду:BEGIN ... COMMITнавколо обох вставок, тоді помилка на другому кроці відкотить перший. - Атрибуція подвійна. Тригер прийшов від тесту (навмисно битий SKU), але реакція «зберегти півзамовлення» — окремий дефект бекенду. Фіксуй обидві складові, а не тільки «тест впав».
SELECTдо і після — обовʼязковий. Без знімка стану «до» неможливо довести, що саме цей прогін лишив сироту, а не попередній.
Кейс 2. rollback-per-test: коли клінап працює, а коли протікає
Найдешевший клінап для інтеграційних тестів — огорнути кожен тест у транзакцію й наприкінці відкотити її. База сама забуває все, що тест наробив, без DELETE і TRUNCATE.
import { Pool, PoolClient } from 'pg';
const pool = new Pool();
let client: PoolClient;
beforeEach(async () => {
client = await pool.connect();
await client.query('BEGIN'); // одна транзакція на весь тест
});
afterEach(async () => {
await client.query('ROLLBACK'); // база повертається до стану до тесту
client.release();
});
test('повторне нарахування бонусів ідемпотентне', async () => {
// ВАЖЛИВО: код під тестом мусить ходити саме цим client, а не своїм пулом
await grantBonus(client, { userId: 1, amount: 100 });
await grantBonus(client, { userId: 1, amount: 100 });
const { rows } = await client.query(
'SELECT coalesce(sum(amount), 0) AS total FROM bonuses WHERE user_id = 1'
);
expect(Number(rows[0].total)).toBe(100); // друге нарахування не дублюється
});
А ось та сама ідея, яка тихо не спрацює в e2e через застосунок:
// НЕ ізолює: бекенд ходить власним пулом і не бачить нашого BEGIN
await page.goto('/checkout');
await page.getByTestId('pay').click();
// afterEach робить ROLLBACK нашого клієнта, але замовлення,
// яке бекенд закомітив своїм конекшеном, лишається в базі
Що дивитися і чому:
- Незакомічена транзакція видима лише всередині свого зʼєднання. Прийом надійний тільки коли тест і код-під-тестом ділять один конекшен. Щойно застосунок бере власний пул (а Playwright-e2e — саме так), твій
ROLLBACKне чіпає його комітів, і дані просочуються між тестами. - Rollback не тестує реальний
COMMIT. Відкладені обмеження, тригери на коміт, поведінка при фактичному записі — усе це rollback-підхід пропускає. Для e2e бери інші стратегії: унікальні дані на прогін, видалення через API, окремий tenant. - Швидкість — бонус, не мета. Відкат дешевший за видалення рядків, але вибирати його треба за ізоляцією, а не за секундами.
Кейс 3. Postgres проти MySQL: де хибне очікування валить тест
Половина «транзакційних» несподіванок — це перенесення поведінки однієї СУБД на іншу. Ось що реально відрізняється:
| Що робимо | PostgreSQL | MySQL (InnoDB) |
|---|---|---|
ROLLBACK даних (INSERT/UPDATE/DELETE) | відкочується | відкочується |
SEQUENCE / AUTO_INCREMENT після відкоту | не відкочується, ID «спалений» | не відкочується, ID «спалений» |
DDL (CREATE TABLE, ALTER TABLE) у транзакції | транзакційний, відкочується | неявний COMMIT, відкотити не можна |
| Помилка одного запиту в транзакції | абортує всю транзакцію | не валить транзакцію автоматично |
Як це б'є по тестах:
- Асерт на конкретний ID — крихкий у будь-якій СУБД.
SEQUENCEіAUTO_INCREMENTне відкочуються, тож після невдалої вставки чи паралельного прогону в ключах будуть пропуски. Тест, що чекає рівноid = 5, впаде дарма — перевіряй факт створення й звʼязки, не число. - «Створю таблицю в транзакції й відкочу» чистий лише в Postgres. У MySQL
CREATE TABLEробить неявний коміт: тестова схема лишиться навіть післяROLLBACK, і наступний прогін спіткнеться об уже наявний обʼєкт. - Скрипт «спробуй, при помилці продовж» поводиться по-різному. У MySQL помилка одного запиту не рве транзакцію, тож наступні виконаються. У Postgres після помилки все падає з
current transaction is aborted— щоб продовжити, ризикований запит треба заздалегідь огорнути вSAVEPOINTі зробитиROLLBACK TO SAVEPOINT.
Транзакція і базові команди
- Знаю, що транзакція — неподільна одиниця роботи: назовні видно або всі операції, або жодну.
- Можу пояснити роль
BEGIN/COMMIT/ROLLBACKі що післяCOMMITзміни постійні й видимі всім сесіям. - Розумію, що до
COMMITзміни транзакції бачить лише вона сама, а інша сесія читає старі значення.
ACID
- Можу розшифрувати ACID і дати приклад на кожну літеру, а не просто перелічити слова.
- Розумію Atomicity як «усе або нічого»: помилка на будь-якому кроці скасовує всю транзакцію.
- Знаю, що Consistency гарантує лише оголошені обмеження, а неописані бізнес-інваріанти — відповідальність застосунку.
- Розумію, що Isolation має рівні, і слабші рівні свідомо допускають аномалії заради швидкодії.
- Знаю, що Durability тримається на журналі попереднього запису (WAL у Postgres, redo log у MySQL/InnoDB).
autocommit
- Розумію, що транзакція є завжди: у режимі autocommit кожен запит комітиться окремо під капотом.
- Можу пояснити, чому кілька
UPDATEпідряд безBEGIN— не одна транзакція, і чому це веде до часткового запису. - Памʼятаю про режим ручного коміту в GUI-клієнтах: незакомічені зміни тримають блокування і зникають при закритті.
SAVEPOINT і часткові відкати
- Знаю, що
SAVEPOINTдає частковий відкат:ROLLBACK TO SAVEPOINTскасовує роботу після точки, лишаючи транзакцію відкритою. - Розумію, що в PostgreSQL помилка абортує всю транзакцію (
current transaction is aborted), а savepoint дозволяє її врятувати; у MySQL — ні.
Частковий запис як баг
- Розумію механізм часткового запису: залежні записи без спільної транзакції плюс збій посередині.
- Знаю типові прояви в даних: замовлення без позицій, списання без зарахування, юзер без профілю.
- Можу спланувати перевірку: зімітувати збій посеред флоу й
SELECT-ом переконатися, що не лишилося напівстворених сутностей.
Відкат як клінап і типові пастки
- Знаю прийом rollback-per-test і його межу: працює лише на одному зʼєднанні, через застосунок з окремим пулом — ні.
- Памʼятаю, що rollback не тестує реальний
COMMIT(відкладені обмеження, тригери на коміт). - Знаю, що
SEQUENCE/AUTO_INCREMENTне відкочуються — пропуски в ID це норма, а не баг. - Розумію різницю в DDL (Postgres транзакційний, MySQL робить неявний коміт) і що
COMMITтеж може впасти.
Яке з визначень транзакції правильне?
Питання
Що таке транзакція?