В уроках 2 и 3 мы говорили, что snapshot isolation запрещает phantom, но допускает write skew, и что SSI его ловит. В этом уроке — конкретный пример, который годами разбирают в академических курсах: two doctors on call. Это не игрушка: реальные системы (страховые, банковские, бронирование) ровно так теряют инварианты.
Сценарий: дежурные врачи
В больнице есть таблица shifts со строками для каждого врача и булевым полем on_call. Инвариант приёмного отделения: в каждый момент count(on_call=true) >= 1. Без этого приёмное отделение закрыто.
Алиса и Боб сегодня дежурят, оба хотят уйти домой. Каждый проверяет: «есть ли ещё кто-то на смене?» — если да, снимает свой on_call. Логика, кажется, корректна.
В snapshot isolation оба видят одну картину (2 дежурных), оба считают, что могут уйти, оба обновляют свою строку — и нарушают инвариант. SI не detect'ит конфликт, потому что они пишут в РАЗНЫЕ строки.
Что произошло формально:
- 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'ятся, инвариант ломается.
В RR (snapshot isolation) обе транзакции прошли бы успешно, потому что они пишут в разные строки и не нарушают ни одного MVCC-правила Postgres.
Теперь SSI: тот же сценарий, другой результат
Запустим то же самое в SERIALIZABLE. В pglite это не полная демонстрация SSI (одно соединение), но логически: при настоящем SERIALIZABLE одна из двух транзакций получит 40001 при COMMIT.
SERIALIZABLE: каждое чтение оставляет SIREAD-метку. Когда вторая транзакция начинает писать в строку, которая входила в read-set первой — создаётся rw-ребро. На втором ребре в обратную сторону — образуется цикл, и SSI abort'ит одну из транзакций.
В реальном Postgres с двумя параллельными сессиями: первая закоммитится, вторая получит ERROR: could not serialize access due to read/write dependencies among transactions. Hint в ошибке так и говорит: «The transaction might succeed if retried».
Анатомия detection
Шаг за шагом — что именно делает Postgres в этом сценарии:
Каждое чтение → SIREAD на page или tuple. Каждый write → проверка пересечения с активными SIREAD. При обнаружении dangerous structure — abort.
Заметь, что abort произошёл на стороне T_Bob, а не T_Alice. Postgres выбирает «жертву» по эвристикам: предпочитает абортить транзакцию, которая ещё не commit’нулась, и которая делает больше изменений. Логика — в predicate.c, функция OnConflict_CheckForSerializationFailure.
Когда SI хватает, когда нужен SSI
Не всегда нужно поднимать уровень. Чек-лист на разработке:
Если транзакция read-only — SI достаточно. Если 'read + check + write' и решение по чтению влияет на запись — нужно или SSI, или SELECT FOR UPDATE.
Альтернатива 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.
READ ONLY DEFERRABLE — best practice для read-only batch-job’ов в SERIALIZABLE-окружении.
Лечение write skew без SSI
Если поднимать уровень не хочется (например, ORM на это не рассчитан), есть два классических лечения:
SELECT FOR UPDATEна читаемых строках. Берёт row-level exclusive lock, конкурирующие транзакции ждут на нём.- Materialize conflict: записать в одну общую строку (например, инкрементировать счётчик
shifts_on_call) при каждом обновлении. Тогда write skew превращается в ww-конфликт, который SI уже ловит. - Promote to single source of truth: денормализовать инвариант в отдельную таблицу с уникальным ключом, например
current_on_call_count, и UPDATE её — это форсирует ww на одной строке.
В прода-коде SSI обычно даёт самый чистый и наименее инвазивный путь — но цена retry-логики реальна.
Чек-лист
- 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 вовсе.