Транзакція задає межу, усередині якої база бачить вашу послідовність команд як одну операцію. Між BEGIN і COMMIT інші сесії не бачать нічого з написаного, а після ROLLBACK не побачать і поготів. Без явного BEGIN кожна команда сама собі транзакція: autocommit увімкнений і в PostgreSQL, і в MySQL, тому одиночний UPDATE атомарний, а три пов'язані UPDATE підряд уже ні. Механіка відкату різна: InnoDB програє назад undo log, PostgreSQL просто позначає транзакцію перерваною, а зайві версії рядків згодом прибирає autovacuum.
ACID розкладається на чотири різні за вагою обіцянки. Atomicity дає «все або нічого» і саме вона рятує, коли посеред створення замовлення вилітає виняток. Consistency означає, що після COMMIT не порушене жодне оголошене обмеження: NOT NULL, UNIQUE, FOREIGN KEY, CHECK. База перевіряє тільки те, що ви описали в схемі; правило «сума позицій дорівнює сумі замовлення» вона не вигадає. Isolation задає, наскільки паралельні транзакції бачать роботу одна одної, і тут ховається головний сюрприз: дефолт у PostgreSQL READ COMMITTED, в InnoDB REPEATABLE READ, і жоден з них не еквівалентний послідовному виконанню. Durability обіцяє, що закомічене переживе падіння процесу й сервера, бо запис у WAL скидається на диск до відповіді клієнту; рівень цієї гарантії регулюють innodb_flush_log_at_trx_commit разом із sync_binlog у MySQL та synchronous_commit у PostgreSQL, і зниження заради швидкості коштує останніх секунд транзакцій.
Найчастіша практична помилка зовсім не про літери абревіатури. Транзакція керує лише вмістом бази. Лист, який Mailgun уже прийняв, платіж, який шлюз уже провів, задача, яку воркер уже витягнув з Redis, файл, записаний на диск, скинутий кеш - усього цього ROLLBACK не повертає. Так з'являється класичний баг: клієнт отримав підтвердження замовлення, а замовлення в базі немає. Ліки два. Побічні ефекти виносять за COMMIT (DB::afterCommit, dispatch()->afterCommit(), параметр after_commit у конфігу черги) або пишуть подію в таблицю outbox тією ж транзакцією, а надсилає її окремий процес. Оскільки повтор при такій схемі неминучий, кожна зовнішня дія має ключ ідемпотентності, який приймач звіряє перед виконанням.
Друга біда - довжина транзакції. Поки вона відкрита, змінені рядки заблоковані, у PostgreSQL autovacuum не може прибрати старі версії по всій базі, а в InnoDB росте undo-історія. Тримати транзакцію на час запиту до платіжного API означає тримати блокування три секунди на кожен виклик, і в пікові хвилини це перетворюється на чергу з lock wait timeout. Тому транзакція починається перед першим записом і закінчується одразу після останнього, а мережеві виклики й важкі обчислення лишаються ззовні. Корисно одразу поставити idle_in_transaction_session_timeout у PostgreSQL: сесія, яка забула закомітити, буде вбита, а не проживе до перезапуску воркера.
І ще одна річ, про яку на junior-рівні забувають: відкат повертає рядки, але не лічильники. Значення AUTO_INCREMENT і sequence після ROLLBACK не перевикористовуються, тож діри в нумерації - нормальна поведінка, а не пошкоджені дані. А якщо таблиця раптом на MyISAM, ніякої транзакції там немає взагалі: рушій мовчки виконає команди й проігнорує ROLLBACK.
-- Одна бізнес-дія = одна транзакція: або є замовлення, позиції й резерв, або немає нічого
BEGIN;
-- request_id приходить із запиту, UNIQUE(request_id) не дає створити дубль при ретраї
INSERT INTO orders (user_id, request_id, status)
VALUES (42, '8f3c1d', 'new')
ON CONFLICT (request_id) DO NOTHING
RETURNING id; -- порожньо = замовлення вже створила попередня спроба
INSERT INTO order_items (order_id, sku, qty) VALUES (1001, 'kbd-87', 2);
-- Перевірка залишку й резерв однією командою, без читання в PHP
UPDATE stock SET reserved = reserved + 2
WHERE sku = 'kbd-87' AND quantity - reserved >= 2;
-- 0 оновлених рядків -> застосунок робить ROLLBACK, і обидва INSERT зникають разом із ним
COMMIT; -- тільки тепер зміни видно іншим сесіям і вони переживуть падіння сервера
-- Антипатерн: зовнішній виклик усередині транзакції
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 1001;
-- HTTP до платіжного шлюзу на три секунди: рядок заблокований увесь цей час,
-- а якщо шлюз списав гроші й після цього впав PHP, ROLLBACK прибере лише запис у базі
COMMIT;
-- PostgreSQL: DDL відкочується нарівні з даними
BEGIN; ALTER TABLE orders ADD COLUMN note text; ROLLBACK; -- колонки не буде
-- MySQL: цей самий ALTER робить неявний COMMIT, відкотити його вже неможливо
Скажіть одним реченням, де проходить межа: усе, що база записала, вона вміє відкотити; усе, що покинуло базу, - ні. Далі наведіть приклад з листом після ROLLBACK, і співбесідник почує досвід, а не вивчену абревіатуру.