Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «ACID - atomicity». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**ACID atomicity (атомарність)** означає, що транзакція виконується за принципом "все або нічого": або всі операції завершуються, або жодна не застосовується. ```sql BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE accounts SET balance = balance + 100 WHERE id = 2; COMMIT; -- Обидві успішні або обидві відкочуються при помилці ``` **Ключове:** жодного часткового стану після помилки. База або застосовує всі зміни, або жодної.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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 в кожному переказі, і цикл не утвориться.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.