Entity FrameworkSeniorSystem design

Как обрабатывать конфликты параллельного доступа (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() или специфичный для провайдера метод (например, Npgsql FOR 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 и разрешение конфликтов на конкретном примере, а не только определением.
  • Указывает последствия для производительности, тестируемости и поддержки.
  • Различает документированное поведение текущего стека и устаревшие практики.

Sources

Related topics