ACID и уровни изоляции транзакций в PostgreSQL

Что такое ACID, как четыре буквы реализуются в PostgreSQL (MVCC, WAL, snapshots, SSI). Четыре уровня изоляции — Read Uncommitted, Read Committed, Repeatable Read, Serializable — детально, с конкретными примерами на таблицах category и product. Когда какой уровень брать, что такое dirty read, non-repeatable read, phantom, write skew.

База данных, которая «теряет деньги при сбое» — это не база данных, а кэш с амбициями. Гарантии того, что транзакция либо применилась целиком, либо не применилась вовсе, — фундамент любой настоящей СУБД. Этот набор гарантий называется ACID.

PostgreSQL реализует ACID через четыре механизма, которые работают вместе: WAL, MVCC, снимки (snapshots) и SSI. В этой статье разберём, что значит каждая буква, как PostgreSQL её обеспечивает, и в чём практическая разница четырёх уровней изоляции — на конкретных примерах с двумя таблицами:

CREATE TABLE category (
    id   BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL
);

CREATE TABLE product (
    id          BIGSERIAL PRIMARY KEY,
    category_id BIGINT REFERENCES category(id),
    price       NUMERIC(10, 2) NOT NULL,
    name        TEXT NOT NULL
);

Данные, на которых будут идти все примеры:

category:

idname
1Сладости
2Мясо
3Молочные продукты

product:

idcategory_idpricename
1150Печенье
2170Мармелад
31150Конфеты
42150Куриная грудка
5NULL400Свинина
6NULL100Молоко
7NULL120Кефир

Обратите внимание на товары 5, 6, 7 — у них category_id IS NULL. Это «осиротевшие» записи: продукт существует, но не привязан к категории. Внешний ключ это позволяет, потому что колонка nullable. Дальше увидим, как такие записи ведут себя в транзакциях.

ACID — что значит каждая буква

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

Транзакция исполняется как одно неделимое действие: либо применяются все её операторы, либо ни один. Промежуточного состояния снаружи не видно.

BEGIN;
UPDATE product SET price = price * 1.10 WHERE category_id = 1;  -- +10% сладостям
UPDATE product SET price = price * 1.15 WHERE category_id = 2;  -- +15% мясу
-- если здесь сервер упадёт, обе UPDATE откатятся
COMMIT;

PostgreSQL обеспечивает атомарность через Write-Ahead Log (WAL). Каждое изменение сначала пишется в WAL — последовательный журнал на диске, потом применяется к страницам данных. Если сервер падает посреди транзакции, при перезапуске PostgreSQL читает WAL и:

  • если в журнале есть COMMIT для транзакции — доигрывает изменения до конца;
  • если COMMIT не было — откатывает (точнее, делает вид, что транзакции не было: её записи останутся в страницах, но станут невидимыми благодаря MVCC).

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

После завершения транзакции база остаётся в корректном состоянии: все ограничения соблюдены — NOT NULL, CHECK, FOREIGN KEY, UNIQUE. Это гарантия не СУБД, а схемы — PostgreSQL только проверяет ограничения, которые объявил автор.

Пример: попытка привязать продукт к несуществующей категории.

INSERT INTO product (category_id, price, name) VALUES (99, 200, 'Тортик');
-- ERROR: insert or update on table "product" violates foreign key constraint

FK-ограничение REFERENCES category(id) отлавливает несогласованность до того, как транзакция дойдёт до COMMIT. Транзакция целиком откатывается.

Особый случай — наши осиротевшие продукты (5, 6, 7). FK с NULL не проверяется (это поведение SQL-стандарта). Если хотим запретить такие записи — добавляем NOT NULL на category_id. Тогда:

INSERT INTO product (category_id, price, name) VALUES (NULL, 200, 'Тортик');
-- ERROR: null value in column "category_id" violates not-null constraint

Поддержка C — это работа DBA и архитектора схемы. PostgreSQL не угадает, какие данные считать корректными.

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

Параллельные транзакции не мешают друг другу так, будто каждая исполняется в одиночестве. На практике полная изоляция дорогая (всё в один поток), поэтому SQL-стандарт определяет уровни изоляции — компромисс между скоростью и видимыми аномалиями. К ним вернёмся подробно ниже.

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

После того как COMMIT вернул успех, изменения гарантированно переживут падение сервера, отключение питания и перезагрузку. Даже если страницы данных ещё не записаны на диск.

PostgreSQL обеспечивает Durability через тот же WAL: на COMMIT запись в журнал синхронно сбрасывается на диск (fsync), и только после этого клиенту возвращается успех. Страницы данных можно записать позже — при следующем checkpoint.

Параметр synchronous_commit = off ускоряет коммиты, но ломает Durability в случае сбоя: подтверждённая транзакция может потеряться, если сервер упадёт до fsync. Использовать только там, где потеря последних N миллисекунд — допустима (метрики, логи доступа).

Как PostgreSQL технически реализует ACID

Четыре механизма работают вместе. Понимание их даёт интуицию для всего остального.

WAL — Write-Ahead Log

Каждое изменение сначала записывается в WAL-журнал, потом применяется к страницам данных. Это даёт:

  • Atomicity и Durability — журнал содержит всё, что нужно, чтобы доиграть или откатить транзакцию после сбоя.
  • Производительность — последовательная запись в WAL быстрее, чем случайная в страницы; страницы пишутся пачкой при checkpoint.
  • Репликацию — slave получает поток WAL и применяет у себя.

MVCC — Multi-Version Concurrency Control

Каждая строка хранит две системные метки: xmin (id транзакции-создателя) и xmax (id транзакции, удалившей или обновившей строку). UPDATE не меняет строку на месте — он создаёт новую версию, а старая остаётся с проставленным xmax.

Пример: T1 обновляет цену конфет.

-- T1
BEGIN;
UPDATE product SET price = 180 WHERE id = 3;
-- На диске теперь ДВЕ версии строки id=3:
--   старая: xmin=100, xmax=200, price=150
--   новая:  xmin=200, xmax=0,   price=180

Параллельная T2, начавшаяся ДО COMMIT T1, увидит старую версию (price=150). T2, начавшаяся ПОСЛЕ COMMIT T1, увидит новую (price=180). Никаких блокировок чтения — каждая транзакция видит свой консистентный снимок.

Старые версии удаляются процессом VACUUM или autovacuum после того, как они становятся невидимыми всем активным транзакциям.

Snapshots — снимки видимости

При начале каждого запроса (Read Committed) или транзакции (Repeatable Read) PostgreSQL фиксирует снимок: какие транзакции уже зафиксированы, какие ещё в полёте. Видимость каждой версии строки определяется этим снимком + парой xmin/xmax.

SSI — Serializable Snapshot Isolation

На уровне Serializable PostgreSQL отслеживает зависимости чтения/записи между транзакциями. Если граф зависимостей образует цикл, ведущий к аномалии — одна из транзакций получает ERROR: could not serialize access и должна повторить попытку. Подробнее — в разделе про Serializable ниже.

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

SQL-стандарт определяет четыре уровня. PostgreSQL поддерживает три из них реально (Read Uncommitted = Read Committed), плюс Serializable работает по схеме SSI.

УровеньDirty ReadNon-Repeatable ReadPhantom ReadWrite Skew
Read Uncommittedв PG — нетдадада
Read Committed (default)нетдадада
Repeatable Readнетнетнетда
Serializableнетнетнетнет

«Феномены» — это конкретные аномалии параллелизма. Разберём каждую на примере наших таблиц.

Read Uncommitted — в PostgreSQL это Read Committed

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

Гипотетический dirty read выглядел бы так:

-- T1
BEGIN;
UPDATE product SET price = 999 WHERE id = 3;
-- НЕТ COMMIT

-- T2
SELECT price FROM product WHERE id = 3;
-- На "честном" Read Uncommitted увидела бы 999.
-- В PostgreSQL увидит 150 — старую версию, T1 ещё не commit-нулась.

-- T1
ROLLBACK;
-- Если бы T2 увидела 999, она бы прочитала значение, которого никогда не существовало.

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

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

Это даёт две аномалии: non-repeatable read и phantom read.

Non-repeatable read

T1 читает одну и ту же строку дважды и получает разные значения, потому что между чтениями T2 успела закоммитить UPDATE.

-- T1
BEGIN ISOLATION LEVEL READ COMMITTED;
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  ← то же id, в той же транзакции, другое значение
COMMIT;

Если код T1 принимал решение на основе первого чтения (например, проверял лимит цены) и потом действовал — между чтениями реальность могла измениться.

Phantom read

T1 повторяет один и тот же запрос с диапазоном и получает другое количество строк, потому что T2 успела вставить или удалить.

-- T1
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT COUNT(*) FROM product WHERE category_id = 1;
-- → 3 (Печенье, Мармелад, Конфеты)

-- T2
BEGIN;
INSERT INTO product (category_id, price, name) VALUES (1, 80, 'Зефир');
COMMIT;

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

Когда Read Committed подходит

Большинство веб-приложений с короткими транзакциями: «прочитал — обновил — закоммитил». Если вся бизнес-логика помещается в один SQL-запрос с правильными WHERE, аномалии не возникают. PostgreSQL берёт этот уровень по умолчанию, потому что он быстрый и для CRUD-сервисов достаточный.

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

Транзакция фиксирует один снимок при первом запросе и видит этот снимок до самого конца. Все запросы внутри транзакции видят одинаковые данные, как если бы они исполнились одномоментно.

Non-repeatable read исчезает:

-- T1
BEGIN ISOLATION LEVEL REPEATABLE READ;
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;
-- → 150  ← снимок зафиксирован при первом SELECT, изменение T2 невидимо
COMMIT;

Phantom read тоже исчезает (в PostgreSQL — в стандарте SQL Repeatable Read обязан только non-repeatable, но PG идёт дальше):

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

-- T2
BEGIN;
INSERT INTO product (category_id, price, name) VALUES (1, 80, 'Зефир');
COMMIT;

-- T1
SELECT COUNT(*) FROM product WHERE category_id = 1;
-- → 3  ← новой строки T2 на снимке T1 нет
COMMIT;

Lost update — что ловит Repeatable Read

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

-- T1
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE product SET price = 160 WHERE id = 3;
-- старая версия id=3: xmin=100, xmax=T1

-- T2
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE product SET price = 170 WHERE id = 3;
-- ждёт, пока T1 завершится

-- T1
COMMIT;

-- T2 получает:
-- ERROR: could not serialize access due to concurrent update

Приложение обязано отловить ошибку и повторить транзакцию. Это контракт Repeatable Read.

Write skew — что Repeatable Read НЕ ловит

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

Постановка: бизнес-правило — «суммарная цена сладостей не должна падать ниже 200». На наших данных сейчас 50 + 70 + 150 = 270, лимит соблюдён.

-- T1: даёт скидку 30% на конфеты, если суммарная цена > 200
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT SUM(price) FROM product WHERE category_id = 1;
-- → 270, ок, можно
UPDATE product SET price = 105 WHERE id = 3;  -- 150 → 105
-- если COMMIT прямо сейчас: сумма станет 50 + 70 + 105 = 225, ок

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

-- T1 COMMIT  → сумма 225
-- T2 COMMIT  → сумма 50 + 49 + 105 = 204
-- инвариант "сумма >= 200" соблюдён? да

В этом конкретном раскладе инвариант чудом сохранился. Но если правило было «сумма >= 250», обе транзакции по отдельности на снимке видели 270 и считали, что скидка допустима — а после обоих коммитов сумма стала 204. Инвариант, который был бы соблюдён при последовательном исполнении, при параллельном нарушился.

Это write skew: обе транзакции изменили разные строки на основе одних и тех же прочитанных данных, и ни одна не пересеклась с другой по UPDATE — Repeatable Read проблему не видит.

Spotting tip: write skew почти всегда — про инварианты, охватывающие несколько строк, и про решения, принимаемые на основе чтения этих строк.

Serializable — формальная сериализация

Параллельные транзакции исполняются так, как если бы они шли последовательно, в каком-то порядке. Никаких аномалий. В PostgreSQL реализован через SSI — без физических блокировок, а через отслеживание графа зависимостей.

Тот же пример с инвариантом, но на Serializable:

-- T1
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT SUM(price) FROM product WHERE category_id = 1;  -- → 270
UPDATE product SET price = 105 WHERE id = 3;

-- T2
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT SUM(price) FROM product WHERE category_id = 1;  -- → 270
UPDATE product SET price = 49 WHERE id = 2;

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

PostgreSQL увидел, что T2 читала диапазон category_id = 1, а T1 обновила строку из этого диапазона на основе чтения того же диапазона — это циклическая зависимость, ведущая к аномалии. T2 откатывается, приложение её повторяет — на повторе T2 видит уже зафиксированное состояние после T1 и принимает решение на актуальных данных.

Цена Serializable

  • Не блокировки, а откаты: SSI не держит долгие блокировки, но провоцирует serialization_failure чаще, чем Repeatable Read. Приложение обязано уметь повторять транзакцию.
  • Накладные расходы на отслеживание зависимостей — заметны на высокой нагрузке с большим числом параллельных длинных транзакций.
  • Read-only транзакции на Serializable почти бесплатны — для них PG включает оптимизацию read-only deferrable.

Когда Serializable необходим

  • Денежные операции с инвариантами на нескольких строках (баланс, лимиты, агрегаты).
  • Бизнес-правила, проверяющие несуществование строк перед вставкой (классическое «один логин в системе»).
  • Везде, где write skew — реальный риск.

В нашем примере: если есть инвариант «сумма цен в категории не падает ниже X», и решения о скидках принимаются параллельно — Serializable.

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

Прагматичный выбор по большинству задач:

СценарийУровень
CRUD-приложение, короткие транзакции, одна строка за разRead Committed (по умолчанию)
Отчёт за период, нужны консистентные данные на момент стартаRepeatable Read
Перевод денег между счетами, проверка лимита перед списаниемSerializable
Бизнес-инвариант на нескольких строках, явные параллельные апдейтыSerializable
Импорт батча, single writerRead Committed хватит
Pure read-only долгий аналитический запросRepeatable Read (или SET TRANSACTION READ ONLY)

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

  • Поднимать уровень снизу вверх, а не «давайте сразу Serializable». Каждый уровень дороже.
  • Если выбран Repeatable Read или Serializable — приложение обязано уметь повторять транзакцию при ошибке serialization_failure / could not serialize access. Без retry-логики код будет нестабильным под нагрузкой.
  • Read Committed + SELECT ... FOR UPDATE часто решает задачу не хуже Repeatable Read и понятнее в коде. Чтение с замком — короче, чем долгая транзакция.
  • Не путать уровень изоляции с явными блокировками: FOR UPDATE, LOCK TABLE, advisory locks работают на любом уровне и могут заменить или дополнить изоляцию.

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