PostgreSQL
Транзакции и уровни изоляции в PG
Два потока одновременно снимают деньги с одного счёта. Без транзакций - оба прочитали 1000, оба записали 500, итог 500 вместо 0. С транзакциями - второй поток ждёт или получает ошибку сериализации. Выбор уровня изоляции определяет, насколько параллельной и насколько безопасной будет система.
- **Stripe** использует SERIALIZABLE изоляцию для списаний с баланса - write skew привёл бы к возможности уйти в минус при параллельных списаниях
- **Booking.com** применяет REPEATABLE READ для бронирования номеров - одна транзакция читает доступные номера и бронирует, другая не должна видеть изменения посередине
- **Shopify** хранит корзину в REPEATABLE READ транзакции на время checkout - все проверки цен и наличия видят консистентный снимок в 50+ миллисекунд транзакции
ACID и что именно гарантирует PG
Транзакция - это единица работы с четырьмя гарантиями: Atomicity (всё или ничего), Consistency (данные остаются валидными), Isolation (параллельные транзакции не мешают друг другу), Durability (зафиксированное переживёт сбой). В PG каждый отдельный SQL-оператор выполняется в неявной транзакции, если явная не открыта.
В PG транзакция помечается как aborted (в error state) при первой же ошибке - все последующие команды отклоняются с `ERROR: current transaction is aborted`. Выйти можно только через ROLLBACK или ROLLBACK TO SAVEPOINT.
В середине транзакции случился `ERROR: duplicate key value`. Что произойдёт со следующим SELECT?
Read Committed - дефолт PG
Read Committed - уровень изоляции по умолчанию в PG. Каждый SQL-оператор видит снимок данных на момент своего начала (не начала транзакции). Это значит: два SELECT в одной транзакции могут увидеть разные данные если между ними кто-то сделал COMMIT.
Read Committed защищает от Dirty Read (чтения незафиксированных данных), но не защищает от Non-Repeatable Read и Phantom Read. Для отчётов по финансовым балансам используйте REPEATABLE READ.
В Read Committed транзакция делает два SELECT count(*) FROM orders WHERE status='pending' с разрывом в 1 секунду. Могут ли они вернуть разные числа?
Repeatable Read
В Repeatable Read транзакция фиксирует снимок данных при первом обращении к данным (первом DML или SELECT) и держит его до конца. Все SELECT в транзакции видят одинаковое состояние базы. При UPDATE/DELETE строки, изменённые другой транзакцией с момента начала снимка, вызывают ошибку сериализации - PG откатывает транзакцию.
PG реализует Repeatable Read через MVCC - без блокировок чтения. Другие транзакции продолжают писать в таблицу; текущая просто не видит их изменений. Это гораздо эффективнее, чем read locks в MySQL.
Транзакция в REPEATABLE READ делает UPDATE строки, которую другая транзакция уже изменила и закоммитила. Что произойдёт?
Serializable и SSI
Serializable - наивысший уровень изоляции. PG использует Serializable Snapshot Isolation (SSI) - алгоритм, обнаруживающий зависимости чтения-записи между транзакциями (rw-антизависимости). Если обнаружен цикл зависимостей (две транзакции взаимно зависят через read-write), одна из них принудительно откатывается. При этом читающие транзакции не блокируют пишущих.
SSI в PG требует хранить predicate locks (pg_predicate_lock) - метаданных о том, что читалось. Параметр `max_pred_locks_per_transaction` (128 по умолчанию) контролирует гранулярность. При превышении PG эскалирует lock с row-уровня до page и table - это может вызвать ложные сериализационные ошибки.
Write Skew аномалия - почему Repeatable Read не защищает от неё?
Savepoints - частичный откат
Savepoint позволяет откатить часть транзакции, сохранив предыдущую работу. Это полезно в длинных транзакциях, где один шаг может упасть с ошибкой, но остальное должно остаться. JDBC и SQLAlchemy используют savepoints под капотом для `nested transactions`.
RELEASE SAVEPOINT sp1 освобождает память, занятую savepoint-метаданными, но не меняет состояние транзакции. После RELEASE откатиться к этому savepoint нельзя - только к более ранним.
SAVEPOINT = отдельная вложенная транзакция с независимым COMMIT
Savepoint не создаёт независимой транзакции. COMMIT финализирует всё, ROLLBACK откатывает всё. ROLLBACK TO возвращает к точке, но не коммитит.
В SQL нет nested transactions с независимыми COMMIT. То, что ORMы называют 'вложенными транзакциями' - это savepoints; COMMIT вложенной транзакции в ORM делает RELEASE SAVEPOINT, а не реальный COMMIT.
ROLLBACK TO SAVEPOINT sp1 при ошибке внутри транзакции - транзакция после этого жива?
Ключевые идеи
- Read Committed (дефолт): каждый оператор видит свежий снимок - быстро, но non-repeatable reads возможны
- Repeatable Read: снимок фиксируется при первой операции - одинаковые SELECTs, но write skew не защищает
- Serializable SSI: обнаруживает циклы rw-зависимостей без блокировок чтения - изоляция без write skew, с overhead на predicate locks
Связанные темы
Уровни изоляции тесно связаны с внутренними механизмами PG:
- MVCC и версионирование строк — Уровни изоляции реализованы через MVCC - snaphot visibility rules определяют какие tuple versions видит транзакция
- Блокировки PG — Serializable использует predicate locks дополнительно к обычным row locks - pg_locks показывает оба типа
- VACUUM и dead tuples — Долгоживущие транзакции в REPEATABLE READ удерживают старые версии строк от очистки VACUUM - bloat растёт
Вопросы для размышления
- Почему SERIALIZABLE в PG не блокирует читателей, хотя и обнаруживает конфликты? Как SSI отличается от S2PL (strict two-phase locking)?
- ORM открывает транзакцию при старте HTTP-запроса и коммитит в конце. При Read Committed: что произойдёт если за 200 мс обработки другая транзакция поменяла данные?
- В каком сценарии savepoints дают явное преимущество перед retry всей транзакции с нуля?