Транзакции и ACID

Транзакция — это группа запросов, которая выполняется как единое целое: либо все изменения применяются, либо ни одного.

Транзакция — логическая единица работы с базой данных, которая переводит её из одного согласованного состояния в другое. Между BEGIN и COMMIT все запросы образуют один неделимый блок.

Представьте перевод денег: со счёта Алисы списали 100 рублей, а зачислить Бобу не успели — упало соединение, кончилось место на диске, сервер перезагрузился. Деньги исчезли. Без транзакций любой сбой между двумя UPDATE оставляет данные в «полусогласованном» состоянии, которое потом мучительно чинят руками. Транзакции дают гарантию: либо обе операции случились, либо ни одной.

Этот раздел — про то, как СУБД защищает данные при сбоях и при одновременной работе многих пользователей. Начнём с фундамента: что такое транзакция, какими командами ею управляют и какие гарантии стоят за аббревиатурой ACID.

Зачем это на практике

Любая операция, которая меняет несколько строк или таблиц и должна остаться целостной, — кандидат на транзакцию. Типичные сценарии:

  • Финансы: перевод, оплата заказа, начисление бонусов — деньги не должны «теряться» или «удваиваться».
  • Заказ в магазине: создать заказ, списать товар со склада, уменьшить остаток — всё вместе или никак.
  • Регистрация: создать пользователя, профиль и стартовые настройки тремя INSERT — чтобы не осталось пользователя без профиля.

BEGIN, COMMIT, ROLLBACK

Управление транзакцией — это три команды. BEGIN (или START TRANSACTION) открывает блок, COMMIT подтверждает и сохраняет всё разом, ROLLBACK отменяет все изменения с момента BEGIN, будто их не было.

CREATE TABLE accounts (
  id    INTEGER PRIMARY KEY,
  owner TEXT NOT NULL,
  balance INTEGER NOT NULL
);
INSERT INTO accounts (id, owner, balance) VALUES (1, 'Алиса', 500), (2, 'Боб', 100);

-- перевод 100 рублей от Алисы к Бобу как единое целое
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE owner = 'Алиса';
  UPDATE accounts SET balance = balance + 100 WHERE owner = 'Боб';
COMMIT;

SELECT owner, balance FROM accounts ORDER BY id;

Вывод:

Алиса|400
Боб|200

Если бы между двумя UPDATE что-то сломалось, незакоммиченные изменения откатились бы автоматически — баланс Алисы остался бы 500. А вот пример осознанной отмены: проверили условие и решили не продолжать.

CREATE TABLE accounts (id INTEGER PRIMARY KEY, owner TEXT, balance INTEGER);
INSERT INTO accounts VALUES (1, 'Алиса', 50);

BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE owner = 'Алиса';
  -- баланс ушёл в минус -50, передумали и откатываем
ROLLBACK;

SELECT owner, balance FROM accounts;

Вывод:

Алиса|50

После ROLLBACK баланс снова 50 — изменение как будто не происходило.

Автокоммит: почему обычный UPDATE и так «сохраняется»

Когда вы пишете одиночный INSERT без BEGIN, он всё равно выполняется в транзакции — просто СУБД сама оборачивает каждую команду в BEGIN ... COMMIT. Это режим автокоммита. Поэтому отдельный запрос либо отрабатывает целиком, либо никак. Явный BEGIN нужен, когда в один атомарный блок надо собрать несколько команд.

SAVEPOINT: точки сохранения внутри транзакции

SAVEPOINT — это «закладка» внутри открытой транзакции. К ней можно откатиться через ROLLBACK TO, не отменяя всю транзакцию целиком. Удобно, когда часть работы рискованная, а остальное надо сохранить.

CREATE TABLE accounts (id INTEGER PRIMARY KEY, owner TEXT, balance INTEGER);
INSERT INTO accounts VALUES (1, 'Алиса', 500), (2, 'Боб', 100);

BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE owner = 'Алиса';
  SAVEPOINT after_debit;
  UPDATE accounts SET balance = balance + 999 WHERE owner = 'Боб'; -- ошиблись в сумме
  ROLLBACK TO after_debit;                                        -- откат только этого шага
  UPDATE accounts SET balance = balance + 100 WHERE owner = 'Боб';-- правильная сумма
COMMIT;

SELECT owner, balance FROM accounts ORDER BY id;

Вывод:

Алиса|400
Боб|200

Списание у Алисы сохранилось, ошибочное зачисление откатилось до закладки, а финальный COMMIT зафиксировал корректный итог.

ACID простыми словами

Гарантии транзакций описывают четырьмя свойствами — ACID. Это не магия, а контракт, который СУБД старается соблюдать.

СвойствоСмысл одним предложением
A — Atomicity (атомарность)«всё или ничего»: либо применяются все изменения транзакции, либо ни одного.
C — Consistency (согласованность)транзакция переводит базу из одного валидного состояния в другое, не нарушая ограничений (CHECK, внешние ключи, уникальность).
I — Isolation (изоляция)параллельные транзакции не видят «полуготовых» изменений друг друга — будто выполняются по очереди.
D — Durability (долговечность)после COMMIT данные переживут падение сервера — они уже на диске.

В примере с переводом: атомарность не даст списать без зачисления; согласованность запретит уйти в минус, если есть CHECK (balance >= 0); изоляция скроет промежуточное состояние от других; долговечность сохранит результат после COMMIT, даже если через секунду выключат питание.

Как это работает под капотом

Главный механизм долговечности и атомарности — журнал упреждающей записи (write-ahead log, WAL). Прежде чем изменить страницу данных, СУБД записывает в журнал на диск намерение: «строку X меняем так-то». Только после того как запись в журнале гарантированно лежит на диске, изменение считается зафиксированным.

Отсюда обе гарантии. Atomicity: если транзакция оборвалась, при восстановлении СУБД по журналу откатывает её незавершённые изменения. Durability: если сервер упал сразу после COMMIT, при старте система «доигрывает» из журнала подтверждённые, но ещё не записанные в основные файлы изменения. Поэтому COMMIT иногда заметно медленнее обычного запроса — он ждёт физической записи журнала (fsync) на диск.

Частые ошибки

  • Забыть COMMIT. Открыли BEGIN, выполнили запросы и ушли — транзакция висит, держит блокировки, тормозит остальных. Незакрытая транзакция при разрыве соединения откатывается, и работа теряется.
  • Длинные транзакции. Чем дольше открыта транзакция, тем дольше держатся блокировки и копятся старые версии строк. Делайте транзакции короткими: только запросы, между ними — никаких сетевых вызовов и ожиданий ввода пользователя.
  • Бизнес-логика между запросами. Не держите транзакцию открытой, пока ходите в платёжный шлюз или ждёте ответа API: внешний сервис может зависнуть на минуты, а ваша транзакция всё это время блокирует строки.
  • Расчёт на «само сохранится». В клиентах с выключенным автокоммитом одиночный INSERT не попадёт в базу без явного COMMIT — частый сюрприз при работе через драйвер.

Итоги

  • Транзакция — неделимая группа запросов: BEGIN открывает, COMMIT фиксирует, ROLLBACK отменяет всё с момента BEGIN.
  • SAVEPOINT + ROLLBACK TO позволяют откатить часть транзакции, сохранив остальное.
  • ACID: атомарность (всё или ничего), согласованность (без нарушения ограничений), изоляция (параллельные не мешают), долговечность (переживает сбой).
  • За долговечность и атомарность отвечает журнал упреждающей записи (WAL); поэтому COMMIT ждёт записи на диск.
  • Держите транзакции короткими — это меньше блокировок и меньше конфликтов.
Проверьте себя
1. Что произойдёт после ROLLBACK?
AВсе изменения с момента BEGIN отменяются, будто их не было
BСохраняются только успешные UPDATE, остальные отменяются
CТранзакция фиксируется и данные записываются на диск
DОткатывается только последний запрос перед ROLLBACK
2. Какое свойство ACID гарантирует, что после COMMIT данные не потеряются даже при падении сервера?
AAtomicity (атомарность)
BConsistency (согласованность)
CIsolation (изоляция)
DDurability (долговечность)
3. Зачем нужен SAVEPOINT?
AЧтобы откатить всю транзакцию целиком до её начала
BЧтобы поставить закладку внутри транзакции и откатиться к ней, не отменяя всё
CЧтобы автоматически сохранять изменения каждые несколько секунд
DЧтобы ускорить COMMIT, минуя запись в журнал