← назад к разделу

База данных, которая «теряет деньги при сбое» — это не база данных, а кэш с амбициями. Разберёмся с нуля: что такое транзакция, что гарантирует ACID и почему выбор уровня изоляции — это не теория, а практическое решение с последствиями.

Что такое транзакция и зачем она нужна

Без транзакций любой сбой посередине операции оставляет данные в сломанном состоянии. Представьте перевод денег:

UPDATE account SET balance = balance - 100 WHERE id = 1;
-- сервер падает прямо здесь
UPDATE account SET balance = balance + 100 WHERE id = 2;

Первая строка выполнилась, вторая — нет. Деньги исчезли.

Транзакция оборачивает несколько операций в одно неделимое действие: либо применяются все, либо ни одна.

BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;

Теперь при любом сбое PostgreSQL либо применит обе строки, либо откатит обе.

ACID — четыре гарантии транзакций

ACID — аббревиатура, описывающая, что именно гарантирует база данных.

A — Atomicity (атомарность)

Транзакция — неделимое действие. Если что-то пошло не так до COMMIT, все изменения откатываются автоматически.

PostgreSQL обеспечивает это через WAL (Write-Ahead Log) — журнал изменений. Каждая операция сначала записывается в журнал, потом применяется к данным. При падении сервера PostgreSQL читает журнал и либо доигрывает транзакцию до конца (если COMMIT был), либо делает вид, что её не было.

C — Consistency (согласованность)

После транзакции база остаётся в корректном состоянии — все ограничения соблюдены: NOT NULL, FOREIGN KEY, UNIQUE, CHECK.

INSERT INTO product (category_id, price, name) VALUES (99, 200, 'Тортик');
-- ERROR: insert or update on table "product" violates foreign key constraint
-- Вся транзакция откатывается

Важно понимать: PostgreSQL проверяет только те ограничения, которые объявлены в схеме. Какие данные считать «корректными» — решает архитектор, а не база данных.

I — Isolation (изоляция)

Параллельные транзакции не должны мешать друг другу. Это самая сложная буква — на практике существует несколько уровней изоляции с разными компромиссами между скоростью и строгостью. Разберём подробно ниже.

D — Durability (долговечность)

Если COMMIT вернул успех — изменения переживут любой сбой: падение сервера, отключение питания, перезагрузку.

Тот же WAL: на COMMIT журнал сбрасывается на диск (fsync), и только потом клиент получает ответ. Данные на диске — даже если страницы данных ещё не записаны.

Параметр synchronous_commit = off ускоряет коммиты, но нарушает эту гарантию: подтверждённая транзакция может потеряться при сбое. Уместно только для некритичных данных — метрик, журналов доступа.

Как PostgreSQL не мешает транзакциям читать друг у друга — MVCC

Наивный способ изолировать транзакции — блокировки: пока одна транзакция пишет, другая не читает. Это медленно.

PostgreSQL использует другой подход — MVCC (Multi-Version Concurrency Control). Каждая строка хранит несколько версий. UPDATE не меняет строку на месте — создаёт новую версию, а старая остаётся.

-- T1: обновляет цену
BEGIN;
UPDATE product SET price = 180 WHERE id = 3;
-- На диске теперь ДВЕ версии строки id=3:
--   старая: price=150 (видна транзакциям, начавшимся до T1)
--   новая:  price=180 (видна только после COMMIT T1)

Параллельная транзакция T2, которая началась раньше, чем T1 закоммитилась, видит старую версию. Никаких блокировок чтения — каждая транзакция видит свой согласованный снимок данных.

Старые версии строк убирает фоновый процесс VACUUM, когда они становятся невидимыми всем активным транзакциям.

Четыре уровня изоляции

Полная изоляция — идеал, но дорогой. SQL-стандарт определяет четыре уровня: чем выше уровень, тем строже изоляция и тем больше накладные расходы.

УровеньDirty ReadNon-Repeatable ReadPhantom ReadWrite Skew
Read Uncommittedв PG — нетдадада
Read Committed (по умолчанию)нетдадада
Repeatable Readнетнетнетда
Serializableнетнетнетнет

Аномалии в таблице — это конкретные «сюрпризы», которые может получить транзакция при параллельной работе. Разберём каждый.

Read Uncommitted — в PostgreSQL работает как Read Committed

Стандарт допускает на этом уровне чтение незафиксированных изменений (dirty read): транзакция видит данные, которые другая ещё не сохранила. PostgreSQL этого не делает ни на каком уровне — благодаря MVCC видны только зафиксированные версии. Так что Read Uncommitted у PostgreSQL = Read Committed.

Read Committed — уровень по умолчанию

Каждый запрос внутри транзакции видит данные, зафиксированные на момент начала этого запроса. Между запросами данные могут измениться.

Non-repeatable read — один и тот же запрос дважды возвращает разное:

-- T1
BEGIN;
SELECT price FROM product WHERE id = 3;
-- → 150

-- T2 в это время
BEGIN;
UPDATE product SET price = 200 WHERE id = 3;
COMMIT;

-- T1 продолжает
SELECT price FROM product WHERE id = 3;
-- → 200  ← та же строка, другое значение в той же транзакции
COMMIT;

Phantom read — один и тот же диапазон возвращает разное количество строк:

-- T1
BEGIN;
SELECT COUNT(*) FROM product WHERE category_id = 1;
-- → 3

-- T2 вставила новую строку и закоммитила

-- T1 продолжает
SELECT COUNT(*) FROM product WHERE category_id = 1;
-- → 4  ← в той же транзакции, больше строк
COMMIT;

Когда подходит: большинство CRUD-сервисов с короткими транзакциями — прочитал одну строку, обновил, закоммитил. Если вся логика умещается в один запрос, аномалии не возникают.

Repeatable Read — снимок на всю транзакцию

Транзакция фиксирует снимок данных при первом запросе и видит этот снимок до конца. Non-repeatable read и phantom read исчезают.

-- T1
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT price FROM product WHERE id = 3;
-- → 150

-- T2 обновила price=200 и закоммитила

-- T1 продолжает
SELECT price FROM product WHERE id = 3;
-- → 150  ← снимок зафиксирован, изменение T2 невидимо
COMMIT;

Дополнительно: если две транзакции пытаются обновить одну строку, вторая получает ошибку:

-- T2 пытается обновить строку, которую уже обновила и закоммитила T1:
-- ERROR: could not serialize access due to concurrent update

Приложение обязано поймать эту ошибку и повторить транзакцию. Без обработки такой ошибки код будет нестабильным под нагрузкой.

Write skew — что Repeatable Read не ловит

Это тонкая аномалия: две транзакции читают одни и те же данные, принимают независимые решения и вместе нарушают правило, которое каждая по отдельности соблюдала.

Пример: правило — «суммарная цена товаров в категории "Сладости" не падает ниже 250». Сейчас сумма 50 + 70 + 150 = 270.

-- T1: снижает цену конфет на 70
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT SUM(price) FROM product WHERE category_id = 1;
-- → 270, запас 20, можно снизить на 70
UPDATE product SET price = 80 WHERE id = 3;

-- T2 параллельно: снижает цену мармелада на 40
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT SUM(price) FROM product WHERE category_id = 1;
-- → 270 (T1 ещё не закоммитила, снимок T2 её не видит)
UPDATE product SET price = 30 WHERE id = 2;

-- T1 COMMIT → сумма: 50 + 70 + 80 = 200
-- T2 COMMIT → сумма: 50 + 30 + 80 = 160  ← нарушение!

Обе транзакции изменили разные строки, поэтому конфликта по UPDATE не было — Repeatable Read проблему не увидел.

Когда подходит: аналитические запросы, отчёты за период, где нужен консистентный срез данных на момент старта.

Serializable — полная изоляция

Транзакции исполняются так, как если бы шли строго одна за другой, в каком-то порядке. Никаких аномалий, включая write skew.

PostgreSQL реализует это через SSI (Serializable Snapshot Isolation) — отслеживание зависимостей между транзакциями. Если PostgreSQL видит, что две параллельные транзакции образуют конфликт, одна из них откатывается:

-- Тот же пример: T1 и T2 параллельно снижают цены в одной категории

-- T1 COMMIT → успех
-- T2 COMMIT → ERROR: could not serialize access due to read/write dependencies
--                    among transactions

T2 откатывается, приложение повторяет её — на повторе T2 видит уже новые данные после T1 и принимает правильное решение.

Цена: PostgreSQL не держит физические блокировки, но откатывает транзакции чаще. Приложение обязано уметь повторять транзакцию при ошибке SQLSTATE 40001 (serialization_failure). Read-only транзакции на Serializable почти бесплатны.

Когда подходит: денежные операции с инвариантами на нескольких строках, проверка уникальности перед вставкой, любые ситуации с write skew.

Когда какой уровень выбирать

ЗадачаУровень
CRUD: читаю одну строку, обновляю, коммичуRead Committed (по умолчанию)
Отчёт за период, нужен консистентный срезRepeatable Read
Перевод денег, проверка лимита перед списаниемSerializable
Инвариант на нескольких строках, параллельные обновленияSerializable
Долгий аналитический запрос (read-only)Repeatable Read

Несколько практических правил:

  • Поднимать уровень снизу вверх — каждый следующий дороже.
  • Если выбрали Repeatable Read или Serializable — реализуйте повтор транзакции при ошибке сериализации. Без этого код нестабилен под нагрузкой.
  • SELECT ... FOR UPDATE часто проще и понятнее, чем высокий уровень изоляции: блокирует конкретную строку, не вызывает откатов, понятен в логах.

Коротко

  • ACID — четыре гарантии транзакции: атомарность (всё или ничего), согласованность (ограничения соблюдены), изоляция (параллельные не мешают), долговечность (данные после COMMIT не теряются).
  • WAL обеспечивает атомарность и долговечность — журнал сбрасывается на диск до подтверждения клиенту.
  • MVCC — каждая транзакция видит согласованный снимок без блокировок чтения.
  • Read Committed (по умолчанию) — снимок на каждый запрос; допускает non-repeatable read и phantom read.
  • Repeatable Read — снимок на всю транзакцию; устраняет non-repeatable и phantom, но не write skew.
  • Serializable — полная изоляция, включая write skew; требует обработки ошибок сериализации в приложении.
  • PostgreSQL никогда не показывает dirty read ни на одном уровне — MVCC позволяет видеть только зафиксированные данные.

Что почитать дальше

  • Партиционирование и шардирование в PostgreSQL — когда база вырастает и одной таблицы становится мало.
  • PG Style Guide — практические правила работы с PostgreSQL.