Транзакции и ACID
Brew переводит выручку с кассы одной кофейни на счёт другой: списать сумму у первой, зачислить второй. Две команды. Между ними — миллисекунды, но именно в этот зазор и прячется самый страшный сценарий: списание прошло, а зачисление упало (сеть моргнула, процесс умер, диск кончился). Деньги списаны и не зачислены — испарились. Или наоборот: зачисление прошло, списание нет — деньги появились из воздуха. В системе, которая считает чужие деньги, это не баг, а катастрофа.
Решение — не «писать аккуратнее», а инструмент, который у базы уже есть: транзакция. BEGIN, обе команды, COMMIT — и Postgres гарантирует, что они применятся вместе или никак. Этот юнит — про четыре буквы, которые стоят за этой гарантией: ACID.
Транзакция: BEGIN, COMMIT, ROLLBACK
Транзакция — это группа команд, которую база выполняет как единое целое. Открывает её BEGIN, закрывает один из двух финалов:
COMMIT— «зафиксируй всё»: изменения становятся постоянными и видимыми другим.ROLLBACK— «забудь всё»: база возвращается ровно в то состояние, что было доBEGIN, будто ни одной команды и не было.
Пока транзакция открыта, её изменения не видны другим сессиям и в любой момент могут быть отменены. Если посреди транзакции случается ошибка (нарушен CHECK, упала связь), несфиксированная работа откатывается целиком. Именно это превращает «списал, но не зачислил» из возможного исхода в невозможный: либо COMMIT после обеих команд, либо ROLLBACK — третьего нет.
ACID: что именно гарантирует транзакция
- A — Atomicity (атомарность). Всё или ничего. Перевод из двух команд либо применяется целиком, либо не применяется вовсе. Половины не бывает.
- C — Consistency (консистентность). Транзакция переводит базу из одного корректного состояния в другое, не нарушая инвариантов (
CHECK,FOREIGN KEY,UNIQUE). Сумма денег на счетах при переводе не меняется — и база не даст её изменить. - I — Isolation (изоляция). Параллельные транзакции не видят промежуточных, незафиксированных состояний друг друга. Этому посвящён почти весь остаток модуля 05 (снимки в 05-02, блокировки в 05-03, уровни изоляции в 05-04).
- D — Durability (долговечность). После
COMMITданные переживут падение сервера — они уже на диске (в журнале WAL).
В этом юните мы наблюдаем A и C напрямую; I и D — дальше по модулю и на уровне сервера.
Четыре буквы как карта — что гарантирует каждая и где это видно:
| Буква | Гарантирует | В нашем демо | Разбирается |
|---|---|---|---|
| A — atomicity | всё или ничего, половин не бывает | шаг 3: прошедшее списание #1 откатилось целиком | здесь |
| C — consistency | инварианты целы (CHECK/FK/UNIQUE) | шаг 4: сумма счетов 150.00 неизменна | здесь |
| I — isolation | параллельные транзакции не видят чужих промежутков | — | 05-02 → 05-04 |
| D — durability | после COMMIT данные переживут падение (WAL) | — | уровень сервера |
Что показывает наш код
Запросы в query.sql — это кирпичики перевода: списать, зачислить и посчитать сумму-инвариант.
-- name: Debit :execrows
UPDATE ledger_accounts SET balance = balance - sqlc.arg(amount) WHERE id = sqlc.arg(id);
-- name: Credit :execrows
UPDATE ledger_accounts SET balance = balance + sqlc.arg(amount) WHERE id = sqlc.arg(id);Списание защищено CHECK (balance >= 0) из schema.sql: уйти в минус нельзя — попытка роняет команду (SQLSTATE 23514), а с ней и всю транзакцию. Собирает кирпичики в транзакцию main.go — функция transfer:
tx, _ := pool.Begin(ctx)
defer tx.Rollback(ctx) // страховка: ранний выход → откат
qtx := queries.WithTx(tx) // запросы внутри этой транзакции
qtx.Debit(ctx, ...) // списать у отправителя
n, _ := qtx.Credit(ctx, ...) // зачислить получателю
if n == 0 { return errNoPayee } // получателя нет → выйдем, defer откатит
tx.Commit(ctx) // обе команды дошли — фиксируемДемо делает два перевода. Первый — обычный, на существующий счёт: COMMIT, деньги переехали. Второй — на несуществующий счёт #999: списание у #1 проходит (реальная работа внутри транзакции), но Credit задевает 0 строк — main.go это замечает по RowsAffected и выходит, defer tx.Rollback откатывает перевод целиком. Списание #1 исчезает вместе с ним.
Запуск
Подними песочницу (из корня репозитория) и накати схему Brew + таблицу юнита:
docker compose up -d
make lecture L=05-transactions-and-mvcc/05-01-transactions-and-acid T=db-reset
make lecture L=05-transactions-and-mvcc/05-01-transactions-and-acid(T=run — значение по умолчанию. Изнутри каталога юнита это make db-reset, make run.)
Вывод:
1) Два кассовых счёта засеяны:
#1 Касса Brew Central 100.00
#2 Касса Brew North 50.00
2) Перевод 30.00 со счёта #1 на #2 (BEGIN → списать → зачислить → COMMIT):
COMMIT. Состояние:
#1 Касса Brew Central 70.00
#2 Касса Brew North 80.00
3) Перевод 20.00 со счёта #1 на НЕсуществующий #999 — должен откатиться целиком:
перевод отклонён: счёта-получателя #999 не существует
ROLLBACK. Состояние (как в шаге 2 — списание #1 откатилось вместе с переводом):
#1 Касса Brew Central 70.00
#2 Касса Brew North 80.00
4) Сумма по всем счетам: 150.00 — неизменна с самого начала (ничего не потеряно, ничего не создано).Шаг 2 — успешный перевод: 30.00 переехали с #1 на #2. Шаг 3 — неудачный: списание у #1 внутри транзакции прошло, но зачислять было некому, и ROLLBACK отменил обе команды разом — в шаге 3 баланс #1 ровно такой же, как в шаге 2 (70.00), а не 50.00. Шаг 4 — главное: сумма по счетам 150.00 с самого начала и до конца. Это атомарность (списание не пережило транзакцию) и консистентность (инвариант суммы цел).
Заборчик
Демо само разворачивает «несуществующего получателя» в ошибку и откатывает. В проде отказ редко такой вежливый — и каждая поблажка демо превращается в продовую заботу:
defer tx.Rollback(ctx)— сразу послеBegin. Транзакция падает на ошибке драйвера, тайм-ауте, разрыве соединения, и откат обязан случиться в любом из этих случаев. Поставленный сразу послеBegin,deferсработает даже на панике или раннемreturn— открытая транзакция не повиснет (а висящая транзакция держит блокировки и горизонт видимости — об этом 05-02).- Настоящий денежный перевод — не два
UPDATE. Это проводки по журналу (double-entry), идемпотентность по ключу операции (чтобы повтор запроса не списал дважды) и аудит. Здесь нам важна только механика «вместе или никак». - Атомарность — только внутри базы. Если после
COMMITнужно отправить событие в Kafka или дёрнуть платёжный шлюз, это уже за пределами транзакции БД, и согласованность там обеспечивают другими средствами — transactional outbox (модуль 09).
Что забрать с собой
- Транзакция (
BEGIN…COMMIT/ROLLBACK) — группа команд, применяемая вместе или никак. - Atomicity: частичная работа (прошедшее списание) откатывается целиком при
ROLLBACK— наблюдаемо в шаге 3. - Consistency: инвариант (сумма счетов,
CHECK-и,FK) сохраняется — сумма 150.00 неизменна. - Isolation и Durability — дальше по модулю 05 и на уровне сервера (WAL).
- В Go:
pool.Begin→queries.WithTx(tx)→Commit/Rollback;defer tx.Rollback(ctx)сразу послеBegin— страховка от любого раннего выхода. CHECK-инвариант в схеме (balance >= 0) — это не «защита от дурака», а механизм, который роняет транзакцию и тем самым держит данные корректными.
Дальше — юнит 05-02 «ментальная модель MVCC»: разберём, как Postgres даёт нескольким транзакциям читать и писать одновременно, не блокируя друг друга, — через версии строк и снимки. Это фундамент под букву I в ACID.