PostgreSQL CookbookТранзакцииТранзакции и ACID
0 / 63 (0%)

Транзакции и 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 — это кирпичики перевода: списать, зачислить и посчитать сумму-инвариант.

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:

go
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 + таблицу юнита:

sh
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.)

Вывод:

plaintext
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).

Что забрать с собой

  • Транзакция (BEGINCOMMIT/ROLLBACK) — группа команд, применяемая вместе или никак.
  • Atomicity: частичная работа (прошедшее списание) откатывается целиком при ROLLBACK — наблюдаемо в шаге 3.
  • Consistency: инвариант (сумма счетов, CHECK-и, FK) сохраняется — сумма 150.00 неизменна.
  • Isolation и Durability — дальше по модулю 05 и на уровне сервера (WAL).
  • В Go: pool.Beginqueries.WithTx(tx)Commit/Rollback; defer tx.Rollback(ctx) сразу после Begin — страховка от любого раннего выхода.
  • CHECK-инвариант в схеме (balance >= 0) — это не «защита от дурака», а механизм, который роняет транзакцию и тем самым держит данные корректными.

Дальше — юнит 05-02 «ментальная модель MVCC»: разберём, как Postgres даёт нескольким транзакциям читать и писать одновременно, не блокируя друг друга, — через версии строк и снимки. Это фундамент под букву I в ACID.

·Модуль 06

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Транзакции / Транзакции и ACID