Destino Dev

Blog

Tratar concorrência otimista e retries com EF Core e Polly em ASP.NET Core

Por John Pires · 09 de março de 2026

Tratar concorrência otimista e retries com EF Core e Polly

Cenário típico

Aplicações web com múltiplos servidores e muitos usuários enfrentam dois problemas comuns ao atualizar dados: conflitos de concorrência otimista quando dois clientes atualizam a mesma linha quase ao mesmo tempo, e falhas transitórias (timeouts, conexões temporariamente perdidas) que exigem retries. Sem uma estratégia consistente você perde dados, gera exceções inesperadas para o usuário ou cria loops de retry infinitos.

Concorrência otimista geralmente é implementada com um token de versão (rowversion/timestamp ou um campo de controle). O Entity Framework Core lança DbUpdateConcurrencyException quando a atualização atinge 0 linhas afetadas, sinalizando que os valores no banco mudaram desde a leitura.

Impacto em aplicações web

Sem tratamento adequado, a experiência do usuário sofre: formulários retornam erros genéricos, operações críticas falham silenciosamente e filas de retry geram carga extra sobre o banco. Em APIs, a abordagem errada retorna 500 em vez de 409/412, dificultando o tratamento no cliente.

Além disso, retrying sem entender a semântica da operação pode transformar uma operação idempotente em não-idempotente (por exemplo, incrementar um contador duas vezes). Precisamos combinar detecção de conflitos com políticas de retry que sejam conscientes do domínio.

2) Abordagem ingênua vs abordagem otimizada

Abordagem ingênua

Desenvolvedores frequentemente implementam retries genéricos: capturam qualquer exceção, aguardam e tentam novamente. Outro erro comum é envolver o DbContext em retry externo sem considerar rastreamento de entidades, o que leva a estados inconsistentes dentro do contexto.

Exemplo de problema: reusar o mesmo DbContext após um retry falho. O DbContext mantém estados das entidades; uma retry que recria a operação sem recriar o contexto pode aplicar dados em entidades já marcadas como Modified, causando comportamentos inesperados.

Abordagem otimizada

A solução robusta combina três elementos: (1) usar um token de versão e lidar com DbUpdateConcurrencyException, (2) aplicar um retry inteligente que execute uma nova leitura e tente mesclar/merge ou abortar, e (3) usar políticas de retry do Polly para tratar falhas transitórias com backoff e jitter.

Princípios — tornar a operação idempotente ou garantir que cada retry recomece em um estado limpo (novo DbContext), evitar retries para falhas de negócio e expor conflitos de concorrência ao cliente quando necessário.

3) Implementação com código real

Entidade e DbContext

Aqui definimos uma entidade com um token de versão (byte[]), configuramos como concurrency token e configuramos o DbContext para usar SQL Server ou SQLite. O token permite ao EF detectar conflitos de concorrência ao atualizar.

Contexto: este bloco cria a entidade Product com um campo RowVersion que é atualizado pelo banco a cada modificação.

using Microsoft.EntityFrameworkCore;

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public byte[] RowVersion { get; set; }
}

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions options) : base(options) { }
    public DbSet Products { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity()
            .Property(p => p.RowVersion)
            .IsRowVersion();
    }
}

O que acontece depois: ao aplicar SaveChanges, o EF inclui o valor de RowVersion no WHERE da cláusula UPDATE; se ninguém mudou a linha, o banco atualiza e retorna o novo RowVersion. Se outra transação já mudou, a atualização afeta 0 linhas e o EF lança DbUpdateConcurrencyException.

Serviço de atualização com Polly

A implementação abaixo mostra um serviço que aplica retries via Polly. Cada tentativa recria o DbContext, lê a entidade atual do banco, tenta aplicar a mudança e trata safes de conflito usando GetDatabaseValues para mesclar ou falhar em 409.

Contexto: mostramos uma estratégia de retry específica para conflitos e outra para falhas transitórias, combinadas com backoff exponencial e jitter.

using Polly;
using Polly.Retry;
using Microsoft.EntityFrameworkCore;

public class ProductService
{
    private readonly IDbContextFactory _contextFactory;
    private readonly AsyncRetryPolicy _transientPolicy;
    private readonly AsyncRetryPolicy _concurrencyPolicy;

    public ProductService(IDbContextFactory contextFactory)
    {
        _contextFactory = contextFactory;

        _transientPolicy = Policy
            .Handle() // trocar por SqlException para SQL Server
            .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromMilliseconds(100 * Math.Pow(2, retryAttempt)),
                (ex, span) => { /* log */ });

        _concurrencyPolicy = Policy
            .Handle()
            .WaitAndRetryAsync(3, _ => TimeSpan.FromMilliseconds(200));
    }

    public async Task UpdatePriceAsync(int id, decimal newPrice)
    {
        // Política combinada: primeiro tratar transitórios, depois concorrência
        return await _transientPolicy.ExecuteAsync(async () =>
        {
            return await _concurrencyPolicy.ExecuteAsync(async () =>
            {
                using var db = _contextFactory.CreateDbContext();
                var entity = await db.Products.FindAsync(id);
                if (entity == null) throw new KeyNotFoundException();

                entity.Price = newPrice;

                try
                {
                    await db.SaveChangesAsync();
                    return entity;
                }
                catch (DbUpdateConcurrencyException ex)
                {
                    // Propaga para a policy de concorrência que fará retry
                    throw;
                }
            });
        });
    }
}

O que acontece depois: a política de concorrência relança a operação inteira usando um novo DbContext a cada tentativa, garantindo estado limpo. Em cada tentativa a leitura é refeita, reduzindo chances de conflito persistente.

Minimal API / Controller: expor conflito corretamente

Ao expor via API, traduza DbUpdateConcurrencyException para 409 (ou 412) com payload que permite ao cliente decidir: fazer retry, refazer a edição ou apresentar conflito ao usuário.

app.MapPut('/products/{id}', async (int id, ProductUpdateDto dto, ProductService svc) =>
{
    try
    {
        var updated = await svc.UpdatePriceAsync(id, dto.Price);
        return Results.Ok(updated);
    }
    catch (DbUpdateConcurrencyException)
    {
        return Results.Conflict(new { message = 'Conflict detected. Refresh and retry.' });
    }
    catch (KeyNotFoundException)
    {
        return Results.NotFound();
    }
});

O que acontece depois: cliente recebe 409 e decide o fluxo. Em UIs, é comum apresentar os valores atuais e permitir mescla manual.

4) Testes e validação

Testes de integração com SQLite in-memory

Para validar concorrência em testes, use SQLite em memória com conexão compartilhada. Isso reproduz a lógica de UPDATE afetando 0 linhas. O InMemory provider do EF Core não simula concorrência adequadamente.

Exemplo funcional de teste usando xUnit que cria dois contextos simultâneos, modifica e tenta salvar para provocar DbUpdateConcurrencyException:

using Xunit;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

public class ConcurrencyTests
{
    [Fact]
    public async Task UpdateConflictThrowsConcurrencyException()
    {
        var connection = new SqliteConnection('DataSource=:memory:');
        connection.Open();

        var options = new DbContextOptionsBuilder()
            .UseSqlite(connection)
            .Options;

        // cria schema e insere
        using (var init = new AppDbContext(options))
        {
            await init.Database.EnsureCreatedAsync();
            init.Products.Add(new Product { Name = 'P', Price = 10 });
            await init.SaveChangesAsync();
        }

        // abre dois contextos simulando concorrentes
        using var db1 = new AppDbContext(options);
        using var db2 = new AppDbContext(options);

        var p1 = await db1.Products.FirstAsync();
        var p2 = await db2.Products.FirstAsync();

        p1.Price = 11;
        await db1.SaveChangesAsync(); // sucesso

        p2.Price = 12;
        await Assert.ThrowsAsync(async () => await db2.SaveChangesAsync());
    }
}

O que acontece depois: o teste confirma que SaveChangesAsync lança DbUpdateConcurrencyException no segundo contexto, replicando o cenário de conflito.

Cenários a testar

Liste de testes recomendados:

  1. Conflict detection: dois contextos atualizam a mesma linha.

  2. Retry policy para falhas transitórias (simular transient exception e checar número de tentativas).

  3. Integração: a policy de concorrência reexecuta a operação e, quando persistente, retorna 409 ao usuário.

Adicione testes para casos de mescla automática se sua lógica fizer merge programaticamente.

5) Considerações de produção

Erros comuns, armadilhas e dicas práticas

Erro 1: Reusar DbContext entre tentativas. Sempre recrie o contexto para garantir estado limpo.

Erro 2: Tratar DbUpdateConcurrencyException como transient genérico. Conflito é semântico: precisa de merge ou exposição ao cliente.

Regra prática: retries automáticos são OK para problemas de infra (timeouts, deadlocks), não para conflitos de domínio sem merge claro.

Dica: mantenha a operação o menor possível. Ler apenas os campos necessários reduz janela de conflito.

Monitoramento, logs e métricas

Instrumente as políticas do Polly e os catch de DbUpdateConcurrencyException. Métricas úteis:

  • taxa de conflitos por minuto

  • número médio de retries por operação

  • latência média da operação após retries

Logue contexto suficiente (id, user, tentativa) mas não exponha dados sensíveis. Configure alertas para quando a taxa de conflito subir acima de um limiar.

Onde automatizar vs onde pedir intervenção humana

Automatize retry quando for seguro (ex.: atualizações idempotentes como set de status). Para edits complexos (mescla de campos textuais) prefira devolver 409 e permitir merges manuais no cliente.

Considere oferecer um endpoint de "merge" que aceita tanto valores do cliente quanto a versão atual do banco, aplicando regras claras de resolução.

Conclusão prática

Para tratar concorrência otimista com EF Core e Polly, combine um token de versão, retries conscientes e políticas de tratamento de falhas transitórias. Recrie o DbContext a cada tentativa, diferencie falhas transitórias de conflitos de domínio e exponha conflitos ao cliente quando necessário.

Implementação típica: RowVersion + GetDatabaseValues para merge, Polly com backoff e jitter para falhas transitórias, e 409 para conflitos não resolvíveis automaticamente. Isso reduz perda de dados, melhora UX e mantém o banco com carga previsível.

Checklist rápido:

  • Use rowversion ou campo de controle para detectar conflitos

  • Recrie DbContext a cada retry

  • Use Polly para transient errors, e uma política específica para DbUpdateConcurrencyException

  • Exponha 409/412 para o cliente com payload útil

  • Monitore taxa de conflitos e retries

Aplicando essas práticas você terá um fluxo previsível e resiliente para updates concorrentes em aplicações ASP.NET Core com EF Core.

Recursos rápidos: documentações de EF Core sobre concurrency e do Polly sobre políticas de retry. Teste sempre com SQLite in-memory para validar conflitos em integração.

Assuntos: C#, ef-core, polly, aspnet-core, concurrency

Leia também