При Snapshot Isolation две транзакции читают одну версию строки, затем обе пытаются изменить её. Почему одн...

При Snapshot Isolation две транзакции читают одну версию строки, затем обе пытаются изменить её. Почему одна из них должна быть отклонена, а не объединена с изменением другой?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

При Snapshot Isolation изменения одной и той же строки двумя конкурентными транзакциями образуют конфликт записи. СУБД обычно разрешает зафиксировать только одну из таких транзакций, а другую блокирует до завершения первой и затем прерывает или отклоняет из-за конфликта версий. Автоматически объединять изменения нельзя: СУБД не знает, как совместить бизнес-смысл двух новых значений.

Исторический контекст

MVCC и снимки данных появились как способ уменьшить блокирование чтений: транзакция может читать согласованную версию данных, пока другая транзакция формирует новую. Это повышает конкурентность по сравнению с подходом, где чтение почти всегда блокирует запись.

Однако независимые снимки сами по себе не решают конфликтов записи. Если две транзакции изменяют одну логическую строку, системе нужно выбрать, какая версия станет следующей, иначе результат будет зависеть от порядка фиксации или одна запись может затереть другую.

Постановка проблемы

Пусть в строке хранится количество товара, равное 10. Две транзакции читают значение 10: одна хочет установить 8, другая — 7. Обе операции могут быть корректны по отдельности, но итоговое значение 8 или 7 не отражает одновременное применение двух независимых намерений.

Если без проверки версий разрешить последнюю запись, получится потеря обновления: результат первой транзакции исчезнет без явной ошибки. Если же попытаться сложить значения автоматически, это тоже может быть неверно: для цены, статуса заказа или лимита такое объединение не имеет однозначного смысла.

Подробное решение

У каждой версии строки есть сведения о транзакции, которая её создала или изменила. Транзакция читает версию, видимую в её снимке, а при записи СУБД проверяет, не была ли эта строка изменена конкурентной транзакцией после формирования снимка.

Если другая транзакция уже зафиксировала новую версию, исходная версия устарела. СУБД не может безопасно записать поверх неё результат старого чтения, поэтому одна транзакция продолжает работу, а другая получает конфликт обновления, ошибку сериализации или аналогичное прерывание. В некоторых реализациях проверка выполняется после ожидания блокировки, в других конфликт обнаруживается при самой попытке изменения.

Точный момент обнаружения и текст ошибки зависят от СУБД и выбранного уровня изоляции. Поэтому приложение должно обрабатывать такие ошибки явно: обычно повторяют всю транзакцию с новым снимком, а не только неудавшийся оператор.

Это поведение не означает, что Snapshot Isolation обеспечивает полную сериализуемость. Конфликты записи одной строки обычно обнаруживаются, но независимые чтения и записи могут привести, например, к аномалии write skew, когда каждая транзакция изменяет свою строку на основании общего условия. Для защиты таких инвариантов нужен сериализуемый уровень или явная координация блокировками.

Автоматическое слияние возможно только на уровне прикладной логики. Например, операцию «увеличить счётчик на 1» можно безопасно выразить как атомарное относительное изменение, тогда как операцию «установить значение 7» нельзя корректно объединить с конкурирующей операцией без правила разрешения конфликта.

Ситуация из практики

В сервисе бронирования две транзакции видят один свободный ресурс. Обе пытаются изменить его статус на «забронирован». Вариант с последней записью опасен: один клиент может получить успешный ответ, хотя его бронь была затёрта другой транзакцией. Вариант с предварительным чтением без проверки версии также не защищает от гонки.

Можно использовать явную блокировку строки: это упрощает рассуждение, но увеличивает ожидание и снижает конкурентность. Можно выбрать сериализуемый уровень: он надёжнее для сложных инвариантов, но может приводить к большему числу откатов при высокой нагрузке.

Практичный вариант — изменить строку с проверкой текущей версии или использовать сериализуемую транзакцию с повтором всей операции. При конфликте один запрос получает понятную ошибку, повторяет чтение актуального состояния и решает, доступен ли ресурс. Это лучше молчаливого перезаписывания, потому что конфликт становится явным и контролируемым.

Что кандидаты часто упускают

1. Обязательно ли Snapshot Isolation блокирует вторую транзакцию сразу?

Нет. Реализация может сначала позволить обеим транзакциям читать снимки, а конфликт обнаружить при записи или фиксации. Часто одна транзакция некоторое время ожидает освобождения блокировки, после чего СУБД решает, можно ли принять её версию. Поэтому отсутствие немедленной блокировки не означает отсутствие защиты от конфликта.

2. Можно ли безопасно повторить только оператор обновления после конфликта?

Обычно нет. Результат обновления мог зависеть от нескольких прочитанных строк, проверок и внешних условий внутри исходной транзакции. После конфликта нужно начать новую транзакцию, получить новый снимок и заново выполнить все действия; иначе часть решений будет основана на устаревшем состоянии.

3. Чем конфликт записи отличается от write skew?

При конфликте записи обе транзакции изменяют одну и ту же строку, поэтому СУБД может обнаружить столкновение версий. При write skew транзакции изменяют разные строки, но принимают решение на основании общего ограничения, например требования оставить хотя бы одного дежурного врача. Проверка версий отдельных строк не выявляет такой конфликт, поэтому для него нужен сериализуемый контроль или явные блокировки общего условия.