Транзакции и ACID в базах данных простыми словами
Основы4 минРедакция Innovialabs
Коротко
Транзакция объединяет несколько операций с базой в единое целое «всё или ничего»; ACID означает, что она либо выполняется полностью, либо не выполняется вовсе (атомарность), сохраняет данные корректными (согласованность), не путается с параллельными транзакциями (изоляция) и переживает сбой после фиксации (долговечность).
Коротко о главном
Транзакция — группа операций с базой данных, которые должны выполниться или не выполниться вместе. Классический пример — перевод денег: списать 100 у Алишера и зачислить 100 Бобуру. Если сервер упадёт между этими шагами, деньги не должны исчезнуть.
ACID — четыре гарантии, которые делают это безопасным:
| Буква | Свойство | На примере перевода |
|---|---|---|
| A | Атомарность (Atomicity) | Выполняются оба обновления или ни одного |
| C | Согласованность (Consistency) | Баланс не уходит в минус, если это запрещено правилом |
| I | Изоляция (Isolation) | Параллельный перевод не видит недоделанных данных |
| D | Долговечность (Durability) | После «успешно» перевод переживёт отключение питания |
Перевод на SQL
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- Алишер
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- Бобур
COMMIT;
BEGIN(илиSTART TRANSACTION) открывает транзакцию.COMMITфиксирует все изменения и делает их видимыми для других.ROLLBACKотменяет всё, что сделано послеBEGIN.
Если посередине что-то пошло не так, выполняется ROLLBACK, и база возвращается в состояние до перевода.
Атомарность: всё или ничего
База отслеживает каждое изменение внутри транзакции. Если транзакцию откатили или соединение оборвалось до COMMIT, изменения отбрасываются. Не бывает состояния, в котором у Алишера списали 100, а Бобур их не получил.
Согласованность: правила всегда соблюдены
Согласованность означает, что транзакция переводит базу из одного корректного состояния в другое. «Корректность» задают ваши ограничения:
ALTER TABLE accounts
ADD CONSTRAINT balance_not_negative CHECK (balance >= 0);
Если у Алишера только 50, первый UPDATE нарушит проверку, оператор завершится ошибкой, и транзакцию нельзя будет зафиксировать. Так же работают внешние ключи, NOT NULL и UNIQUE. База следит за правилами, которые вы объявили; необъявленные бизнес-правила остаются задачей приложения.
Изоляция: параллельные транзакции не мешают друг другу
В реальной системе одновременно идёт множество транзакций. Без изоляции два перевода со счёта Алишера могли бы оба прочитать баланс 100, оба списать по 100 и оставить счёт в неверном состоянии.
Базы предлагают уровни изоляции — от более слабых и быстрых к строгим:
| Уровень | От чего защищает |
|---|---|
| Read Uncommitted | Почти ни от чего; можно прочитать незафиксированные данные |
| Read Committed | От грязного чтения: видны только зафиксированные данные |
| Repeatable Read | Ещё от неповторяемого чтения: прочитанная строка не меняется у вас под ногами |
| Serializable | Ещё от фантомов: результат такой, будто транзакции шли по очереди |
Значения по умолчанию отличаются: в PostgreSQL — Read Committed, в MySQL с InnoDB — Repeatable Read. Точное поведение на каждом уровне тоже различается между СУБД, так что сверяйтесь с документацией своей базы.
Для перевода на уровне Read Committed заблокируйте строки, которые будете менять:
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- проверяем баланс в приложении, затем:
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
FOR UPDATE заставит второй перевод с того же счёта ждать, пока первый не завершится.
Долговечность: зафиксировано — значит сохранено
Когда COMMIT вернул успех, изменение записано в надёжный журнал на диске (в PostgreSQL это WAL, write-ahead log, в InnoDB — redo log). После сбоя база проигрывает журнал и восстанавливает зафиксированные данные. Долговечность защищает от перезапуска сервера, но не от потери самого диска — для этого нужны бэкапы и реплики.
Транзакции в коде приложения
Большинство драйверов и ORM оборачивают этот шаблон за вас. Идея везде одинакова:
with conn: # открывает транзакцию
with conn.cursor() as cur:
cur.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s", (100, 1))
cur.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", (100, 2))
# commit при успехе, rollback при исключении
Частые ошибки
- Неожиданный autocommit: без явной транзакции каждый оператор фиксируется сам, и сбой между двумя обновлениями оставляет полперевода.
- Долгие транзакции: держать транзакцию открытой, пока ждёте пользователя или внешний API, — значит держать блокировки и тормозить других.
- Вызов внешних сервисов внутри транзакции: откат не отменит отправленное SMS или платёжный запрос.
- Нет повторов: на уровне Serializable или при взаимных блокировках (deadlock) база может намеренно прервать транзакцию — приложение должно её повторить.