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:
| id | name |
|---|---|
| 1 | Сладости |
| 2 | Мясо |
| 3 | Молочные продукты |
product:
| id | category_id | price | name |
|---|---|---|---|
| 1 | 1 | 50 | Печенье |
| 2 | 1 | 70 | Мармелад |
| 3 | 1 | 150 | Конфеты |
| 4 | 2 | 150 | Куриная грудка |
| 5 | NULL | 400 | Свинина |
| 6 | NULL | 100 | Молоко |
| 7 | NULL | 120 | Кефир |
Обратите внимание на товары 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 Read | Non-Repeatable Read | Phantom Read | Write 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 writer | Read 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 работают на любом уровне и могут заменить или дополнить изоляцию.
Что почитать дальше
- PostgreSQL: Transaction Isolation — официальная документация.
- PG Style Guide — правила работы с PostgreSQL в наших Java/Spring-сервисах.
- Тактические паттерны DDD — почему агрегат = транзакционная граница, и как это упрощает выбор уровня изоляции.
- Distributed Patterns Style Guide — как обеспечить целостность за пределами одной БД (saga, idempotency, outbox).