Что такое транзакция и зачем
Транзакция — это группа операций, которая выполняется как единое целое. Либо все операции применяются к данным, либо ни одна. Между ними не существует промежуточного состояния, видимого снаружи.
Классический пример — банковский перевод. Списать 1000 ₽ со счёта Ани и зачислить 1000 ₽ на счёт Бориса — это две операции UPDATE. Если между ними упадёт сервер, у Ани будет −1000, а у Бориса не появится +1000. Деньги «исчезнут» из системы.
Над этой гарантией стоит набор из четырёх свойств — ACID: Atomicity, Consistency, Isolation, Durability. Их сформулировал в 1983 году Andreas Reuter и Theo Härder в каноническом обзоре. С тех пор это базовый словарь, на котором СУБД договариваются с разработчиком о том, что именно гарантируется при работе с данными.
Каждая буква отвечает за свой аспект надёжности транзакций. Это не «магия СУБД», а конкретный контракт.
Дальше — по каждой букве отдельно, с конкретным примером того, что нарушается без неё.
A — Atomicity (атомарность)
Atomicity отвечает за принцип «всё или ничего». Транзакция — это атомарная единица: либо все её операции отразились на данных, либо ни одна. Промежуточного состояния не бывает.
Это слово специально позаимствовано из физики: атом в древнегреческом — «неделимое». То же и здесь: транзакция — это единица, которую СУБД не позволяет «надломить пополам». Внутри неё может быть один UPDATE или тысяча — для внешнего наблюдателя всё это один акт.
Самый простой способ это увидеть — выполнить два UPDATE внутри транзакции, потом сделать ROLLBACK. Изменения исчезнут, как будто их никогда не было.
Atomicity: BEGIN, UPDATE, ROLLBACK — никаких следов:
Внутри транзакции in_stock стало отрицательным — но ROLLBACK всё отменил. До COMMIT любые UPDATE/INSERT/DELETE находятся в подвешенном состоянии: видны только текущей сессии, могут быть откачены одним словом.
Что было бы без atomicity: если сервер упал бы между UPDATE products и UPDATE order_items, мы получили бы товар с уменьшенным остатком, но без заказа. Это рассинхрон, который потом ловят костыли вроде ночных скриптов «проверь, что не побилось». В мире NoSQL без транзакций такие скрипты называют «reconciliation jobs» — целая инженерная дисциплина, которая в SQL не нужна благодаря atomicity.
Atomicity работает не только при ROLLBACK, но и при сбое. Если процесс PostgreSQL упал в середине транзакции — после рестарта WAL replay автоматически откатит её. Если упало само приложение, и сессия закрылась — серверный backend заметит обрыв и откатит транзакцию. Гарантия «никаких полу-применённых состояний» работает в обоих случаях.
Любая точка сбоя в середине транзакции до COMMIT — равносильна ROLLBACK. После COMMIT транзакция уже зафиксирована.
C — Consistency (согласованность)
Consistency — самая «расплывчатая» буква в ACID. Формально: после commit база остаётся в согласованном состоянии, то есть все ограничения (CHECK, FOREIGN KEY, UNIQUE, NOT NULL) выполнены.
Это не «СУБД гарантирует, что ваши инварианты соблюдены». Это: «СУБД проверит те инварианты, которые вы ей объявили». Если вы не написали CHECK (balance >= 0) — никто не помешает отправить баланс в минус. Consistency — это про обещанные правила.
В литературе по distributed systems consistency имеет совсем другой смысл — там это про синхронность реплик (CAP-теорема, eventual consistency). Это разные понятия с одним словом. ACID-consistency — про инварианты на одной базе. CAP-consistency — про согласованность между узлами кластера. Не путать.
Consistency: CHECK не пропустит транзакцию, нарушающую инвариант:
UPDATE упадёт с ошибкой check constraint accounts_balance_cents_check, и транзакция автоматически перейдёт в aborted state. Любая следующая команда внутри той же транзакции вернёт ошибку «current transaction is aborted, commands ignored until end of transaction block». Единственный выход — ROLLBACK.
Это и есть consistency в действии: СУБД отказывается фиксировать состояние, где инвариант нарушен. Но обязанность сформулировать инварианты — на вас. Без CHECK СУБД бы пропустила минусовой баланс без вопросов.
Историческая ремарка про Consistency
Стоит сказать: буква C в ACID — самая спорная. Многие исследователи (включая Härder, одного из авторов оригинальной формулировки) позже признавались, что C добавили скорее «для красоты слова». На самом деле consistency — это следствие атомарности плюс корректно объявленных ограничений, не самостоятельное свойство СУБД.
То есть СУБД не «обеспечивает consistency» в смысле «следит за вашей бизнес-логикой». Она обеспечивает atomicity (всё или ничего) и проверяет constraints (CHECK, FK, UNIQUE). Из этих двух свойств следует, что после commit инварианты, которые вы объявили в схеме, выполнены. Это и называется consistency.
Поэтому современные учебники иногда говорят, что «настоящие» свойства транзакций — это Atomicity, Isolation, Durability. Consistency — это то, что вы получаете в результате, если правильно использовали atomicity и объявили constraints.
I — Isolation (изоляция)
Isolation отвечает за параллельные транзакции. Если две сессии одновременно меняют данные, результат должен быть как если бы они выполнялись последовательно — либо A, потом B; либо B, потом A. Параллельность не должна порождать состояний, невозможных в последовательном расписании.
На практике строгая изоляция дорогая, поэтому СУБД предлагают уровни изоляции — компромиссы между производительностью и количеством разрешённых аномалий. PostgreSQL по умолчанию работает в READ COMMITTED — это самый слабый практичный уровень. Самый строгий — SERIALIZABLE. Подробно про уровни и про конкретные аномалии — в уроках 4 и 5 этого модуля.
Сейчас важно понять: isolation — это про то, как ваша транзакция взаимодействует с чужими, выполняющимися одновременно. Atomicity и consistency работают и в одиночной системе, isolation — только в многопользовательской.
Простой пример: два пользователя одновременно покупают последний iPhone на складе. Транзакция A читает остаток (1 шт.) и проверяет «есть в наличии». Транзакция B параллельно делает то же самое. Обе видят остаток = 1, обе создают заказ, обе списывают по 1. Если isolation слабая — мы продали один iPhone дважды. Если строгая (SERIALIZABLE) — одна из транзакций откатится. Этой простой картинке посвящён весь следующий урок про аномалии.
Внутри одной сессии isolation не видна, но можно увидеть «свои» изменения, которых не видят другие сессии:
D — Durability (устойчивость)
Durability — самое простое из четырёх. После того как COMMIT вернул управление приложению, изменения должны пережить что угодно: внезапное отключение питания, kill -9 процесса базы, падение ОС. При следующем запуске они должны быть на месте.
Механизм —
fsync, и только после того, как fsync вернулся успешно, возвращает COMMIT OK приложению. Если в этот момент выключить питание — после перезапуска СУБД прочитает WAL и применит все изменения, для которых был зафиксирован commit. То, что было «в процессе», — откатит.
Это объясняет одну вещь, важную для backend-разработчиков: COMMIT — это медленная операция. Не потому что СУБД ленивая, а потому что она физически ждёт ответа от диска о том, что данные действительно записаны. Поэтому если у тебя 100 коротких транзакций — батчинг их в одну может ускорить запись в десятки раз. Каждый отдельный commit стоит ~миллисекунду (на SSD) или ~10ms (на HDD).
Запись в WAL и fsync — это и есть «договор» о durability. Только после успешного fsync клиент получает OK.
Из этой картинки следует один важный фокус: можно отключить fsync в настройках (synchronous_commit = off) — и тогда COMMIT возвращается мгновенно, без ожидания fsync. Скорость растёт в 10+ раз. Но durability в этом случае ослабляется: если упасть в окне между COMMIT OK и физическим fsync (типично 200ms), последние транзакции потеряются. Это компромисс для специфических нагрузок — например, для metrics/logs, где потеря последних 200ms терпима. Для финансов — нельзя.
ACID на практике: типичные ошибки
Несколько практических наблюдений, которые я часто встречаю.
Открыли транзакцию и забыли закрыть. Психопатологическая ошибка: разработчик в psql написал BEGIN, что-то посмотрел, переключился на другую вкладку. Транзакция продолжает существовать часами. Она не пишет ничего, но держит snapshot, и VACUUM перестаёт чистить таблицы. Через неделю — раздутая таблица, медленные запросы, паника в команде. Запрос для поиска: SELECT pid, xact_start FROM pg_stat_activity WHERE state IN ('idle in transaction', 'idle in transaction (aborted)');.
Делают update без транзакции. Пишут несколько связанных UPDATE без BEGIN. Каждый — своя транзакция, между ними нет atomicity. Если упасть после первого — данные в неконсистентном состоянии. Защита: ORM-обёртка @transaction.atomic или явный BEGIN/COMMIT.
Полагаются на ACID для бизнес-логики. «У нас же транзакции, значит, всё ок». Нет, ACID не знает, что в вашей бизнес-логике важно. Если вам важна уникальность email — пишите UNIQUE constraint, иначе СУБД её не обеспечит.
Делают HTTP-запросы внутри транзакции. Транзакция длится секундами вместо миллисекунд. Локи держатся долго, VACUUM не работает. Правило: всё «дорогое» (HTTP, S3, файлы) — снаружи транзакции.
ACID — это не магия, это контракт
Главное заблуждение про ACID: «база сама всё гарантирует». На самом деле:
- Atomicity работает только внутри одной транзакции. Если у вас две транзакции в разных сессиях — atomicity между ними не действует. Распределённые транзакции (2PC) — это отдельная сложная тема, и реальные системы их избегают.
- Consistency обеспечивается только теми ограничениями, которые вы написали. СУБД не угадывает бизнес-логику.
- Isolation работает на том уровне, который вы выбрали. На дефолтном
READ COMMITTEDнекоторые аномалии разрешены — об этом весь урок 4. - Durability гарантируется только после возврата
COMMIT. Если вы не дождались ответа от базы и считаете данные «сохранёнными» — это ваша ошибка, не СУБД.
ACID — это контракт: вы получаете гарантии в обмен на правильное использование транзакций, ограничений и явное управление уровнем изоляции.
Каждое нарушение свойства приводит к конкретному классу багов. Все они встречаются в реальных системах, особенно в NoSQL без транзакций.
Транзакции на уровне приложения
Часто разработчики не знают, что они уже работают с транзакциями, даже не вызвав BEGIN. Это потому что в драйверах (psycopg, pg, JDBC) обычно есть концепция autocommit = false — драйвер сам вставляет BEGIN перед первым запросом и COMMIT после.
В psycopg (Python):
conn = psycopg.connect(...)
cur = conn.cursor()
cur.execute("UPDATE accounts SET balance = balance - 100 WHERE id = 1")
cur.execute("UPDATE accounts SET balance = balance + 100 WHERE id = 2")
conn.commit() # вот это COMMIT
Между первым execute и conn.commit() идёт одна транзакция. Если упасть до commit() — оба UPDATE откатятся (или ни один не применится). Это ровно та же atomicity, о которой мы говорили выше.
В Node.js pg:
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE ...');
await client.query('UPDATE ...');
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
}
Здесь явно — BEGIN/COMMIT/ROLLBACK пишем сами. Драйвер не делает магии за нас.
В ORM (Django, SQLAlchemy, Rails) — обычно есть декоратор/контекст-менеджер @transaction.atomic или with session.begin(). Они оборачивают блок кода в транзакцию, делают commit при успехе и rollback при исключении.
Главное правило для backend-разработчика: всегда понимай, в какой транзакции выполняется твой код. Это влияет на видимость данных, на блокировки, на retry-логику. ACID не «работает магически» — оно работает, потому что вы (или ваш ORM) явно открыли транзакцию.
А что с NoSQL
Долгое время в маркетинге NoSQL ACID был синонимом «медленно и не масштабируется». Появилось альтернативное обещание — BASE: Basically Available, Soft state, Eventually consistent. То есть «данные когда-нибудь согласуются, но сейчас могут не быть».
Сегодня история выровнялась: MongoDB и DynamoDB научились ACID-транзакциям (с ограничениями), PostgreSQL научился горизонтальному масштабированию через шардирование. Жёсткое противопоставление SQL vs NoSQL по признаку ACID — это уже устаревший взгляд. Но PostgreSQL остаётся образцом: все четыре свойства, все четыре уровня изоляции, mature WAL, точечные блокировки — всё это в коробке.
В современных распределённых системах появились новые понятия — linearizability, serializability (изоляция в распределённой среде), causality. Они расширяют классическое ACID на кластеры из множества узлов. Если интересно — это материал отдельного курса по distributed systems. Для понимания PostgreSQL на одном инстансе хватает классики Reuter–Härder.
Распространённые ошибки понимания ACID
Когда я провожу собеседования, я регулярно встречаю одни и те же недоразумения. Перечислю, чтобы вы их не повторяли.
«ACID гарантирует, что данные не потеряются» — нет. ACID гарантирует durability после возврата COMMIT. Если ваше приложение упало до COMMIT — данных нет, и это нормально. Защита от такой потери — это уже логика приложения (например, queue-based retry).
«Транзакция — это всегда быстрее» — нет, это всегда дороже, чем autocommit одной командой. Транзакция требует ведения списка локов, версий и WAL-записей. Транзакция выигрывает в скорости только когда она оборачивает много связанных команд и заменяет N отдельных COMMIT’ов на один.
«SERIALIZABLE — самый правильный уровень, всегда используйте его» — нет. SERIALIZABLE может вернуть serialization failure и потребовать retry. В коде это нужно явно обработать. Если ваше приложение не готово к retry — SERIALIZABLE может сделать ему хуже, чем RC + явные блокировки.
«CHECK = проверка на стороне клиента, она медленная» — нет, CHECK выполняется в PostgreSQL на стороне сервера, до записи на диск. Это часть валидации INSERT/UPDATE. Стоимость минимальна — копейки.
«Транзакция блокирует чтения других сессий» — нет, благодаря MVCC. Читатели в PostgreSQL никогда не блокируют писателей и наоборот. Блокировка появляется только когда два писателя пишут одну строку.
Что дальше в модуле
В следующих уроках мы будем не пересказывать теорию, а видеть всё это руками. Урок 2 — синтаксис BEGIN/COMMIT/ROLLBACK и работа autocommit’а. Урок 3 — savepoints для частичного отката. Урок 4 — четыре аномалии параллельности на конкретных сценариях из e-commerce. Урок 5 — уровни изоляции и snapshot isolation в PostgreSQL. Урок 6 — явные блокировки SELECT FOR UPDATE и очереди задач на SKIP LOCKED.
К концу модуля у вас будет полное понимание, как PostgreSQL обеспечивает ACID — и как писать код, который этим пользуется.
Дальше за рамками модуля — внутреннее устройство WAL, MVCC, vacuum’а; распределённые транзакции и 2PC; репликация и failover. Эти темы — для отдельных курсов по PostgreSQL internals и distributed systems. Здесь же мы остановимся на уровне разработчика-практика: знать, что гарантировано, как этим пользоваться, какие есть подводные камни.
Как Postgres реализует Atomicity через MVCC WAL и Durability — как commit оказывается на дискеЧек-лист
- Транзакция — это группа операций, выполняемая как единое целое.
- ACID — четыре гарантии: Atomicity (всё или ничего), Consistency (инварианты сохраняются), Isolation (параллельные не мешают), Durability (commit = на диске).
- Atomicity работает только внутри одной транзакции; ROLLBACK откатывает всё.
- Consistency проверяет объявленные вами ограничения (CHECK, FK, UNIQUE). СУБД не знает вашу бизнес-логику.
- Isolation — отдельная большая тема; PostgreSQL по умолчанию даёт
READ COMMITTED. - Durability реализуется через WAL: запись в лог с
fsyncдо того, как commit вернулся. - ACID — это контракт, а не магия: вы получаете гарантии в обмен на правильное использование транзакций и ограничений.