Learning Platform
Урок 12.04 · 28 мин
Продвинутый
write skewSSISERIALIZABLEanomalypredicate lockperformance

В уроках 2 и 3 мы говорили, что snapshot isolation запрещает phantom, но допускает write skew, и что SSI его ловит. В этом уроке — конкретный пример, который годами разбирают в академических курсах: two doctors on call. Это не игрушка: реальные системы (страховые, банковские, бронирование) ровно так теряют инварианты.

Сценарий: дежурные врачи

В больнице есть таблица shifts со строками для каждого врача и булевым полем on_call. Инвариант приёмного отделения: в каждый момент count(on_call=true) >= 1. Без этого приёмное отделение закрыто.

Алиса и Боб сегодня дежурят, оба хотят уйти домой. Каждый проверяет: «есть ли ещё кто-то на смене?» — если да, снимает свой on_call. Логика, кажется, корректна.

Write skew: оба врача уходят, инвариант сломан

В snapshot isolation оба видят одну картину (2 дежурных), оба считают, что могут уйти, оба обновляют свою строку — и нарушают инвариант. SI не detect'ит конфликт, потому что они пишут в РАЗНЫЕ строки.

T_Alice (RR)BEGIN ISOLATION LEVEL REPEATABLE READ
T_AliceSELECT count(*) FROM shifts WHERE on_call → 2
T_Aliceif count >= 2 then UPDATE shifts SET on_call=false WHERE doctor='Alice'
T_AliceCOMMIT
T_Bob (RR)BEGIN ISOLATION LEVEL REPEATABLE READ
T_BobSELECT count(*) FROM shifts WHERE on_call → 2 (свой snapshot)
T_Bobif count >= 2 then UPDATE shifts SET on_call=false WHERE doctor='Bob'
T_BobCOMMIT — оба зафиксированы успешно!
инвариантнарушен: count(on_call)=0

Что произошло формально:

  • T_Alice прочитала множество {Alice, Bob} и записала в Alice.
  • T_Bob прочитала то же множество {Alice, Bob} и записала в Bob.
  • Write-sets непересекающиеся → ww-конфликта нет.
  • Read-sets пересекающиеся, и каждая пишет в множество, которое другая прочитала → rw-зависимость в обе стороны.

В графе зависимостей это цикл: T_Alice -rw-> T_Bob -rw-> T_Alice. По теореме Cahill — расписание не сериализуемо. SI этого не видит, потому что отслеживает только ww через xmax.

Демо: write skew в REPEATABLE READ

Покажем на pglite. Конечно, у нас одно соединение, но семантически: два snapshot’а можно «эмулировать», пока никто не делает COMMIT между ними. Используем PREPARE / EXECUTE:

Симуляция write skew. В реальности нужно две сессии, но логика такая: каждый «врач» проверяет count и затем обновляет свою строку. Если в RR оба прошли первый шаг до того, как кто-то записал — оба commit'ятся, инвариант ломается.

PostgreSQL

В RR (snapshot isolation) обе транзакции прошли бы успешно, потому что они пишут в разные строки и не нарушают ни одного MVCC-правила Postgres.

Теперь SSI: тот же сценарий, другой результат

Запустим то же самое в SERIALIZABLE. В pglite это не полная демонстрация SSI (одно соединение), но логически: при настоящем SERIALIZABLE одна из двух транзакций получит 40001 при COMMIT.

SERIALIZABLE: каждое чтение оставляет SIREAD-метку. Когда вторая транзакция начинает писать в строку, которая входила в read-set первой — создаётся rw-ребро. На втором ребре в обратную сторону — образуется цикл, и SSI abort'ит одну из транзакций.

PostgreSQL

В реальном Postgres с двумя параллельными сессиями: первая закоммитится, вторая получит ERROR: could not serialize access due to read/write dependencies among transactions. Hint в ошибке так и говорит: «The transaction might succeed if retried».

Анатомия detection

Шаг за шагом — что именно делает Postgres в этом сценарии:

Что происходит внутри SSI

Каждое чтение → SIREAD на page или tuple. Каждый write → проверка пересечения с активными SIREAD. При обнаружении dangerous structure — abort.

T_Alice SELECT count(*) WHERE on_call
результатSIREAD-метка на read-set (heap-page или index-range)
T_Bob SELECT count(*) WHERE on_call(параллельно)
результатсвой SIREAD на тот же read-set
T_Alice UPDATE Alice.on_call=false
результатдвижок видит SIREAD от T_Bob → ребро T_Bob -rw-> T_Alice
T_Bob UPDATE Bob.on_call=false
результатдвижок видит SIREAD от T_Alice → ребро T_Alice -rw-> T_Bob
commit T_Alicedangerous structure обнаружена: T_Alice -rw-> T_Bob -rw-> T_Alice — это pivot. T_Bob abort'ится с 40001

Заметь, что abort произошёл на стороне T_Bob, а не T_Alice. Postgres выбирает «жертву» по эвристикам: предпочитает абортить транзакцию, которая ещё не commit’нулась, и которая делает больше изменений. Логика — в predicate.c, функция OnConflict_CheckForSerializationFailure.

Когда SI хватает, когда нужен SSI

Не всегда нужно поднимать уровень. Чек-лист на разработке:

Решение: SI или SSI

Если транзакция read-only — SI достаточно. Если 'read + check + write' и решение по чтению влияет на запись — нужно или SSI, или SELECT FOR UPDATE.

read-only транзакцияSI достаточно
ничего не записываешьнечего терять
UPDATE одной строкиSI достаточно (ww защита)
чтение нерелевантно для записинет write skew
read-then-writeSSI или SELECT FOR UPDATE
инвариант на множестве строкклассический случай write skew

Альтернатива SSI — явная блокировка: SELECT ... FOR UPDATE в RR. Тогда read становится lock-take, и параллельные T1/T2 сериализуются на этом lock’е. Минус — нужно помнить добавлять, легко забыть. Плюс — без false positives.

Гибрид: в большинстве кода RC + точечный FOR UPDATE на критических read-then-write секциях.

Performance: что стоит SERIALIZABLE

Цена SSI на синтетических бенчмарках:

  • TPC-C (write-heavy, точечные обновления): SERIALIZABLE медленнее RR на 10-25%.
  • YCSB workload A (read/write mix): 5-15%.
  • Чистая аналитика (read-only): почти 0% разницы — read-only транзакции в SERIALIZABLE имеют специальную оптимизацию: не ставят SIREAD-locks, если объявлены READ ONLY + DEFERRABLE.

Что влияет на overhead:

  • Объём read-set: чем больше — тем больше SIREAD-locks, тем больше работы при write-проверке.
  • Гранулярность: точечные индексные доступы лучше Seq Scan’ов.
  • Длина транзакций: длинные = SIREAD-локи живут дольше, шансы конфликта выше.
  • Conflict rate: при высоком contention частота 40001 растёт квадратично от числа параллельных транзакций на одно «горячее» множество.

READ ONLY DEFERRABLE — специальный режим. Postgres определяет такую транзакцию как 'не может быть pivot' и не ставит SIREAD-locks. Идеально для долгих отчётов в SERIALIZABLE.

PostgreSQL

READ ONLY DEFERRABLE — best practice для read-only batch-job’ов в SERIALIZABLE-окружении.

Лечение write skew без SSI

Если поднимать уровень не хочется (например, ORM на это не рассчитан), есть два классических лечения:

  1. SELECT FOR UPDATE на читаемых строках. Берёт row-level exclusive lock, конкурирующие транзакции ждут на нём.
  2. Materialize conflict: записать в одну общую строку (например, инкрементировать счётчик shifts_on_call) при каждом обновлении. Тогда write skew превращается в ww-конфликт, который SI уже ловит.
  3. Promote to single source of truth: денормализовать инвариант в отдельную таблицу с уникальным ключом, например current_on_call_count, и UPDATE её — это форсирует ww на одной строке.

В прода-коде SSI обычно даёт самый чистый и наименее инвазивный путь — но цена retry-логики реальна.

Проверка знанийKnowledge check
Объясни конкретно: почему snapshot isolation НЕ может в принципе detect'ить write skew с двумя врачами, и какое минимальное изменение в схеме приложения превратит этот write skew в обычный ww-конфликт, который SI поймает?
ОтветAnswer
Snapshot isolation определяет конфликт через xmax: если строка, которую я хочу обновить, уже имеет xmax от закоммиченной невидимой мне транзакции — это ww-конфликт, abort. В write skew T_Alice обновляет строку 'Alice', T_Bob — строку 'Bob'. У этих строк xmax = 0 для обеих транзакций, никто их не трогал. ww нет. Read-set'ы пересекаются (обе читали обе строки в SELECT count), но SI этого не отслеживает — для этого нужен read-tracking, которого в чистом SI нет (только в SSI через SIREAD-locks). Минимальное изменение: создать таблицу с одной строкой 'shift_state' и инкрементировать в ней счётчик 'doctors_off_today' при каждом UPDATE. Теперь обе транзакции UPDATE'ят одну строку — это форсирует ww-конфликт, SI его поймает через xmax, и одна из них упадёт с 40001. Это и называется 'materialize conflict' — паттерн, который позволяет жить в RR без full SERIALIZABLE.

Чек-лист

  • Write skew — два транзакции читают пересекающееся множество, пишут в непересекающиеся строки, обе commit’ятся в RR/SI → инвариант нарушен.
  • В графе зависимостей это цикл из rw-рёбер: SI не отслеживает rw, поэтому не видит проблемы.
  • SSI через SIREAD-locks detect’ит rw-зависимости и abort’ит одну из транзакций цикла с 40001.
  • Жертва выбирается по эвристикам Postgres’а — не всегда последняя, чаще ещё не commit’нувшаяся.
  • Альтернативы SSI: SELECT FOR UPDATE, materialize conflict в одну row.
  • Performance overhead: 10-25% на write-heavy, ~0 на read-only с READ ONLY DEFERRABLE.
  • Best practice: длинные отчёты в SERIALIZABLE READ ONLY DEFERRABLE — не ставят SIREAD-locks вовсе.
Четыре аномалии: dirty read, non-repeatable, phantom, write skew

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Почему snapshot isolation (REPEATABLE READ в Postgres) не может detect'ить write skew?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5