База данных, которая «теряет деньги при сбое» — это не база данных, а кэш с амбициями. Разберёмся с нуля: что такое транзакция, что гарантирует 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 Read | Non-Repeatable Read | Phantom Read | Write 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.