Защита от потерянных изменений с rowversion в SQL Server
Используйте rowversion для проверки версии при сохранении, обнаруживайте конфликты и сохраняйте работу пользователя без скрытой перезаписи чужих изменений.
Два редактора открывают одну карточку товара. Первый исправляет подпись цены и сохраняет данные. Второй меняет пунктуацию в старой копии и отправляет всю форму. Оба запроса завершаются успешно, но второй удаляет исправление первого. Транзакция вокруг каждого отдельного UPDATE не устраняет эту потерю: чтение устаревшей копии произошло еще до начала транзакции.
Проверять версию непосредственно при записи
Запрос на изменение должен сообщать, какую версию видел пользователь. Столбец rowversion предоставляет двоичный токен длиной восемь байт, который создает база данных. Возвращайте токен вместе с редактируемыми полями и требуйте его при сохранении. Первичный ключ и ожидаемый токен должны входить в условие одного UPDATE.
Недостаточно сначала прочитать токен, сравнить его в приложении, а затем выполнить безусловное обновление. Между этими действиями другая сессия может изменить строку. Следующий пример моделирует двух редакторов в одном соединении. Они запоминают исходный токен, но после первой записи условие второго обновления перестает выполняться.
CREATE TABLE #Draft
(
DraftId int NOT NULL PRIMARY KEY,
Title nvarchar(100) NOT NULL,
Revision rowversion NOT NULL
);
INSERT #Draft (DraftId, Title) VALUES (1, N'Initial title');
DECLARE @SeenByA binary(8), @SeenByB binary(8);
SELECT @SeenByA = Revision, @SeenByB = Revision
FROM #Draft WHERE DraftId = 1;
UPDATE #Draft SET Title = N'Editor A'
OUTPUT inserted.DraftId, inserted.Title, inserted.Revision
WHERE DraftId = 1 AND Revision = @SeenByA;
UPDATE #Draft SET Title = N'Editor B'
WHERE DraftId = 1 AND Revision = @SeenByB;
DECLARE @Changed int = @@ROWCOUNT;
SELECT @Changed AS RowsChanged;
SELECT DraftId, Title, Revision FROM #Draft;
DROP TABLE #Draft;
Второй UPDATE изменяет ноль строк, а заголовок остается Editor A. Первый OUTPUT возвращает новый токен для успешного ответа. При использовании @@ROWCOUNT сохраняйте значение сразу: последующие инструкции могут его заменить. Первичный ключ гарантирует, что запрос затронет не больше одной строки.
Рассматривайте токен как непрозрачные двоичные данные. В JSON можно использовать Base64 или шестнадцатеричную строку фиксированного формата. Декодируйте ровно восемь байт и передавайте двоичный параметр. Не преобразуйте значение в число JavaScript. Токен не является датой, и клиент не должен вычислять следующее значение самостоятельно. Для отображаемого времени изменения нужен отдельный столбец datetime2.
Сделать конфликт понятным для пользователя
Ноль измененных строк означает, что указанная комбинация ключа и версии недоступна для записи. Причиной может быть чужое изменение, удаление записи или отсутствие доступа к этой строке. Сам результат UPDATE не различает эти случаи. Проверяйте права в условии записи либо в столь же надежной транзакционной проверке. Дополнительный диагностический запрос не должен раскрывать закрытые записи.
Для пользователя с нужными правами сохраните отправленный текст и загрузите актуальную карточку. Если изменения можно объединить, покажите исходные значения, его предложение и текущие данные. Простая перезагрузка страницы с потерей несохраненного текста защищает базу, но создает новую проблему для человека.
Не повторяйте автоматически устаревшее сохранение всей формы с последним токеном. Такой повтор просто возвращает скрытую перезапись. Некоторые команды имеют более точный смысл: счетчик можно атомарно увеличить, не записывая заново ранее прочитанную сумму. Выбирайте контракт по бизнес-намерению, а не представляйте любую операцию как замену документа целиком.
Защитить всю бизнес-операцию
Токен относится к одной строке. Если форма меняет заголовок заказа и несколько позиций, проверка только заголовка не обнаружит независимую правку позиции. Нужна общая версия, которая изменяется при каждой существенной правке, либо сравнение токенов всех изменяемых позиций внутри одной транзакции. Если операция должна выполняться полностью, любой конфликт обязан отменять все связанные изменения.
Отдельно проверьте работу триггеров. OUTPUT возвращает значения до выполнения AFTER-триггеров. Если триггер еще раз обновляет ту же строку, токен снова изменится. Тогда окончательный токен следует прочитать внутри транзакции после выполнения триггера. Сообщайте об успехе только после commit: строка из OUTPUT сама по себе не доказывает фиксацию записи.
В тестах воспроизведите одновременное сохранение, удаление перед сохранением и повтор после потери сетевого ответа. Разделяйте в метриках конфликты редактирования и ошибки сервера. Большое число конфликтов может указывать на форму, заменяющую слишком много полей, или фоновый процесс, который обновляет строки без необходимости.
Также заранее определите поведение кнопки повторного сохранения после разрешения конфликта. Она должна отправлять осознанно выбранные значения и токен именно той актуальной версии, с которой пользователь их сравнил. Если за время сравнения данные снова изменились, корректен новый конфликт. Это нормальная часть протокола, а не основание отключать проверку ради удобного ответа.
Техническая документация: Microsoft Learn: rowversion · Microsoft Learn: OUTPUT clause.