Транзакции: ACID и что ломается без него
Транзакции: ACID и что ломается без него
Транзакция - это когда несколько операций должны выполниться как единое целое. Или всё, или ничего. Это страховка для твоих данных.
Пример из жизни
Перевод денег:
- списали со счёта A (-100)
- зачислили на счёт B (+100)
Если между шагом 1 и 2 какой-то краш - 100 денежных единиц исчезают в чёрной дыре. С транзакцией: либо оба шага выполнены, либо оба откачены. Третьего не дано.
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. Поэтому код вида «поймали ошибку, залогировали, продолжили работать в той же транзакции» приводит к худшему из вариантов: приложение думает, что всё записалось, а в базе не осталось ничего.
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, никакой магии там нет.
Когда транзакции нужны
- Денежные переводы - классика, смотри выше
- Инвентарь - отправили товар со склада, одновременно создали заказ
- Многошаговые бизнес-процессы - создали пользователя, выдали ему стартовый баланс, записали запись в журнал аудита
- Обновление связанных таблиц - если одна операция упадёт, остальные откатятся
Схему таблиц под такой сценарий целиком собираем в мини-проекте трека.
-- Пример: покупка товара
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): вторая транзакция записала результат, посчитанный из устаревшего значения, и затёрла работу первой.
Обрати внимание, чего тут не произошло. Транзакция дала атомарность - каждая пара «прочитать, записать» выполнилась целиком. Дала изоляцию от чужого незакоммиченного - ни одна не увидела промежуточное состояние другой. И этого оказалось мало, потому что решение «хватает ли денег» приложение приняло между чтением и записью, а за это время мир изменился.
Решение 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';
Проблема: долгие транзакции
Это частая ошибка:
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.
Практические задания
- Создай две таблицы:
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и посмотри, где именно второе окно останавливается и ждёт