ACID - atomicity
ACID atomicity (атомарність) гарантує, що транзакція в базі даних виконується як єдиний неподільний блок: або повністю, або взагалі ніяк.
Теорія
TL;DR
- Аналогія: лист із грошима в конверті - або конверт доходить із вмістом, або не доходить взагалі.
- Всі операції записуються в лог перед commit; будь-яка помилка запускає rollback до попереднього стану.
- Два окремих 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
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:
BEGIN;
SAVEPOINT sp1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
ROLLBACK TO sp1; -- Скасовує тільки другий update
COMMIT;Помилка: забутий client.release() у Node.js
const client = await pool.connect();
await client.query('BEGIN');
// ... запити ...
await client.query('COMMIT');
// Немає client.release() тут → витік з'єднань під навантаженням, пул вичерпуєтьсяЗавжди пиши finally { client.release(); }. Без винятків.
Помилка: довга транзакція
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
-- 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)
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 при паралельних переказах
-- Сесія 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 в кожному переказі, і цикл не утвориться.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.