Как обеспечить корректный конкурентный доступ при инкременте счетчиков в базе данных
Что проверяют
Видите ли вы классическую гонку «прочитал, увеличил, записал» (lost update) и знаете ли, что lock в C# от неё не спасает, когда экземпляров сервиса несколько. Следом обычно спрашивают, что делать, если счётчик горячий и в него пишут сотни раз в секунду.
Как отвечать
Сначала покажите проблему: два запроса читают значение 10, оба прибавляют единицу и записывают 11. Транзакция сама по себе это не лечит: на уровне изоляции по умолчанию (Read Committed) оба чтения пройдут без блокировки.
Решения от простого к сложному:
- атомарный UPDATE:
UPDATE counters SET value = value + 1 WHERE id = @id. Одна команда берёт блокировку строки, и приращения не теряются. В EF Core 7+ этоExecuteUpdateAsync(s => s.SetProperty(c => c.Value, c => c.Value + 1)), без загрузки сущности. Новое значение можно сразу вернуть черезRETURNINGв PostgreSQL илиOUTPUT inserted.valueв SQL Server; - пессимистичная блокировка:
SELECT ... FOR UPDATEв PostgreSQL или хинтWITH (UPDLOCK, ROWLOCK)в SQL Server внутри транзакции, если между чтением и записью есть логика; - оптимистичная блокировка: колонка версии,
[Timestamp]на свойствеbyte[] RowVersionдля SQL Server или[ConcurrencyCheck], в PostgreSQL обычно используют системную колонкуxmin. EF Core добавляет версию вWHERE, при конфликте бросаетDbUpdateConcurrencyException, и вы повторяете операцию; - горячий счётчик: разбить на N строк-шардов и суммировать при чтении, либо копить
INCRв Redis и периодически сбрасывать в базу.
Уровень Serializable тоже убирает гонку, но ценой ошибок сериализации или дедлоков, которые всё равно придётся повторять.
Где спотыкаются
Оборачивают Find, Value++ и SaveChanges в TransactionScope и считают задачу решённой. Без блокировки строки это та же гонка.
Предлагают lock или Interlocked.Increment. Они работают в пределах одного процесса, а второй экземпляр за балансировщиком о них не знает.
Ловят DbUpdateConcurrencyException, но не повторяют операцию с перечитанным значением, и приращение молча теряется.