Транзакции: ACID и что ломается без него

Транзакции: ACID и что ломается без него

Транзакция - это когда несколько операций должны выполниться как единое целое. Или всё, или ничего. Это страховка для твоих данных.

Пример из жизни

Перевод денег:

  1. списали со счёта A (-100)
  2. зачислили на счёт B (+100)

Если между шагом 1 и 2 какой-то краш - 100 денежных единиц исчезают в чёрной дыре. С транзакцией: либо оба шага выполнены, либо оба откачены. Третьего не дано.

BEGIN / COMMIT / ROLLBACK

Жизненный цикл транзакции: BEGIN, операции, развилка COMMIT или ROLLBACK

Это основная триада:

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
  • BEGIN - начало транзакции
  • COMMIT - фиксируем все изменения в базе
  • ROLLBACK - отменяем всё

Если где-то произойдёт ошибка:

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- Ошибка! Счёт 2 не существует
UPDATE accounts SET balance = balance + 100 WHERE id = 999;
-- База автоматически вернёт первый UPDATE
ROLLBACK;

После ROLLBACK счёт 1 вернёт свой баланс, как будто ничего не происходило.

Autocommit: неявные транзакции

PostgreSQL (и большинство СУБД) работает в режиме autocommit по умолчанию. Это значит:

UPDATE accounts SET balance = 100 WHERE id = 1; -- автоматически COMMIT после этой строки

Каждый одиночный statement оборачивается в неявную транзакцию. Это удобно для простых операций, но опасно для сложных сценариев.

Если ты хочешь явный контроль - всегда начинай с BEGIN:

BEGIN;
-- твой код здесь
COMMIT;

Ошибка внутри транзакции: aborted state

Вот сообщение, на которое новички тратят по несколько часов:

ERROR: current transaction is aborted, commands ignored until end of transaction block

Логика PostgreSQL такая: любая ошибка внутри транзакции переводит её в состояние aborted. Дальше база не выполняет ничего, кроме ROLLBACK (или ROLLBACK TO SAVEPOINT). Даже безобидный SELECT 1 вернёт то же сообщение:

BEGIN;
INSERT INTO users (email) VALUES ('alice@example.com');
INSERT INTO users (id) VALUES ('это не число');  -- ошибка типа
SELECT 1;    -- тоже ошибка: транзакция уже aborted
COMMIT;      -- сработает как ROLLBACK, ничего не сохранится

Обрати внимание на последнюю строку: COMMIT в aborted-транзакции не падает, он молча выполняет ROLLBACK. Поэтому код вида «поймали ошибку, залогировали, продолжили работать в той же транзакции» приводит к худшему из вариантов: приложение думает, что всё записалось, а в базе не осталось ничего.

В MySQL с InnoDB упавший statement откатывает сам себя, и транзакция продолжает работать. В PostgreSQL так нельзя: пойманную ошибку внутри транзакции нужно либо откатить целиком, либо вернуться к `SAVEPOINT`. Если переносишь код между этими базами, это первое место, где он себя проявит.

SAVEPOINT: частичные откаты

Иногда нужно откатить только часть операций внутри большой транзакции. Для этого есть SAVEPOINT:

BEGIN;
INSERT INTO users (email) VALUES ('alice@example.com'); -- успех
SAVEPOINT sp1;
INSERT INTO users (email) VALUES ('bob@example.com'); -- успех
INSERT INTO users (email) VALUES ('alice@example.com'); -- ОШИБКА (дубликат email, спасибо [UNIQUE](./09-constraints.md))
ROLLBACK TO sp1; -- откатываем только до sp1, первый INSERT остаётся
COMMIT; -- коммитим только alice

В этом примере alice@example.com окажется в базе, а bob - нет.

SAVEPOINT - это ещё и единственный способ поймать ошибку и продолжить работу в той же транзакции: вернулся к точке, транзакция снова живая. Цена: каждый savepoint база держит в памяти и учитывает при откате, поэтому ставить их в цикле на 100 тысяч итераций не стоит - откат такой транзакции будет заметно дольше обычного.

Вложенных транзакций в SQL нет

Первая мысль при виде savepoint: «значит, транзакции можно вкладывать». Нельзя. Второй BEGIN внутри открытой транзакции ничего не начинает:

BEGIN;
INSERT INTO users (email) VALUES ('a@example.com');
BEGIN;   -- WARNING: there is already a transaction in progress
INSERT INTO users (email) VALUES ('b@example.com');
ROLLBACK;  -- откатились обе вставки, а не только вторая

Это предупреждение, а не ошибка, поэтому запрос выполняется дальше и код выглядит работающим. Ловушка проявляется, когда одна функция вызывает другую, и обе «начинают транзакцию»: внутренний COMMIT фиксирует работу внешней раньше времени, а внутренний ROLLBACK уносит её целиком.

Практическое правило: транзакцией управляет тот, кто знает границы бизнес-операции. Метод репозитория CreateOrder не должен открывать транзакцию сам - он должен принимать её снаружи. То, что ORM и фреймворки называют «вложенной транзакцией», внутри реализовано ровно через SAVEPOINT, никакой магии там нет.

Когда транзакции нужны

  1. Денежные переводы - классика, смотри выше
  2. Инвентарь - отправили товар со склада, одновременно создали заказ
  3. Многошаговые бизнес-процессы - создали пользователя, выдали ему стартовый баланс, записали запись в журнал аудита
  4. Обновление связанных таблиц - если одна операция упадёт, остальные откатятся

Схему таблиц под такой сценарий целиком собираем в мини-проекте трека.

-- Пример: покупка товара
BEGIN;
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 42;
INSERT INTO orders (user_id, product_id, status) VALUES (10, 42, 'pending');
INSERT INTO payments (order_id, amount) VALUES (currval('orders_id_seq'), 99.99);
COMMIT; -- либо всё, либо ничего

Слово «атомарно» означает три разных вещи

Термин встретится в курсе трижды, в трёх разных смыслах - и путаница между ними стоит дороже, чем кажется, потому что она выглядит как перенос гарантии с уровня на уровень.

ГдеЧто значит «атомарно»Кто обеспечивает
транзакция базы (этот урок)все изменения внутри BEGIN … COMMIT фиксируются или откатываются вместеСУБД
операция в конкурентном коде (sync/atomic, INCR в Redis)неделима относительно других наблюдателей: никто не увидит половинупроцессор или однопоточный сервер
бизнес-данные плюс запись события (outbox)и то, и другое пишутся одной локальной транзакцией одной базыта же СУБД
Ни один из трёх смыслов не распространяется на **два разных хранилища**. Фразу вида «атомарно обновляем базу и отправляем сообщение в брокер» стоит считать ошибкой формулировки: такой атомарности нет и не может быть без распределённых транзакций, которых в этом курсе нет намеренно.

Третья строка таблицы - именно про это: outbox атомарен потому, что и данные, и запись события лежат в одной базе. Отправка в брокер происходит после коммита, отдельным процессом, и атомарной частью не является.

Проверка формулировки простая: если в предложении со словом «атомарно» названы две системы (база и брокер, база и внешний API, Redis и PostgreSQL) - предложение неверно, и нужно уточнить, что именно атомарно.

Что транзакция не откатит

Транзакция управляет данными в этой базе. Всё остальное она не контролирует, и это регулярно ломает представления новичков.

Внешние эффекты. Отправленный email, платёж во внешнем API, записанный файл, сообщение в брокер - ROLLBACK их не отменит. Поэтому порядок такой: сначала коммитим, потом делаем внешний вызов. Или, если внешний вызов обязателен, кладём задание в таблицу outbox в той же транзакции и отправляем отдельным воркером после коммита.

BEGIN;
INSERT INTO users (email) VALUES ('new@example.com');
INSERT INTO outbox (topic, payload) VALUES ('user.registered', '{"email":"new@example.com"}');
COMMIT;  -- письмо отправит воркер, прочитав outbox

Счётчики последовательностей. SERIAL и IDENTITY берут значение через nextval, а он специально не откатывается: иначе параллельные вставки блокировали бы друг друга.

BEGIN;
INSERT INTO users (email) VALUES ('x@example.com');  -- получил id = 42
ROLLBACK;
-- Следующая вставка получит id = 43, а не 42. В нумерации будет дырка, и это нормально.

Отсюда практический вывод: не показывай пользователю id как «номер заказа №42» с обещанием непрерывности. Дырки в id появятся от любого откатившегося запроса.

Транзакция не спасает от гонки

На счету 1000. Два банкомата одновременно снимают по 800.

Оба спрашивают базу: «денег хватает?» База обоим отвечает: «хватает». Оба выдают. На счету минус 600.

Самое неприятное здесь то, что транзакции на месте, и обе честно закоммитились:

BEGIN;
SELECT balance FROM accounts WHERE id = 1;   -- обе транзакции видят 1000
-- приложение проверяет: 1000 >= 800, значит можно
UPDATE accounts SET balance = 1000 - 800 WHERE id = 1;
COMMIT;

Это называется потерянное обновление (lost update): вторая транзакция записала результат, посчитанный из устаревшего значения, и затёрла работу первой.

Обрати внимание, чего тут не произошло. Транзакция дала атомарность - каждая пара «прочитать, записать» выполнилась целиком. Дала изоляцию от чужого незакоммиченного - ни одна не увидела промежуточное состояние другой. И этого оказалось мало, потому что решение «хватает ли денег» приложение приняло между чтением и записью, а за это время мир изменился.

Потерянное обновление возможно на `READ COMMITTED` - уровне по умолчанию в PostgreSQL. То есть код выше сломается на настройках, которые ты не менял. Классическая триада аномалий из [урока про изоляцию](./08-isolation.md) - грязное, неповторяющееся и фантомное чтение - это про **чтение**, а здесь теряется запись.

Решение 1: не читать перед записью

Самое простое и самое надёжное - вообще не вычислять новое значение в приложении. Пусть база посчитает и проверит сама, одним запросом:

UPDATE accounts
SET balance = balance - 800
WHERE id = 1 AND balance >= 800;

UPDATE берёт блокировку на строку, поэтому вторая транзакция дождётся первой, перечитает уже обновлённый баланс (200) и не найдёт строку под условие balance >= 800. Ключевое - проверить число затронутых строк: ноль означает «денег не хватило», и это не ошибка базы, а бизнес-результат, который надо обработать.

res, err := tx.ExecContext(ctx,
    `UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1`,
    amount, accountID)
if err != nil {
    return err
}
n, err := res.RowsAffected()
if err != nil {
    return err
}
if n == 0 {
    return ErrInsufficientFunds // строка не подошла под условие - денег не хватило
}

Решение 2: SELECT ... FOR UPDATE

Если посчитать одним запросом не выходит - логика сложнее, чем вычитание, - строку блокируют явно при чтении:

BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- вторая транзакция ждёт здесь
-- считаем что угодно в приложении
UPDATE accounts SET balance = 200 WHERE id = 1;
COMMIT;  -- только теперь вторая транзакция продолжится и прочитает 200

Это пессимистическая блокировка: мы заранее считаем, что конкурент будет, и занимаем строку. Цена - конкуренты стоят в очереди, а при блокировке нескольких строк в разном порядке появляется шанс словить взаимную блокировку. Порядок блокировки строк в коде стоит держать одинаковым везде.

Решение 3: оптимистическая блокировка

Обратный подход: ничего не блокируем, но проверяем при записи, что строка не изменилась с момента чтения. Для этого в таблице держат версию:

SELECT balance, version FROM accounts WHERE id = 1;  -- balance = 1000, version = 7

UPDATE accounts
SET balance = 200, version = version + 1
WHERE id = 1 AND version = 7;

Если конкурент успел раньше, версия уже 8, UPDATE затронет ноль строк - и операцию нужно повторить с начала, перечитав свежие данные. Подходит, когда конфликты редки: в обычном случае никто никого не ждёт, а платим мы только за редкие повторы. Если конфликты частые, повторов станет больше, чем полезной работы, и пессимистическая блокировка окажется дешевле.

А если поднять уровень изоляции?

На SERIALIZABLE база сама заметит конфликт и откатит одну из транзакций с ошибкой сериализации. Но это не «включил и забыл»: приложение обязано ловить такую ошибку и повторять транзакцию целиком, иначе вместо тихой потери денег получишь громкое падение запроса. Что именно меняется на каждом уровне - в следующем уроке.

Практическое правило: начинай с решения 1. Атомарный UPDATE с условием закрывает большинство счётчиков, балансов и остатков на складе, и не требует ни блокировок вручную, ни ретраев.

Что произойдёт, если забыть COMMIT или потерять соединение

Если открыл транзакцию (BEGIN) и закрыл соединение без COMMIT:

BEGIN;
UPDATE accounts SET balance = 999999 WHERE id = 1;
-- закрыл окно psql, упал процесс приложения, оборвался Wi-Fi

Результат: автоматический ROLLBACK, изменения исчезнут. Это защита от незаконченных операций, и работает она надёжно. Интересен не результат, а момент, когда база об этом узнает.

Если процесс закрыл соединение штатно, сервер получает сообщение о разрыве и откатывает транзакцию мгновенно. А если машина с приложением умерла жёстко (потеряли сеть, убили контейнер по OOM, дёрнули под во время деплоя), TCP-соединение остаётся для сервера живым. Транзакция висит в состоянии idle in transaction, удерживая все взятые блокировки, пока не сработает keepalive. По умолчанию это минуты, а не секунды.

Как это выглядит на практике: задеплоились, старые поды убили, и вдруг половина запросов к таблице orders встала в очередь на несколько минут без единой ошибки в логах приложения. Виновника видно так:

SELECT pid, state, now() - xact_start AS duration, left(query, 60) AS query
FROM pg_stat_activity
WHERE state <> 'idle' OR xact_start IS NOT NULL
ORDER BY xact_start;

Строки с state = 'idle in transaction' и большим duration - это как раз брошенные транзакции. Аварийно снять такую можно через SELECT pg_terminate_backend(pid), а чтобы проблема не возвращалась, на базе или на пользователе ставят страховку:

-- Автоматически убивать транзакции, которые ничего не делают дольше 30 секунд
ALTER DATABASE shop SET idle_in_transaction_session_timeout = '30s';
Приложение может упасть, зависнуть или уйти в дебаггер на брейкпоинте. Пока живо соединение, база считает транзакцию актуальной и держит блокировки. `statement_timeout` и `idle_in_transaction_session_timeout` стоит выставлять до того, как это понадобится, а не после.

Проблема: долгие транзакции

Это частая ошибка:

BEGIN;
-- читаем все данные в приложении
SELECT * FROM huge_table; - 1 миллион строк
-- обрабатываем 5 минут в коде приложения
-- пишем результат
UPDATE accounts SET balance = ... WHERE id = 1;
COMMIT;

Проблема: 5 минут база держит блокировки на строках, другие соединения ждут. Кто кого и насколько жёстко блокирует, зависит от уровня изоляции. Performance падает. Правило: открывай транзакцию как можно позже, закрывай как можно раньше.

-- Правильно:
-- 1. Читаем данные БЕЗ BEGIN
SELECT * FROM huge_table;
-- 2. Обрабатываем в коде
-- 3. ПОТОМ открываем транзакцию
BEGIN;
UPDATE accounts SET balance = ... WHERE id = 1;
COMMIT;

MVCC: почему долгая транзакция раздувает таблицы

Блокировки - только половина беды. Вторая половина неочевидна и бьёт больнее.

PostgreSQL не изменяет строку на месте: UPDATE создаёт новую версию строки, а старую помечает как мёртвую (это механика MVCC, благодаря которой читатели не ждут писателей). Мёртвые версии убирает фоновый процесс autovacuum, но убрать он может только те, которые уже никому не нужны. А «никому» определяется по самой старой открытой транзакции в базе.

Дальше арифметика простая. Твоя аналитическая транзакция висит открытой два часа. За эти два часа таблица orders обновлялась 5 миллионов раз. Ни одну из 5 миллионов мёртвых версий autovacuum тронуть не может, потому что теоретически твоя транзакция вправе их прочитать. Таблица физически растёт, индексы вместе с ней, запросы по ним замедляются, и место на диске не освободится даже после коммита - оно останется занятым внутри файлов таблицы. Это и называется bloat.

-- Сколько мёртвых строк накопилось и когда таблицу последний раз чистили
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 5;

Отсюда неожиданный вывод: длинный SELECT в открытой транзакции опаснее длинного UPDATE, потому что выглядит безобидно. Отчёт «просто читает», ревью его пропускает, а раздувает он таблицы, которые сам не трогает. Читающие отчёты либо держи вне транзакции, либо выноси на реплику.

Почему defer tx.Rollback() безопасен

Стандартный шаблон работы с транзакцией в коде выглядит так, будто в нём ошибка: откат ставят сразу после открытия, ещё до коммита.

tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}
defer tx.Rollback() // выполнится всегда: и при ошибке, и после успешного Commit

if _, err := tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - $1 WHERE id = $2", 100, 1); err != nil {
    return err // defer откатит транзакцию
}
if _, err := tx.ExecContext(ctx, "UPDATE accounts SET balance = balance + $1 WHERE id = $2", 100, 2); err != nil {
    return err
}
return tx.Commit() // после этого defer вызовет Rollback, и это ничего не сломает
$pdo->beginTransaction();
try {
    $pdo->prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?')->execute([100, 1]);
    $pdo->prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?')->execute([100, 2]);
    $pdo->commit();
} catch (Throwable $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    throw $e;
}

Почему двойного отката не происходит: транзакция после Commit уже завершена, и повторный откат просто нечего откатывать. В Go такой вызов возвращает ошибку sql.ErrTxDone и никаких запросов в базу не отправляет - именно поэтому её принято игнорировать в defer. Смысл конструкции в том, что ни один return по ошибке не сможет оставить транзакцию открытой: даже тот, который допишут в этот метод через год. В PHP роль страховки играет inTransaction() в catch, потому что там rollBack() вне транзакции бросает исключение и маскирует настоящую ошибку.

Полный разбор этого кода - в уроках про database/sql и PDO.

- **A**tomicity (атомарность): всё или ничего - если что-то упадёт, откатимся полностью - **C**onsistency (согласованность): данные не ломаются, правила целостности соблюдаются - **I**solation (изоляция): параллельные транзакции не мешают друг другу - насколько именно, задаёт [уровень изоляции](./08-isolation.md) - **D**urability (надёжность): после `COMMIT` данные точно на диске, даже если БД упадёт

Практические задания

  • Создай две таблицы: accounts(id, balance) и transfers(id, from_id, to_id, amount)
  • Напиши транзакцию для перевода 50 денег со счёта 1 на счёт 2 (+ запись о трансфере)
  • Сломай транзакцию нарочно (неправильный ID) и проверь, что оба UPDATE откатились
  • Добавь SAVEPOINT в большую транзакцию с несколькими UPDATE
  • Посмотри в логи: когда PostgreSQL пишет на диск (после COMMIT)
  • Повтори тот же перевод из кода: транзакции в Go - в уроке про database/sql
  • Внутри BEGIN выполни заведомо ошибочный запрос, потом SELECT 1 и прочитай сообщение про aborted transaction
  • Проверь, что после ROLLBACK вставки номер в SERIAL не переиспользуется: вставь, откати, вставь снова
  • Открой транзакцию во втором окне psql и не закрывай, затем найди её в pg_stat_activity по состоянию idle in transaction
  • Посмотри n_dead_tup для своей таблицы, обнови в ней все строки несколько раз и посмотри снова
  • Воспроизведи потерянное обновление руками: в двух окнах psql открой BEGIN, в обоих сделай SELECT balance, затем в обоих UPDATE на посчитанное значение и COMMIT. Сверь итог с ожидаемым
  • Повтори тот же сценарий через UPDATE ... WHERE balance >= :amount и убедись, что вторая транзакция затронула ноль строк
  • Повтори через SELECT ... FOR UPDATE и посмотри, где именно второе окно останавливается и ждёт

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