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
- Crie uma tabela
Outboxno 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. - Grave o pedido e a mensagem no mesmo
SaveChanges. Mesmo banco, mesma transação: ou os dois existem, ou nenhum. - Responda ao cliente com o pedido criado e o estoque como "reservando".
- Um worker lê mensagens pendentes em ordem e aplica cada uma no banco de estoque.
- 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.
- Marque a mensagem como processada no banco da loja só depois do commit no estoque.
- 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
SaveChangesem 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, ocatchnunca 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?