Serializability deep dive — concept page covering SQL isolation levels, write skew anomaly, SSI in Postgres, Strict 2PL with deadlock detection, and OCC. Multi-scenario FlowBuilder: SERIALIZABLE happy path with disjoint writes, write skew under SNAPSHOT ISOLATION (doctors on-call invariant broken), SSI abort 40001 with client retry, and 2PL deadlock with InnoDB victim selection. Topology: Postgres group with tx-mgr (MVCC + SSI), heap (row versions), serialization graph (rw-deps), plus T1/T2 clients. ADR on tx-mgr explaining when to use SERIALIZABLE vs SI/RC + SELECT FOR UPDATE.
READ COMMITTED (default Postgres) и REPEATABLE READ (default MySQL, он же SNAPSHOT ISOLATION) — НЕ защищают от write skew: классики «должен быть хотя бы один врач on-call», «не уйди в минус по балансу», «не продай последний билет дважды». Обе транзакции читают snapshot одновременно, обе видят инвариант целым, обе уводят свою строку — и инвариант ломается на коммитах, потому что writes не пересекаются по rows и БД не видит конфликта.
SERIALIZABLE — единственный isolation level, который ловит write skew, но платит throughput'ом (2–10× drop) и serialization_failure (SQLSTATE 40001), которые требуют retry в клиенте. Этот гайд — про то, как именно SSI/2PL/OCC реализуют гарантию, где они ломаются и когда поднимать isolation вместо SELECT FOR UPDATE точечно.
Сериализуемый ≠ один за другим. Транзакции выполняются параллельно, но результат должен быть таким, будто их выполнили последовательно в каком-то порядке (любом, не конкретном). Если ни один serial order не воспроизводит увиденное состояние — это аномалия, и SERIALIZABLE её аборт'ит.
Три ключевых разделения, которые путают на собеседованиях:
Tx Manager (snapshot + SSI) — координатор: парсит SQL, выдаёт каждой транзакции свой MVCC snapshot, направляет writes в heap, регистрирует rw-зависимости в graph. Heap (MVCC row versions) — хранит несколько версий каждой строки (xmin/xmax tuples), читатели не блокируют писателей. Serialization graph (rw-deps) — структура, которая трекает «T2 прочитал то, что T1 потом перезаписал» (rw-edge) и при коммите ищет dangerous structure (две rw-edges подряд = potential cycle → abort).
Два клиента — T1: Alice и T2: Bob — это две параллельные транзакции в сценариях ниже. Edges между клиентами и tx-mgr — физические соединения (запросы и ответы идут по ним в обе стороны, reverse animation).
happy-serializable — SERIALIZABLE без конфликта. T1 трогает alice, T2 трогает bob. Каждая получает свой snapshot (xmin=100, xmin=101), пишет свою row, граф фиксирует «T1 пишет alice, нет читателя alice ⇒ no rw-edge». Обе коммитятся, результат эквивалентен serial T1→T2. Это типичный case на disjoint row sets: SSI не платит ничего за SERIALIZABLE, когда транзакции реально независимы.
write-skew-under-SI — почему SI этого не ловит. Обе транзакции читают count(*) WHERE on_call=true → 2. Каждая думает «инвариант защищён, могу уйти off-call». T1 пишет alice, T2 пишет bob — writes не пересекаются по rows, SI не видит конфликта, обе коммитятся. Результат: 0 врачей on-call, инвариант сломан. Под SERIALIZABLE одна из транзакций получила бы 40001 — потому что SSI трекает predicate reads, а не только row reads.
ssi-abort-retry — тот же сценарий, но с SSI. При читах T1.reads += {alice, bob}, T2.reads += {alice, bob} (predicate lock на on_call=true). При записи T1 в alice граф рисует rw-edge T2→T1 (T2 прочитал то, что T1 переписал). При записи T2 в bob — rw-edge T1→T2. Цикл. На COMMIT T1 — побеждает (committed first), на COMMIT T2 — graph detect dangerous structure, abort 40001. Клиент retry'ит, T2-retry видит alice уже off-call, application logic видит count=1 и возвращает 409. Цена — 10–30ms latency tail на retry; payoff — инвариант никогда не ломается.
2pl-deadlock — альтернативная реализация (MySQL InnoDB). SS2PL берёт shared read locks + exclusive write locks, держит до COMMIT. T1 берёт X-lock на A, T2 — на B. T1 хочет B, T2 хочет A — wait-for graph: T1→T2→T1. Deadlock detector выбирает victim (обычно ту, что меньше записала), убивает с error 1213. Цена 2PL — read locks убивают read-heavy workload + deadlock storms на cross-row updates без отсортированного порядка захвата. SSI избегает обоих этих издержек (нет read locks, нет wait-for graph).
ADR ноды tx-mgr фиксирует главное решение: SERIALIZABLE — точечно, не глобально. Включай его на критичных транзакциях, где нарушение инварианта = деньги/безопасность: payment writes, inventory decrement, seat allocation, on-call rosters, любой business invariant поверх множества строк. Везде ещё — RC/SI + точечный SELECT FOR UPDATE на горячих строках (закрывает 80% write skew без боли SSI). Обязательное условие включения SERIALIZABLE: retry-loop в data layer на 40001, 3–5 попыток с exponential backoff и jitter. Без retry приложение начнёт случайно фейлить под нагрузкой и никто не поймёт почему.
Глобально SERIALIZABLE — антипаттерн: на read-heavy workload throughput падает в 5×, abort rate растёт нелинейно с contention, p99 latency идёт в потолок. Strict serializability (Spanner TrueTime, Fauna) — ещё одна категория, нужна когда требуется Lin + Ser одновременно (cross-region exactly-once payments, distributed leader elections с external observers).
SERIALIZABLE на balance writes (двойное списание = катастрофа).BEGIN; SELECT; sleep; UPDATE; COMMIT под RC → lost update (последний writer выигрывает, потеряв чужой update).SELECT FOR UPDATE блокирует phantoms — нет, только существующие rows. Для phantom protection нужны predicate locks (которые SSI делает) или SERIALIZABLE.SELECT FOR UPDATE без сортировки ID при multi-row locks → deadlock storm. Всегда ORDER BY id перед FOR UPDATE.Не поднимай isolation до SERIALIZABLE, если:
UPDATE ... SET balance = balance - 100 WHERE id = X AND balance >= 100) решает дешевле.Используй SELECT FOR UPDATE вместо SERIALIZABLE, если: конфликт локализован в небольшом наборе rows (inventory item, account row, seat), и ты можешь заранее знать, какие именно строки трогать. Это 80% реальных случаев write skew. SERIALIZABLE остаётся для тех 20%, где предикаты сложные (WHERE on_call=true AND department='X') и FOR UPDATE на predicate невозможен.