Как обрабатывать конфликты параллельного доступа (concurrency conflicts) в EF Core?
EF Core реализует optimistic concurrency через атрибут [ConcurrencyCheck] или rowversion/xmin. При конфликте выбрасывается DbUpdateConcurrencyException. Нужно перехватить исключение, обновить значения из БД и повторить попытку или вернуть ошибку пользователю.
Что такое concurrency conflict
Конфликт параллельного доступа возникает, когда два пользователя одновременно читают одну запись, оба вносят изменения и пытаются сохранить их. Без защиты последний пишущий молча перезапишет данные первого. EF Core поддерживает optimistic concurrency — блокировки не используются, вместо этого в момент UPDATE проверяется, не изменилась ли запись с момента её чтения.
Настройка: rowversion и xmin
Для SQL Server используется тип rowversion (автоинкрементный бинарный токен):
public class Order
{
public Guid Id { get; set; }
public OrderStatus Status { get; set; }
public decimal TotalAmount { get; set; }
[Timestamp] // EF Core: IsConcurrencyToken + ValueGeneratedOnAddOrUpdate
public byte[] RowVersion { get; set; } = null!;
}
Для PostgreSQL (Npgsql) используется системный столбец xmin — номер транзакции, изменившей строку:
public class Order
{
public Guid Id { get; set; }
public OrderStatus Status { get; set; }
// Npgsql: автоматически добавляет xmin как concurrency token
public uint Version { get; set; }
}
// В OnModelCreating:
modelBuilder.Entity<Order>()
.UseXminAsConcurrencyToken();
Можно также пометить любое отдельное свойство через [ConcurrencyCheck] или .IsConcurrencyToken() в Fluent API — EF добавит его значение в WHERE-условие UPDATE.
Как EF генерирует SQL с проверкой
-- EF Core добавляет исходное значение rowversion/xmin в WHERE
UPDATE "Orders"
SET "Status" = @p0
WHERE "Id" = @p1 AND "xmin" = @p2;
-- Если строка изменилась другой транзакцией, xmin не совпадёт,
-- UPDATE затронет 0 строк → EF выбросит DbUpdateConcurrencyException
Обработка DbUpdateConcurrencyException
public async Task<Result> ConfirmOrderAsync(Guid orderId, CancellationToken ct)
{
const int maxRetries = 3;
for (int attempt = 0; attempt < maxRetries; attempt++)
{
try
{
var order = await _context.Orders
.FirstOrDefaultAsync(o => o.Id == orderId, ct);
if (order is null)
return Result.NotFound();
order.Status = OrderStatus.Confirmed;
order.ConfirmedAt = DateTime.UtcNow;
await _context.SaveChangesAsync(ct);
return Result.Ok();
}
catch (DbUpdateConcurrencyException ex)
{
if (attempt == maxRetries - 1)
return Result.Conflict("Запись была изменена другим пользователем.");
// Обновить значения из базы и попробовать снова
foreach (var entry in ex.Entries)
{
var dbValues = await entry.GetDatabaseValuesAsync(ct);
if (dbValues is null)
return Result.NotFound(); // Запись удалена
// Перезаписать текущие значения значениями из БД
entry.OriginalValues.SetValues(dbValues);
// entry.CurrentValues сохраняет изменения пользователя
}
}
}
return Result.Conflict();
}
Стратегии разрешения конфликтов
- Store wins: перезаписать изменения пользователя данными из БД (
entry.CurrentValues.SetValues(dbValues)). Безопасно, но пользователь теряет свои правки. - Client wins: сбросить
OriginalValuesдо текущих значений БД и повторить сохранение — изменения пользователя победят. Риск: перезапись чужих изменений. - Merge: для каждого свойства отдельно решить, чьё значение оставить. Наиболее сложная, но правильная стратегия для collaborative editing.
- Fail fast: вернуть HTTP 409 Conflict, показать пользователю актуальные данные и предложить повторить операцию осознанно.
Подводные камни
- Disconnected сценарии: потеря исходного токена. В Web API клиент читает запись, затем возвращает изменённую версию через форму. Если
RowVersion/xminне передаётся обратно с запросом, EF не знает исходного значения и не сможет проверить конфликт. Всегда включайте токен в DTO. - [ConcurrencyCheck] на изменяемых полях. Если пометить часто меняющееся поле (например,
UpdatedAt), конфликты будут возникать чаще, чем нужно. Лучше использовать выделенный rowversion/xmin. - Retry без reload приведёт к повторному исключению. После
DbUpdateConcurrencyExceptionнужно обновитьOriginalValuesчерезGetDatabaseValuesAsync(), иначе следующая попытка снова упадёт с той же ошибкой. - Удалённая запись в конфликте.
GetDatabaseValuesAsync()вернётnull, если строка удалена. Это отдельный случай, который нужно обрабатывать явно. - Нет поддержки pessimistic locking из коробки. EF Core не предоставляет
SELECT FOR UPDATE. Для pessimistic locking нужно использовать сырой SQL черезExecuteSqlRawAsync()или специфичный для провайдера метод (например, NpgsqlFOR UPDATE). - Бесконечный retry. Без ограничения попыток код может зациклиться при постоянно конкурирующих транзакциях. Всегда задавайте maxRetries и экспоненциальную задержку или используйте Polly.
- xmin и репликация. При использовании PostgreSQL streaming replication значение xmin на реплике отличается от мастера. Если читаете с реплики и пишете на мастер — concurrency token будет несовместим.
Common mistakes
- Путать optimistic concurrency и разрешение конфликтов с похожим механизмом из другой версии или платформы.
- Игнорировать runtime-границы Entity Framework: lifecycle, DI scope, SQL translation, UI thread или platform API.
- Не обсуждать null/empty/error cases и поведение под нагрузкой.
What the interviewer is testing
- Кандидат объясняет optimistic concurrency и разрешение конфликтов на конкретном примере, а не только определением.
- Указывает последствия для производительности, тестируемости и поддержки.
- Различает документированное поведение текущего стека и устаревшие практики.