Skip to main content

ACID - atomicity

ACID atomicity (атомарність) гарантує, що транзакція в базі даних виконується як єдиний неподільний блок: або повністю, або взагалі ніяк.

Теорія

TL;DR

  • Аналогія: лист із грошима в конверті - або конверт доходить із вмістом, або не доходить взагалі.
  • Всі операції записуються в лог перед commit; будь-яка помилка запускає rollback до попереднього стану.
  • Два окремих SQL-запити поза транзакцією не мають жодних гарантій атомарності.
  • Взаємозалежні зміни в кількох таблицях → транзакція. Запити тільки на читання → пропускай.

Швидкий приклад

sql
-- Переказ грошей: атомарність у дії (PostgreSQL) BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- Знімаємо з Аліси UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- Зараховуємо Бобу COMMIT; -- Обидва успішні: Аліса $900, Боб $1100 -- Якщо другий UPDATE падає (наприклад, порушення constraint): -- Engine автоматично робить ROLLBACK -- Результат: нуль змін. Аліса $1000, Боб $1000

Обидві операції виконуються як одна. Якщо щось ламається між BEGIN і COMMIT, база скасовує всі зміни автоматично.

Ключова різниця

Без транзакції два окремих UPDATE - це дві незалежні операції. Один може пройти, другий впасти, і баланси стануть некоректними. Обгортка в BEGIN/COMMIT змушує базу сприймати їх як єдиний логічний крок із автоматичним rollback при будь-якій помилці.

Коли використовувати

  • Взаємозалежні оновлення кількох таблиць → обгорни в транзакцію.
  • Банківський переказ, створення замовлення, списання зі складу → завжди атомарно.
  • Одиночні ідемпотентні вставки → транзакція не потрібна.
  • Запити тільки на читання → пропускай, щоб не тримати зайвих блокувань.
  • Розподілені системи (мікросервіси) → використовуй 2PC або патерн saga замість однієї DB-транзакції.

Як атомарність працює всередині

PostgreSQL використовує WAL (Write-Ahead Logging): перед тим як змінити будь-яку сторінку даних, engine записує операцію в лог-файл і позначає межі транзакції. При COMMIT лог скидається на диск. Якщо стався crash до цього скидання, WAL при відновленні відтворює тільки завершені транзакції, а незавершені відкочуються за допомогою undo-логів із до-образами рядків. MVCC (Multi-Version Concurrency Control) створює snapshot-и, щоб паралельні транзакції бачили консистентний стан протягом усього процесу.

Одна річ, яка регулярно ламає команди в продакшені: довга транзакція тримає блокування на кожен рядок, якого вона торкнулась. Бачив, як це спричинило 10-хвилинний простій у Rails-застосунку, де batch-джоб відкривав BEGIN і не встигав робити COMMIT достатньо швидко.

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

Помилка: вкладений BEGIN без savepoint

sql
BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; BEGIN; -- PostgreSQL ігнорує це і попереджає, але продовжує зовнішню транзакцію UPDATE accounts SET balance = balance + 100 WHERE id = 2; ROLLBACK; -- Відкочує ВСЮ зовнішню транзакцію, а не тільки внутрішній блок

Більшість розробників очікують, що внутрішній ROLLBACK скасує лише другий update. Ні. Для часткового відкату використовуй SAVEPOINT:

sql
BEGIN; SAVEPOINT sp1; UPDATE accounts SET balance = balance + 100 WHERE id = 2; ROLLBACK TO sp1; -- Скасовує тільки другий update COMMIT;

Помилка: забутий client.release() у Node.js

javascript
const client = await pool.connect(); await client.query('BEGIN'); // ... запити ... await client.query('COMMIT'); // Немає client.release() тут → витік з'єднань під навантаженням, пул вичерпується

Завжди пиши finally { client.release(); }. Без винятків.

Помилка: довга транзакція

sql
BEGIN; SELECT * FROM large_table; -- Виконується 10 хвилин, тримає shared locks -- Усі writer-и заблоковані на цей час

Встанови statement timeout. Там, де можливо, використовуй READ ONLY транзакції. У продакшені тримай транзакції коротшими за 30 секунд.

Помилка: очікування, що crash застосунку відкатить усе

COMMIT, який скинувся в WAL, переживає crash. Якщо застосунок впав між відправкою COMMIT і отриманням підтвердження, транзакція на стороні бази може вже бути завершена. Перевіряй pg_stat_activity після несподіваних падінь і використовуй PREPARE TRANSACTION у розподілених сценаріях.

Де зустрічається в реальному світі

  • PostgreSQL: WAL + 2PC для XA-транзакцій (розподілений протокол) у банківських системах.
  • MySQL (InnoDB): binlog + undo-логи; Shopify використовує для обробки замовлень.
  • MongoDB: multi-document транзакції з v4.0; Stripe використовує для атомарності платежів.
  • SQL Server: TempDB для версіонування; поширений у enterprise ERP на Azure SQL.
  • Мікросервіси: DB-транзакція не перекриває кілька сервісів, тому команди використовують saga з Kafka Streams або outbox-патерн.

Питання на співбесіді

Q: Як атомарність взаємодіє з ізоляцією?
A: Атомарність - про "все або нічого" для однієї транзакції. Ізоляція контролює, що паралельні транзакції бачать під час виконання. PostgreSQL забезпечує обидва через MVCC snapshot-и.

Q: Як насправді працює rollback під капотом?
A: Engine читає undo-лог (до-образи змінених рядків) і застосовує їх у зворотному порядку. WAL при відновленні після crash відтворює тільки завершені транзакції, незавершені відкидає.

Q: Яка різниця між атомарністю та ідемпотентністю?
A: Атомарність означає, що транзакція або повністю завершується, або повністю відкочується. Ідемпотентність означає, що повторне виконання тієї ж операції дає той самий результат. POST /transfer може бути ідемпотентним (через унікальний transfer ID), навіть якщо DB-транзакція атомарна.

Q: Як забезпечити атомарність у мікросервісах?
A: Одна DB-транзакція не може охопити два сервіси. Стандартні підходи: 2PC (prepare/commit, використовується в XA з Java Spring) або saga-патерн, де кожен сервіс має компенсуючу транзакцію, яка скасовує свій крок при помилці далі по ланцюгу.

Q: Що відбувається, якщо транзакція перевищує lock timeout?
A: Engine перериває транзакцію з помилкою (код 40P01 для deadlock у PostgreSQL). Проектуй із NOWAIT або SKIP LOCKED. Моніторинг pg_locks допоможе знайти довгі ланцюги очікування до того, як вони стануть аварією.

Приклади

Базовий: банківський переказ із автоматичним rollback

sql
-- PostgreSQL: атомарний переказ BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- Аліса: $1000 -> $900 UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- Боб: $1000 -> $1100 COMMIT; -- Обидва рядки оновлено. Загальний баланс: $2000 -- Симуляція помилки: BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE accounts SET balance = balance + 100 WHERE id = 999; -- id=999 не існує -- Порушення constraint → автоматичний ROLLBACK -- Аліса залишається $1000. Жодного часткового стану.

Ключовий інваріант тут - загальний баланс. Після будь-якої транзакції, успішної чи ні, сума всіх балансів має залишатися сталою. Атомарність це і забезпечує.

Середній: створення замовлення (Node.js + PostgreSQL)

javascript
const { Pool } = require('pg'); const pool = new Pool({ connectionString: process.env.DATABASE_URL }); async function createOrder(userId, items) { const client = await pool.connect(); try { await client.query('BEGIN'); // Резервуємо товар на складі for (const item of items) { await client.query( 'UPDATE inventory SET stock = stock - $1 WHERE product_id = $2 AND stock >= $1', [item.quantity, item.productId] ); } // Створюємо запис замовлення const result = await client.query( 'INSERT INTO orders (user_id, total) VALUES ($1, $2) RETURNING id', [userId, items.reduce((sum, i) => sum + i.price * i.quantity, 0)] ); await client.query('COMMIT'); return result.rows[0].id; // orderId=123, залишки вже списані } catch (err) { await client.query('ROLLBACK'); throw err; // Жодних змін складу, жодного запису замовлення } finally { client.release(); // Завжди звільняй, навіть при помилці } }

Якщо списання зі складу пройшло, але вставка замовлення впала, блок catch відкочує всі зміни складу. Користувач бачить помилку. Жодного часткового стану.

Senior: deadlock при паралельних переказах

sql
-- Сесія 1 (Tx1): BEGIN; UPDATE accounts SET balance = balance - 50 WHERE id = 1; -- Блокує рядок 1 UPDATE accounts SET balance = balance + 50 WHERE id = 2; -- Чекає рядок 2 -- Сесія 2 (Tx2, одночасно): BEGIN; UPDATE accounts SET balance = balance - 50 WHERE id = 2; -- Блокує рядок 2 UPDATE accounts SET balance = balance + 50 WHERE id = 1; -- Чекає рядок 1 -- Deadlock: Tx1 чекає Tx2, Tx2 чекає Tx1 -- PostgreSQL виявляє цикл, перериває Tx2 з помилкою 40P01 -- Tx1 завершується успішно. Tx2 має повторити спробу.

Deadlock-и - пряме слідство атомарності разом із ізоляцією: кожна транзакція тримає блокування до commit-у. Рішення просте, але його легко забути. Завжди блокуй рядки в однаковому порядку в усіх транзакціях. Блокуй id=1 перед id=2 в кожному переказі, і цикл не утвориться.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?