ACID на практике
Транзакция — это забор: BEGIN…COMMIT делает группу записей всё-или-ничего, а SAVEPOINT режет внутри точки отката. Но ACID — это не управление конкурентностью: по умолчанию оно не сериализует.
Платёжный сервис списывает с одного счёта и зачисляет на другой двумя UPDATE. Однажды ночью процесс падает между ними: с 1 200 клиентов списали, но не зачислили, и деньги просто исчезли из реестра. Фикс — три слова, которые автор забыл: BEGIN и COMMIT. Транзакция сделала бы так, что обе записи лягут вместе или никак. ACID не академичен; это разница между реестром и слухом.
Что на самом деле даёт каждая буква
Если ты когда-нибудь отлаживал наполовину применённый пакет или удивлялся, почему «обернул в транзакцию» — а порча данных всё равно случилась, ответ почти всегда в том, какую букву ты предполагал против той, что реально получил.
ACID — это четыре гарантии, и инженеры путают ту, которой не получают, с теми, что получают.
- Atomicity (атомарность) — каждый оператор между
BEGINиCOMMITложится вместе, или никакой. Любая ошибка илиROLLBACKвыбрасывает весь пакет. Полуготовое состояние никогда не видно и не сохраняется. - Consistency (согласованность) — закоммиченная транзакция оставляет базу удовлетворяющей всем ограничениям (foreign key,
CHECK, unique,NOT NULL). Если оператор нарушит ограничение, транзакция падает с ошибкой и нужен откат. Postgres обеспечивает правила; твои инварианты («баланс не уходит в минус») ты кодируешь как ограничения, иначе они не гарантированы. - Isolation (изоляция) — конкурентные транзакции не видят незакоммиченной работы друг друга. Важно: у изоляции есть уровни: по умолчанию
READ COMMITTED— это слабое обещание, а не полная сериализуемость. Именно эту букву люди считают сильнее, чем она есть. - Durability (долговечность) — как только
COMMITвернулся, изменение переживёт крах или потерю питания. Postgres гарантирует это, сбрасывая write-ahead log (WAL) на диск до подтверждения коммита.
-- Атомарный перевод денег: обе строки двигаются, или ни одна.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- только теперь перевод долговечен и виден другимЕсли второй UPDATE упадёт (скажем, счёт 2 удалён), транзакция переходит в aborted-состояние; первый UPDATE отбрасывается при ROLLBACK. Деньги не утекают.
BEGIN, COMMIT, ROLLBACK и неявная транзакция
Каждый оператор уже внутри транзакции. Вне явного BEGIN Postgres оборачивает каждый одиночный оператор в собственную транзакцию, которая коммитится автоматически (autocommit). Поэтому один UPDATE accounts SET balance = balance * 1.05 сам по себе атомарен — все подходящие строки меняются или ни одна, даже без BEGIN.
BEGIN ты пишешь только когда нужно сделать несколько операторов одной атомарной единицей. После ошибки внутри транзакционного блока Postgres отвергает каждый следующий оператор с current transaction is aborted, пока ты не сделаешь ROLLBACK (или COMMIT, который Postgres превратит в откат). Никакого «проигнорировать ошибку и продолжить» — если только не используешь savepoint.
SAVEPOINT: частичный откат внутри одной транзакции
SAVEPOINT — именованная метка, к которой можно откатиться, не бросая всю транзакцию. Она позволяет одной транзакции пережить восстановимую ошибку в части своей работы.
BEGIN;
INSERT INTO jobs (id, status) VALUES (501, 'queued');
SAVEPOINT try_dedupe;
-- это может нарушить unique-индекс, если задача уже есть:
INSERT INTO jobs (id, status) VALUES (501, 'queued');
-- падает; вместо потери всей транзакции откатываем только до savepoint:
ROLLBACK TO SAVEPOINT try_dedupe;
-- первый INSERT всё ещё жив; продолжаем и коммитим его:
COMMIT;Именно это большинство драйверов делают под капотом для «вложенных транзакций» — в Postgres нет настоящих вложенных транзакций, только savepoint’ы. Цена, однако, реальна: каждый savepoint потребляет subtransaction ID, и транзакция, открывающая десятки тысяч их, может вызвать пресловутый subtransaction (subxid) overflow — как только backend превышает 64 живых подтранзакции, остальным приходится обращаться к pg_subtrans на диске, и задержка чтения на нагруженном primary может скакнуть с микросекунд до миллисекунд. Savepoint — скальпель, а не тело цикла.
▸Частая ошибка
Самое частое непонимание ACID: «я обернул это в транзакцию, значит конкурентные обновления безопасны». Нет. Два перевода, которые SELECT один и тот же баланс на уровне по умолчанию READ COMMITTED, могут оба прочитать 500, оба вычесть 100 и оба записать 400 — lost update (потерянное обновление) — хотя каждый идеально атомарен и долговечен. Атомарность — про твои операторы как единицу; она ничего не говорит про чередование с другими сессиями. Сериализовать конкурентный доступ — задача уровней изоляции и явных блокировок, следующих уроков этого раздела.
Чего транзакция НЕ даёт
Транзакция по умолчанию — не мьютекс и не сериализуема. На READ COMMITTED (умолчание Postgres) каждый оператор видит свежий снимок, взятый в начале оператора, так что две конкурентные последовательности read-modify-write могут гоняться. Атомарность и долговечность ты получаешь бесплатно; защиту от lost update, write skew или фантомных строк ты не получаешь, пока не поднимешь уровень изоляции или не возьмёшь явные блокировки строк. Знание этой границы — вся причина, по которой существует остаток раздела.
Две конкурентные сессии каждая выполняет на уровне по умолчанию: BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; COMMIT. Стартовый баланс 500. Что гарантировано?
Упорядочи жизненный цикл атомарного перевода в два шага, восстанавливающегося от ошибки дубль-вставки через savepoint:
- 1 BEGIN — открыть явную транзакцию, чтобы следующие операторы были одной единицей
- 2 Сделать первую запись (списать со счёта 1)
- 3 SAVEPOINT перед записью, которая может упасть
- 4 Попытаться рискованную запись; при ошибке ROLLBACK TO SAVEPOINT отменяет только этот шаг
- 5 COMMIT — сделать уцелевшие записи долговечными и видимыми
- 01Что гарантирует атомарность и к какой наименьшей единице она уже применяется без BEGIN?
- 02Чем SAVEPOINT отличается от ROLLBACK и какова его продакшн-цена на масштабе?
- 03Почему оборачивание read-modify-write в транзакцию НЕ предотвращает lost update по умолчанию?
ACID — это четыре отдельных обещания, и сеньоры держат их раздельно. Atomicity: BEGIN…COMMIT делает группу записей всё-или-ничего; любая ошибка отравляет транзакцию до ROLLBACK, и даже одиночный autocommit-оператор атомарен. Consistency: коммит должен оставить все ограничения удовлетворёнными — Postgres обеспечивает объявленные правила, а бизнес-инварианты ты кодируешь как ограничения. Isolation: конкурентные транзакции прячут незакоммиченную работу, но изоляция уровневая, и умолчание READ COMMITTED — слабое обещание, которое не сериализует. Durability: как только COMMIT вернулся, WAL на диске и изменение переживёт крах. SAVEPOINT добавляет частичный откат внутри одной транзакции, с реальной subxid-ценой на масштабе. Единственное, чего транзакция не даёт по умолчанию, — защиты от конкурентных гонок read-modify-write; это требует уровней изоляции и явных блокировок, которые ты встретишь дальше, включая проблему lost update, ради которой существует SELECT FOR UPDATE. Теперь, когда встретишь отчёт об инциденте с «потерянным обновлением» или «балансом ушедшим в минус», первый вопрос — какую букву ACID предполагали, но реально не получили.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.