В системе должны постоянно дежурить два врача: при Snapshot Isolation каждый из них проверяет, что второй ещё дежурит, и снимает себя с дежурства. Почему обе транзакции могут успешно завершиться, нарушив это правило?
Это аномалия write skew, или перекос записи. При Snapshot Isolation каждая транзакция читает согласованный снимок, в котором второй врач ещё дежурит, поэтому обе проверки проходят. Поскольку транзакции изменяют разные строки, прямого конфликта записи нет, и обе могут зафиксироваться, хотя совместный результат нарушает бизнес-инвариант.
MVCC и изоляция на основе снимков появились как способ уменьшить блокирование чтений и дать транзакциям согласованное представление данных при высокой конкуренции. Читатель обычно не обязан ждать завершения записи, а писатель не блокирует уже начавшееся чтение так, как это происходит при исключительно блокирующей модели.
Цена такой масштабируемости состоит в том, что согласованность часто проверяется относительно снимка, а не относительно всех эффектов параллельного выполнения. Поэтому защита от конфликта отдельных строк ещё не гарантирует сохранение правила, связывающего несколько строк.
Пусть существуют строки двух дежурных врачей, и инвариант системы требует, чтобы хотя бы один из них оставался на дежурстве. Транзакция врача A видит врача B дежурящим и снимает A; одновременно транзакция врача B видит врача A дежурящим и снимает B.
Каждая транзакция по отдельности выглядит корректной: она не снимает последнего дежурного, согласно своему снимку. После фиксации обеих транзакций дежурных не остаётся. Если такие правила защищают доступность сервиса, безопасность или финансовый лимит, последствие может быть значимее обычной ошибки чтения.
При Snapshot Isolation транзакция читает версии строк, существовавшие в момент формирования её снимка. Проверка врача A видит старую версию строки врача B, а проверка врача B — старую версию строки врача A. Эти наблюдения совместимы с каждым отдельным снимком, но не с единым последовательным порядком выполнения.
Обычно Snapshot Isolation предотвращает конфликт, когда две транзакции записывают одну и ту же строку: одна из них должна завершиться ошибкой конфликта или не сможет зафиксироваться. В данном случае записи направлены в разные строки, поэтому такого конфликта нет. Проблема возникает из-за зависимости между прочитанными строками и изменённой строкой — это и есть write skew.
Надёжные варианты защиты:
Пример с блокировкой координирующей строки:
Здесь все операции, изменяющие данный инвариант, должны использовать ту же строку system_state и тот же протокол. Само наличие блокировки в одном месте не защищает от другого кода, который изменяет врачей в обход этого протокола. Кроме того, блокировка сериализует операции и может увеличить время ожидания.
В сервисе расписаний два администратора одновременно снимают двух последних дежурных операторов. Вариант с обычным READ COMMITTED и последовательностью «посчитать дежурных — изменить свою строку» не устраняет гонку: между проверкой и обновлением состояние может измениться. Вариант с Snapshot Isolation также не гарантирует сохранение инварианта, потому что изменения затрагивают разные строки.
Команда рассмотрела три решения. Повышение до SERIALIZABLE лучше всего отражает требование, но требует обработки ошибок сериализации и повторов транзакций. Блокировка всех строк дежурных усложняет порядок блокирования и может создавать лишнее ожидание при большом расписании. Координирующая строка состояния с FOR UPDATE дала простой единый протокол и предсказуемое поведение, поэтому её выбрали; операции снятия дежурства стали выполняться последовательно, а нарушение инварианта исчезло.
1. Достаточно ли повысить изоляцию с READ COMMITTED до Snapshot Isolation?
Нет. Snapshot Isolation устраняет ряд проблем, включая многие неповторяющиеся чтения и часть конфликтов обновления, но не гарантирует сериализуемость. Если параллельные транзакции читают общий инвариант и пишут разные строки, write skew всё ещё возможен.
2. Почему блокировка изменяемой строки не всегда решает проблему?
Потому что транзакции могут изменять разные строки. Блокировка строки врача A не мешает другой транзакции изменить строку врача B, даже если обе решения принимаются на основании общего количества дежурных. Нужно блокировать общий объект, диапазон или все строки, от которых зависит проверяемый инвариант, либо использовать режим, гарантирующий сериализуемый результат.
3. Если СУБД поддерживает SERIALIZABLE, можно ли не обрабатывать ошибки транзакций?
Нет. Сериализуемый режим может обнаружить конфликт и прервать одну из транзакций вместо того, чтобы позволить некорректному результату появиться. Приложение должно распознать ошибку сериализации или взаимной блокировки, откатить транзакцию и повторить операцию с ограничением числа попыток; при этом побочные действия вне транзакции нельзя бездумно повторять.