Сохраняет ли snapshot isolation инвариант, зависящий от нескольких строк, при параллельных изменениях разных строк?
Нет. Snapshot isolation обычно предотвращает конфликтующие записи в одну и ту же строку, но не гарантирует сохранение инварианта, который зависит от набора строк. Поэтому возможен write skew: пара транзакций читает согласованный снимок, изменяет разные строки и успешно фиксируется, хотя их совместный результат нарушает правило.
Snapshot isolation появился как практический способ уменьшить блокировки при конкурентных операциях. Благодаря MVCC читатели работают со снимком данных и обычно не блокируют записи, а писатели не блокируют чтения на всё время транзакции.
Такой подход хорошо решает конфликты чтения и записи, но его цель — не полная сериализация всех транзакций. Поэтому он допускает некоторые аномалии, включая нарушение межстрочных ограничений.
Предположим, в системе всегда должен оставаться хотя бы один дежурный врач. Две транзакции одновременно проверяют, что дежурят два врача, после чего каждая снимает с дежурства одного из них.
При snapshot isolation обе транзакции видят один и тот же исходный снимок. Одна изменяет строку первого врача, другая — строку второго. Поскольку они не записывают одну и ту же строку, система может не обнаружить конфликт, и после фиксации дежурных не останется.
Неверно считать, что согласованный снимок автоматически гарантирует сохранение всех бизнес-инвариантов. Согласованность отдельного снимка и сериализуемость последовательности транзакций — разные свойства.
Snapshot isolation предоставляет каждой транзакции версию данных, согласованную на момент начала чтения. При фиксации система обычно проверяет, не изменились ли строки, которые транзакция сама записывает, после формирования её снимка.
Если две транзакции изменяют одну строку, возникает конфликт записи, и одна из них обычно отклоняется. Но если транзакции читают общий набор строк, а записывают разные строки, возникает write skew: причинная зависимость между прочитанными данными не превращается в конфликт записи.
Для защиты межстрочного инварианта нужны дополнительные меры:
Важно, что название уровня изоляции не всегда полностью описывает поведение конкретной СУБД. Нужно проверять документацию: реализация уровня repeatable read может использовать snapshot isolation, а режим serializable может применяться блокировками, обнаружением конфликтов или их комбинацией.
В сервисе управления дежурствами правило гласило: минимум один врач должен оставаться доступным. Две транзакции почти одновременно читали список из двух доступных врачей; каждая отключала своего врача. При snapshot isolation обе операции завершались успешно, и инвариант нарушался.
Рассматривались три варианта. Полный serializable надёжно защищал правило, но увеличивал вероятность откатов при высокой конкуренции. Явная блокировка всех строк врачей сохраняла корректность, однако расширяла область блокирования и усложняла работу с изменяющимся набором. Отдельная строка с числом доступных врачей делала конфликт явным, но требовала, чтобы каждое изменение статуса обязательно обновляло этот счётчик.
Был выбран serializable для операций изменения дежурства, поскольку корректность правила важнее максимальной пропускной способности, а сами транзакции короткие. Клиентский слой добавил повтор ограниченного числа отклонённых транзакций с задержкой; операции чтения остались на более дешёвом уровне изоляции.
Чем snapshot isolation отличается от serializable?
Snapshot isolation даёт транзакции согласованный снимок и обычно предотвращает конфликтующие записи в одну строку. Serializable дополнительно требует, чтобы итог можно было объяснить последовательным выполнением транзакций, поэтому должна предотвращаться и опасная зависимость через разные строки. Snapshot isolation может быть быстрее, но не защищает от всех аномалий сериализуемости.
Какие конфликты snapshot isolation обычно обнаруживает?
Типичный механизм обнаруживает пересечение записей: если две конкурентные транзакции пытаются изменить одну и ту же строку, одна из них не фиксируется. Сам факт, что обе транзакции читали одну строку или один предикат, ещё не обязательно создаёт конфликт. Поэтому чтение общего условия с последующей записью разных строк может завершиться write skew.
Как защитить инвариант без включения serializable для всей системы?
Можно локализовать конфликт: выделить одну защищаемую строку, атомарный счётчик или другой ресурс, который каждая операция обязана изменять. Например, изменение статуса врача сначала уменьшает общий счётчик доступных врачей с проверкой, что результат не станет отрицательным. Это снижает стоимость защиты, но корректность зависит от того, что все пути изменения данных используют тот же механизм; прямое обновление исходных строк в обход счётчика снова создаст дыру.