REPEATABLE READ в Postgres не обеспечивает настоящую сериализуемость: write skew пролетает мимо. До 9.1 (2011) единственный способ получить true SERIALIZABLE в Postgres был — взять LOCK TABLE или SELECT FOR UPDATE на все читаемые строки. Это работало, но убивало concurrency.
В 9.1 появилась реализация SSI — Serializable Snapshot Isolation на основе работы
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE в современном Postgres. И это сильно отличается от классического блокировочного SERIALIZABLE из DB2.
Граф зависимостей: rw-зависимость как причина проблем
Чтобы понять SSI, нужно научиться видеть транзакции как вершины графа, а конфликты — как рёбра:
ww — write-write, wr — write-read, rw — read-write (анти-зависимость). SI ловит ww-конфликты (через xmax). А вот rw — нет: и именно rw создаёт write skew и serialization violations.
Cahill et al. доказали: если в графе rw-зависимостей нет цикла, то расписание транзакций сериализуемо. Если есть цикл — расписание может нарушить инварианты. Минимальная опасная структура — это «pivot».
Pivot и dangerous structure
- есть входящее rw-ребро от T_in (то есть T_in читает то, что T потом пишет)
- есть исходящее rw-ребро к T_out (T читает то, что T_out потом пишет)
- T_out коммитит первой из этих трёх
Если такая структура существует и T_out закоммитилась первой — расписание может быть несериализуемым. SSI находит её и abort'ит pivot.
Конкретный пример с write skew (детально в уроке 4): два врача, оба читают «активных дежурных» (rw), оба обновляют свой статус. Если «активных» становится 0 — инвариант «хотя бы один на смене» нарушен.
SIREAD-locks: легковесные предикатные «блокировки»
Чтобы detect’ить rw-зависимости, Postgres должен знать, что именно читала каждая SERIALIZABLE-транзакция. Для этого вводится
- На каждое прочитанное место (page, tuple, диапазон по индексу) ставится SIREAD-метка.
- Метка хранит txid читавшего и идентификатор объекта.
- Когда другая транзакция пишет в тот же объект, движок проверяет: есть ли SIREAD от активной транзакции? → создаёт rw-ребро в графе.
От грубой к мелкой. Чем мельче — тем меньше false positives (лишних abort'ов), но больше памяти. Эскалация: если транзакция читает много — метки могут собраться вверх по иерархии.
Важное свойство: SIREAD-локи переживают COMMIT транзакции. Они хранятся до тех пор, пока хотя бы одна другая активная транзакция могла бы вступить с ними в конфликт. Это нужно, потому что dangerous structure определяется по commit-order, а не по «текущему набору транзакций».
Detection algorithm: когда поднимается 40001
В упрощённом виде Postgres делает следующее:
для каждой записи (UPDATE/INSERT/DELETE) транзакции T:
найти все SIREAD-локи, покрывающие изменяемый объект
для каждого такого lock от транзакции Tr:
добавить rw-ребро Tr → T в граф
на COMMIT транзакции T:
для каждого pivot-кандидата (T_in -rw-> T -rw-> T_out):
если T_out уже закоммичена:
abort одной из (T_in, T, T_out) с ошибкой 40001
Это пессимистично-оптимистичный алгоритм:
- Чтения не блокируются — только метятся (это часть «оптимистичной» стороны).
- Конфликт detect’ится при commit или при write — приложение узнаёт об ошибке поздно.
- Abort’ятся транзакции внутри dangerous structure, не обязательно та, которая последней пыталась commit’нуться.
false positives: цена за легковесность
SSI может abort’ить транзакции, которые объективно были бы сериализуемы — это false positives. Цена за то, что SIREAD-локи имеют ограниченную гранулярность:
- Если T1 прочитала страницу из 100 строк, а T2 обновила одну строку из тех 100, которая T1 даже не интересовала — SIREAD на странице создаёт rw-ребро.
- На больших Seq Scan’ах SSI почти всегда сигналит — поэтому в
SERIALIZABLEобычно избегают полных сканов.
SET application_name + pg_stat_activity.wait_event помогает диагностировать. Реальный показатель false positive rate — параметр pg_stat_database.serialization_failures vs общий tx-count.
Демо: открываем SERIALIZABLE-транзакцию
Базовый запуск SERIALIZABLE-транзакции в Postgres. SIREAD-локи невидимы для пользователя в обычных SELECT, но можно глянуть pg_locks и фильтровать по mode='SIReadLock'.
В реальном Postgres ты увидишь несколько строк типа mode = 'SIReadLock'. В pglite (упрощённой embedded-версии) внутренние lock-структуры экспонированы не полностью, но семантика та же.
Проверка serialization_failures в статистике базы. Этот счётчик растёт при abort'е транзакций с ошибкой 40001. Полезен для мониторинга.
В production-Postgres колонки xact_commit, xact_rollback + pg_stat_database.serialization_failures дают полную картину абортов.
Что SSI не делает
Чтобы не питать иллюзий, перечислим, чего SSI не обеспечивает:
- DDL не сериализуется.
ALTER TABLEберёт обычные heavyweight lock’и, и его эффект виден всем сразу. - Triggers and functions — выполняются в той же транзакции, и их чтения учитываются. Но если функция вызывает
dblinkилиpg_notify— это внешние эффекты, не откатываемые при 40001. - Sequences (nextval) — не сериализуются. Если две транзакции в SERIALIZABLE вызвали
nextval('s')и обе откатились — числа всё равно потеряны. - Truncate и аналогичные DDL — ведут себя как exclusive lock.
Performance trade-off вперёд
В следующем уроке покажем конкретный пример write skew с дежурными врачами и продемонстрируем, как SSI его ловит. А заодно поговорим о цене: SERIALIZABLE заметно медленнее RC и RR из-за overhead SIREAD-locks. На write-heavy workload разница доходит до 30%.
Чек-лист
- SSI (Postgres 9.1+) — реализация SERIALIZABLE через snapshot + SIREAD-locks + detection of dangerous structure.
- Cahill et al. (2008) показали: если в графе rw-зависимостей нет цикла → расписание сериализуемо. Минимальная опасная структура — pivot с двумя соседями.
- SIREAD-lock — легковесная метка «эта транзакция читала этот объект», не блокирующая чтения других транзакций. Хранится в shared-memory.
- Гранулярность SIREAD: tuple → page → relation. Большие сканы = больше false positives.
- SIREAD-локи переживают COMMIT — они хранятся до тех пор, пока могут конфликтовать с активной транзакцией.
- При detection — поднимается SQLSTATE 40001
serialization_failure, та же ошибка, что и при ww-конфликте в RR. Retry — единственное правильное действие. - SSI быстрее блокировочного SERIALIZABLE, но медленнее RC/RR. Цена платится за честную сериализуемость.