Programação Por Equipe DevBras

Capa do artigo: Gravar em dois bancos com EF Core sem meia verdade: outbox no lugar da transação

Gravar em dois bancos com EF Core sem meia verdade: outbox no lugar da transação

Entenda por que dois SaveChanges em bancos diferentes não são atômicos, quando a transação cobre os dois de verdade e como usar uma tabela outbox com reprocessamento idempotente.

O pedido 9031 foi gravado no banco da loja, o cliente recebeu o e-mail de confirmação, e o estoque nunca soube. No meio do request, o banco de estoque, em outro servidor, demorou, o comando estourou o timeout e a tela mostrou "erro ao finalizar". O cliente tentou de novo e gerou o pedido 9032. A resposta curta: dois SaveChanges em bancos diferentes são duas transações, não uma, mesmo que o código pareça uma operação só. Se o resultado precisa ser "os dois ou nenhum", grave no primeiro banco, na mesma transação, o pedido e uma mensagem numa tabela outbox; um processo em segundo plano aplica a mensagem no segundo banco, com reprocessamento seguro, até dar certo.

O resultado é consistência eventual com estado visível, em vez de atomicidade fingida. O estoque fica certo alguns segundos depois, e você sempre sabe o que falta aplicar.

Por que o código engana

O código original era assim, num serviço com dois contextos injetados:

loja.Pedidos.Add(pedido);
await loja.SaveChangesAsync();        // banco da loja: commit

foreach (var item in pedido.Itens)
    estoque.Reservas.Add(new Reserva(item.ProdutoId, item.Quantidade, pedido.Id));
await estoque.SaveChangesAsync();     // banco de estoque: pode falhar

Cada SaveChangesAsync abre e confirma a sua própria transação no seu próprio banco. Se o segundo falhar, o primeiro já está confirmado e não volta. Inverter a ordem só troca qual lado fica órfão. Colocar tudo num try/catch que tenta apagar o pedido em caso de erro parece compensação, mas falha justamente quando você mais precisa: se o processo cair entre os dois passos, ninguém executa o catch.

A situação fica mais enganosa quando os dois bancos aparecem dentro do mesmo DbContext, como no artigo de origem, mapeando tabelas de outra base. Aí vale distinguir dois casos.

Quando a transação cobre os dois de verdade

Se as duas bases estão na mesma instância de SQL Server e o contexto chega à segunda pela mesma conexão, com nome em três partes ou sinônimo, o comando roda numa única conexão e a transação local do SQL Server cobre as duas bases. Esse é o caso em que um contexto "com dois bancos" é de fato atômico. Há ressalvas em algumas configurações de alta disponibilidade, então confira a documentação da sua versão antes de depender disso.

Se são servidores diferentes, ou conexões diferentes, não existe transação local que cubra os dois. A promoção para transação distribuída com TransactionScope depende de coordenador externo e de suporte da plataforma; no .NET moderno ela é restrita e não está disponível em todo ambiente, como contêineres Linux. Não desenhe o seu sistema contando com ela.

O padrão outbox, passo a passo

  1. Crie uma tabela Outbox no banco da loja. Cada linha é uma tarefa a aplicar em outro sistema: id, tipo, conteúdo em JSON, data de criação, data de processamento e número de tentativas.
  2. Grave o pedido e a mensagem no mesmo SaveChanges. Mesmo banco, mesma transação: ou os dois existem, ou nenhum.
  3. Responda ao cliente com o pedido criado e o estoque como "reservando".
  4. Um worker lê mensagens pendentes em ordem e aplica cada uma no banco de estoque.
  5. A aplicação no estoque é idempotente. O id da mensagem fica registrado no banco de estoque na mesma transação da reserva; repetir a mensagem não reserva duas vezes.
  6. Marque a mensagem como processada no banco da loja só depois do commit no estoque.
  7. Alerte quando uma mensagem passa de N tentativas ou fica pendente por mais tempo que o aceitável.

Gravando o pedido e a mensagem juntos

public class MensagemOutbox
{
    public Guid Id { get; set; }
    public string Tipo { get; set; } = "";
    public string Conteudo { get; set; } = "";
    public DateTime CriadaEm { get; set; }
    public DateTime? ProcessadaEm { get; set; }
    public int Tentativas { get; set; }
}

loja.Pedidos.Add(pedido);
loja.Outbox.Add(new MensagemOutbox
{
    Id = Guid.NewGuid(),
    Tipo = "ReservarEstoque",
    Conteudo = JsonSerializer.Serialize(new
    {
        PedidoId = pedido.Id,
        Itens = pedido.Itens.Select(i => new { i.ProdutoId, i.Quantidade })
    }),
    CriadaEm = DateTime.UtcNow
});
await loja.SaveChangesAsync();

Um único SaveChangesAsync já roda numa transação. Se o banco da loja recusar, nem o pedido nem a mensagem existem. Se aceitar, os dois existem, e o estoque será avisado mesmo que o processo caia no segundo seguinte. Nesse exemplo, o id do pedido é gerado pela aplicação antes de salvar; se o seu vem de coluna identidade, serialize a mensagem com uma referência que já exista antes do commit, como um código do pedido.

O worker que aplica no estoque

public class OutboxWorker(IServiceScopeFactory scopes, ILogger<OutboxWorker> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            using var scope = scopes.CreateScope();
            var loja = scope.ServiceProvider.GetRequiredService<LojaDbContext>();
            var estoque = scope.ServiceProvider.GetRequiredService<EstoqueDbContext>();

            var pendentes = await loja.Outbox
                .Where(m => m.ProcessadaEm == null && m.Tentativas < 10)
                .OrderBy(m => m.CriadaEm)
                .Take(20)
                .ToListAsync(ct);

            foreach (var msg in pendentes)
            {
                try
                {
                    var jaAplicada = await estoque.MensagensAplicadas.AnyAsync(a => a.Id == msg.Id, ct);
                    if (!jaAplicada)
                    {
                        var dados = JsonSerializer.Deserialize<ReservaMensagem>(msg.Conteudo)!;
                        foreach (var item in dados.Itens)
                            estoque.Reservas.Add(new Reserva(item.ProdutoId, item.Quantidade, dados.PedidoId));
                        estoque.MensagensAplicadas.Add(new MensagemAplicada { Id = msg.Id });
                        await estoque.SaveChangesAsync(ct);
                    }
                    msg.ProcessadaEm = DateTime.UtcNow;
                }
                catch (Exception ex)
                {
                    msg.Tentativas++;
                    estoque.ChangeTracker.Clear();
                    log.LogWarning(ex, "Falha ao aplicar mensagem {Id}, tentativa {N}", msg.Id, msg.Tentativas);
                }
                await loja.SaveChangesAsync(ct);
            }

            await Task.Delay(TimeSpan.FromSeconds(5), ct);
        }
    }
}

A tabela MensagensAplicadas no banco de estoque tem o id como chave primária. Se o worker aplicar a reserva e cair antes de marcar a mensagem como processada na loja, na próxima volta ele encontra o id já aplicado e só marca. E se dois workers pegarem a mesma mensagem ao mesmo tempo, a chave primária recusa a segunda inserção e a transação inteira do estoque é desfeita. É o mesmo princípio de idempotência no pagamento: a repetição precisa ser segura, porque ela vai acontecer.

O que mostrar para a pessoa e para a operação

A tela do pedido não deve dizer "estoque reservado" antes de ser verdade. Um status "reserva pendente" que vira "reservado" quando a mensagem é processada é honesto e cabe na maioria dos negócios. Para a operação, duas consultas resolvem: quantas mensagens estão pendentes e qual a mais antiga. Se a mais antiga tem mais de alguns minutos, alguém precisa olhar. Mensagem que esgotou tentativas não some: fica na tabela, com o erro no log, esperando correção e reprocessamento manual.

Quando o volume crescer, a mesma tabela outbox pode alimentar uma fila de mensagens em vez de o worker escrever direto no outro banco. O princípio não muda: o evento nasce na mesma transação do dado que o originou.

Erros comuns

  • Dois SaveChanges em sequência tratados como uma operação. O primeiro confirma, o segundo falha, e o sistema fica com meia verdade sem registro de que falta algo.
  • Compensação no catch. Se o processo cair entre os passos, o catch nunca roda e o pedido órfão fica para sempre.
  • Contar com transação distribuída. Funciona no ambiente de alguém e falha no contêiner de produção.
  • Worker sem idempotência. Uma queda depois do commit no estoque e antes de marcar a mensagem duplica a reserva.
  • Mostrar sucesso total antes da hora. O cliente acha que o produto está garantido enquanto a reserva ainda não aconteceu.
  • Mensagem que falha some em silêncio. Sem contador de tentativas e alerta, o estoque diverge e ninguém sabe desde quando.

Checklist

  • Toda operação que escreve em dois bancos foi listada, com o que acontece se o segundo falhar.
  • O dado principal e a mensagem outbox são gravados no mesmo SaveChanges.
  • O destino registra o id da mensagem aplicada, com chave única.
  • Mensagens têm contador de tentativas e não são apagadas quando falham.
  • Existe alerta para mensagem pendente há mais tempo que o aceitável.
  • A tela mostra um estado intermediário em vez de prometer o que ainda não aconteceu.
  • Nenhum fluxo depende de transação distribuída para funcionar.

Pegue a operação do seu sistema que grava em dois bancos e derrube o segundo no meio dela, num ambiente de teste: o primeiro banco fica com um registro que ninguém vai corrigir?

Avalie este artigo

Referência: https://dejanstojanovic.net/aspnet/2020/november/accessing-multiple-databases-from-the-same-dbcontext-in-ef-core/