База данных, которая «теряет деньги при сбое» — это не база данных, а кэш с амбициями. Гарантии того, что транзакция либо применилась целиком, либо не применилась вовсе, — фундамент любой настоящей СУБД. Этот набор гарантий называется 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 НЕ ловит
Аномалия, при которой обе транзакции читают одно и то же, принимают независимые решения, и в результате нарушается инвариант, который держался бы при последовательном исполнении.
Постановка: бизнес-правило — «суммарная цена сладостей не должна падать ниже 250». На наших данных сейчас 50 + 70 + 150 = 270, лимит соблюдён с запасом 20. Логика приложения: «если сумма ещё ≥ 250, можно списать с одного товара до 70».
-- 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; -- 150 → 80
-- T2 параллельно: списывает 40 с мармелада
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT SUM(price) FROM product WHERE category_id = 1;
-- → 270 (на её снимке изменения T1 не видны), запас 20 → можно списать 40
UPDATE product SET price = 30 WHERE id = 2; -- 70 → 30
-- T1 COMMIT → сумма 50 + 70 + 80 = 200
-- T2 COMMIT → сумма 50 + 30 + 80 = 160
-- Итог: 160 < 250, инвариант нарушен.
Обе транзакции по отдельности на своём снимке видели 270 и считали, что списание допустимо. После обоих коммитов сумма стала 160. При последовательном исполнении T2 после T1 увидела бы уже 200 и отказалась бы делать списание.
Это 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 = 80 WHERE id = 3;
-- T2
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT SUM(price) FROM product WHERE category_id = 1; -- → 270
UPDATE product SET price = 30 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 работают на любом уровне и могут заменить или дополнить изоляцию.
FAQ для Java/Spring команд
Какой уровень изоляции по умолчанию у Spring @Transactional?
Spring не устанавливает уровень и не меняет PostgreSQL — действует значение, заданное в драйвере (по умолчанию default_transaction_isolation = 'read committed' в postgresql.conf). Чтобы зафиксировать явно, пишем @Transactional(isolation = Isolation.REPEATABLE_READ).
Почему @Transactional(isolation = SERIALIZABLE) не «магически решает все гонки»?
Решает — но только при условии, что приложение умеет повторять транзакцию по SQLException с sqlState = 40001 (serialization_failure). Без retry-логики код будет случайно падать на пользователе. В Spring обычно оборачивают вызов в @Retryable(retryFor = ConcurrencyFailureException.class, ...) или ловят CannotAcquireLockException / CannotSerializeTransactionException руками.
Когда брать SELECT ... FOR UPDATE вместо высокого уровня изоляции?
Когда нужна блокировка одной конкретной строки (списать с баланса, выдать ID из последовательности), а не сериализация целой бизнес-операции. FOR UPDATE короче в коде, понятнее в логах, не зависит от уровня изоляции и не вызывает откатов — просто ждёт.
Что лучше: Repeatable Read + FOR UPDATE или просто Serializable?
Зависит от профиля нагрузки. Если конфликтов мало — Serializable дешевле (FOR UPDATE блокирует всегда, Serializable откатывает только при реальном конфликте). Если конфликтов много или транзакции долгие — FOR UPDATE предсказуемее по времени отклика.
Длинная транзакция на Repeatable Read — почему это плохо? Старые версии строк не могут удаляться VACUUM-ом, пока существует транзакция с зафиксированным снимком. На write-heavy таблице долгий снимок (минуты, часы) ведёт к bloat — таблица растёт, индексы тормозят. Поэтому отчётные транзакции лучше пускать на реплику или в read-only режиме на отдельный экземпляр.
Что почитать дальше
- PostgreSQL: Transaction Isolation — официальная документация.
- PG Style Guide — правила работы с PostgreSQL в наших Java/Spring-сервисах.
- Тактические паттерны DDD — почему агрегат = транзакционная граница, и как это упрощает выбор уровня изоляции.
- Distributed Patterns Style Guide — как обеспечить целостность за пределами одной БД (saga, idempotency, outbox).