Programming On Mars Logo
  • Início
  • Artigos
  • Laboratórios
Programming On Mars Logo
  • Início
  • Artigos
  • Laboratórios

  • Andre Lucas
  • Mon Feb 23 2026

O Problema do Dual Write: Consistência em Microsserviços

Você já escreveu esse código. Passou em code review, passou em testes de integração, e está rodando em produção agora.

function placeOrder(order):
    db.save(order)
    broker.publish("OrderPlaced", order)
    return order.id

No caminho feliz, as duas operações funcionam sem problemas. O pedido entra no banco. O evento chega no broker. Os serviços downstream recebem e processam. Tudo parece correto.

Agora imagine que o processo cai entre as duas linhas. Ou a rede para o broker dá timeout. Ou o banco commita e o broker retorna um erro transitório. Em cada um desses cenários — que não são edge cases, mas modos de falha normais em sistemas distribuídos — seu banco de dados e seu broker ficam em estados diferentes. Um acredita que o pedido existe. O outro nunca soube dele.

Esse é o dual write problem. E ele vai afetar todo sistema que escreve em um banco de dados e em um broker de mensagens na mesma operação — é só questão de tempo.


O Que É o Dual Write Problem?

O dual write problem é a impossibilidade de escrever atomicamente em dois sistemas independentes — tipicamente um banco relacional e um broker de mensagens — sem um mecanismo de coordenação distribuída que abranja os dois.

Em uma aplicação tradicional com banco único, uma transação garante atomicidade: ou todas as alterações dentro da transação são commitadas juntas, ou nenhuma delas é. Essa garantia é o fundamento da consistência de dados — é o que torna sistemas de banco de dados confiáveis.

No momento em que você adiciona um segundo sistema independente — um cluster Kafka, um broker RabbitMQ, uma API HTTP externa — essa garantia de atomicidade desaparece. Os dois sistemas operam de forma independente. Não há um transaction log compartilhado. Não há um coordenador capaz de fazer rollback nos dois ao mesmo tempo se algo der errado.

O resultado é uma lacuna de consistência: uma janela de tempo (por menor que seja) durante a qual um sistema já commitou uma alteração e o outro ainda não a recebeu. Em condições normais, essa janela fecha rapidamente e passa despercebida. Sob condições de falha, ela se torna permanente, e a inconsistência vira um bug.

O que torna o dual write problem insidioso é que ele não aparece no caminho feliz. Seus testes de integração passam. Seus testes de carga passam. Tudo parece correto — até que uma falha parcial expõe a inconsistência, frequentemente em produção, frequentemente depois de meses de drift silencioso acumulado.


Por Que Transações Não Cruzam Fronteiras de Sistema

O instinto natural é perguntar: "Não posso simplesmente envolver as duas operações em uma transação?" A resposta é não — pelo menos não de forma prática.

Transações de banco de dados são escopadas a uma única conexão de banco. Quando você chama db.save(), o motor do banco rastreia a alteração no write-ahead log e a mantém pendente até o commit. Quando você chama broker.publish(), você está fazendo uma chamada de rede para um processo completamente separado, com seu próprio estado interno e seu próprio modelo de durabilidade.

BEGIN / COMMIT em SQL, @Transactional em Java, session.flush() em um ORM — nenhum desses chega até o broker. Eles simplesmente não sabem que ele existe.

Mas e as transações distribuídas? O Two-Phase Commit (2PC) é o mecanismo clássico para coordenar transações entre múltiplos participantes. Em teoria, você poderia incluir tanto o banco quanto o broker em um protocolo 2PC e obter commits atômicos nos dois.

Na prática, isso raramente funciona:

  • A maioria dos brokers de mensagens suportar XA é raro e pouco utilizado em arquiteturas cloud-native — o protocolo do qual o 2PC depende
  • O 2PC é fundamentalmente bloqueante: todos os participantes mantêm locks enquanto o coordenador decide, o que impacta severamente escalabilidade sob carga
  • Coordenadores distribuídos se tornam pontos únicos de falha
  • A complexidade operacional de gerenciar 2PC entre infraestrutura heterogênea é considerável

Por essas razões, a indústria praticamente evita em sistemas distribuídos modernos o 2PC. Arquiteturas cloud-native são construídas sobre a premissa de consistência eventual. O limite da transação termina na borda do banco de dados. O broker fica do lado de fora. E é exatamente nessa fronteira que o dual write problem vive.


Os Três Modos de Falha

O dual write problem não é uma falha única — é uma família de falhas. Entender cada uma com precisão é fundamental antes de projetar uma solução.

Modo de Falha 1: Evento Perdido

O write no banco funciona, mas a publicação no broker falha.

db.save(order)       // ✓ commitado no banco
broker.publish(...)  // ✗ erro de rede — evento nunca entregue

O pedido existe no banco. Os consumidores downstream nunca recebem o evento OrderPlaced. O serviço de estoque nunca reserva os itens. O serviço de notificações nunca envia o e-mail de confirmação.

A inconsistência é silenciosa. Nenhuma exceção chega ao usuário — o ID do pedido é retornado com sucesso. A divergência fica invisível até que algum time downstream perceba que os registros deles não batem com a tabela de pedidos.

Modo de Falha 2: Evento Fantasma

A publicação no broker funciona, mas o write no banco falha ou sofre rollback.

broker.publish("OrderPlaced", order)  // ✓ evento entregue ao broker
db.save(order)                        // ✗ violação de constraint — rollback

Ou, mais comumente, a publicação acontece dentro de uma transação que depois sofre rollback:

begin transaction
    db.save(order)
    broker.publish(...)   // evento sai do sistema AGORA, antes do commit
    // algo falha depois — validação, chamada HTTP, constraint no banco
rollback transaction
// pedido nunca existiu no banco, mas o evento já está no broker

Os consumidores downstream já estão agindo sobre o evento. O estoque foi reservado. Um e-mail foi enviado. Uma cobrança está pendente. Mas o pedido em si nunca foi commitado no banco de dados.

Esse é o evento fantasma — uma realidade visível para o mundo externo que não existe na fonte da verdade. Desfazer isso exige compensating transactions em todos os sistemas downstream, o que é um problema de sistemas distribuídos pelo menos tão difícil quanto o original.

Modo de Falha 3: Evento Duplicado

O write no banco funciona, mas não se sabe se a publicação no broker completou antes do crash.

db.save(order)          // ✓ commitado
broker.publish(...)     // chamada inicia — processo cai no meio
// o evento chegou ao broker? não há como saber.

Na recuperação ou no retry, a aplicação publica novamente — porque ela não consegue saber se a primeira tentativa funcionou. O broker agora tem duas cópias do OrderPlaced. Os consumidores downstream processam o evento duas vezes: estoque reservado duas vezes, cliente cobrado duas vezes, dois e-mails de confirmação enviados.

Esse modo de falha é particularmente perigoso porque não impede que o sistema pareça funcionar. Tudo tem sucesso — só que vezes demais. Os bugs são bugs de lógica de negócio: cobranças duplicadas, estoque reservado em excesso, notificações em duplicata. Eles aparecem em dashboards de monitoramento, não em logs de erro.

Esse modo também empurra complexidade para o exterior: ele obriga cada consumidor downstream a implementar idempotência — a capacidade de detectar e descartar eventos duplicados. Não é lógica trivial, e fica espalhada por todos os serviços que assinam seus eventos.


Veja como os três modos se mapeiam em relação ao timing de execução:

sequenceDiagram
    participant S as Serviço
    participant DB as Banco de Dados
    participant B as Broker

    rect rgb(0, 0, 0)
        Note over S,B: Caminho Feliz
        S->>DB: save(order) ✓
        S->>B: publish(OrderPlaced) ✓
    end

    rect rgb(0, 0, 0)
        Note over S,B: Evento Perdido
        S->>DB: save(order) ✓
        S->>B: publish(OrderPlaced) ✗ timeout
        Note over B: evento nunca chega
    end

    rect rgb(0, 0, 0)
        Note over S,B: Evento Fantasma
        S->>B: publish(OrderPlaced) ✓
        S->>DB: save(order) ✗ rollback
        Note over DB: pedido nunca commitado
    end

    rect rgb(0, 0, 0)
        Note over S,B: Evento Duplicado
        S->>DB: save(order) ✓
        S->>B: publish(OrderPlaced) — crash
        Note over S: retry no restart
        S->>B: publish(OrderPlaced) ✓
        Note over B: duas cópias entregues
    end

O lab Event-Driven Architecture in Practice demonstra cada um desses três cenários rodando contra PostgreSQL e Kafka reais — você vê exatamente o que quebra e quando.


Por Que Correções no Nível de Aplicação Não Resolvem

Diante desses modos de falha, times costumam recorrer a correções no nível de aplicação: lógica de retry, verificações de estado antes de publicar, compensating transactions.

Essas abordagens reduzem o problema — elas não o eliminam.

Retry ajuda com falhas transitórias no broker, mas cria duplicatas quando você não consegue distinguir "o broker nunca recebeu o evento" de "o broker recebeu mas o ACK foi perdido no caminho de volta". Sem essa distinção, o retry troca eventos perdidos por eventos duplicados.

Verificar estado antes de publicar — ler do banco para confirmar que o write funcionou antes de publicar — introduz desafios de consistência de leitura após escrita, adiciona latência em todo o caminho de write, e ainda tem uma race condition entre a verificação e a publicação. Também é código morto no caminho feliz e difícil de testar corretamente.

Compensating transactions — desfazer operações de negócio após uma falha parcial — funcionam em operações simples de dois passos e quebram em operações complexas. Exigem design cuidadoso, adicionam complexidade de código a cada serviço envolvido, e ainda têm uma janela de inconsistência entre quando a falha é detectada e quando a compensação roda.

O problema fundamental com todas essas abordagens: elas tentam cobrir com lógica de aplicação um problema estrutural. O write no banco e o write no broker são operações separadas e não coordenadas. Nenhuma quantidade de retry ou verificação consegue tornar dois writes em sistemas independentes atômicos.

As soluções confiáveis tratam isso no nível de arquitetura.


O Espaço de Soluções

Três padrões resolvem o dual write problem de forma confiável. Cada um elimina a lacuna de consistência na raiz, com trade-offs diferentes.

Transactional Outbox Pattern

A solução mais amplamente adotada. O insight é simples: se você não consegue escrever atomicamente em um banco e em um broker, escreva no banco duas vezes — e deixe um processo separado fazer o relay do banco para o broker.

begin transaction
    db.save(order)                                            // write de negócio
    db.save(outbox, { type: "OrderPlaced", payload: order })  // write de outbox
commit transaction

// processo de relay (roda de forma independente):
for each pending_message in outbox:
    broker.publish(pending_message)
    db.mark_as_processed(pending_message)

Os dois writes no banco são atômicos — commitam juntos ou não commitam. O processo de relay lê da tabela outbox e publica no broker. Se o relay cair, ele reinicia e continua a partir da última linha não processada.

A garantia crítica: um evento só entra no outbox se o write de negócio correspondente foi commitado. Eventos fantasma são estruturalmente impossíveis. Eventos perdidos são eliminados. O relay pode entregar o evento mais de uma vez em cenários de crash-recovery, então idempotência nos consumidores downstream continua sendo uma boa prática — mas você centraliza a lógica de retry em um único lugar, não espalhada por cada consumidor.

O trade-off: a entrega é eventualmente consistente. O evento chega ao broker logo após o commit no banco, não instantaneamente. Para a maioria das arquiteturas orientadas a eventos, isso é perfeitamente aceitável. Você também precisa de um processo de relay e de uma estratégia para gerenciar o crescimento da tabela outbox.

Para experimentar o padrão localmente antes de implementar, o mars-enterprise-kit-lite já sobe PostgreSQL, Kafka e um serviço Spring Boot com Docker Compose — infraestrutura real sem configuração manual. Clone, suba, e você tem um ambiente funcional para validar o design da sua tabela outbox contra uma stack completa.

Change Data Capture (CDC)

Uma abordagem mais no nível de infraestrutura. Em vez de escrever em uma tabela outbox explícita, você acessa diretamente o log de replicação do banco — Write-Ahead Log do PostgreSQL, binlog do MySQL — e transmite as alterações commitadas em nível de linha diretamente para o broker.

Ferramentas como o Debezium atuam como conectores CDC: eles leem o log de replicação e publicam change events em tópicos Kafka sem nenhuma alteração no código da aplicação. Toda linha que commita no banco se torna automaticamente um evento.

O trade-off: CDC emite change events no nível do banco, não domain events. Você recebe "linha com esses valores de coluna foi inserida nessa tabela" em vez de "um pedido foi feito com esse contexto de negócio". Traduzir entre os dois exige lógica adicional de processamento de eventos. Operacionalmente, conectores CDC (Debezium, Kafka Connect) adicionam complexidade de infraestrutura. Para times que já rodam Kafka Connect, pode ser um fit natural. Para quem não roda, é um investimento operacional significativo.

Event Sourcing

A mudança mais arquitetural. No event sourcing, o log de eventos é a fonte da verdade. Seu serviço não persiste estado — persiste eventos. O estado atual de um pedido é derivado da replay de todos os eventos OrderCreated, OrderUpdated, OrderShipped desde o início.

O dual write problem desaparece porque existe apenas um destino de write — o event store. Eventos são simultaneamente o mecanismo de persistência e o mecanismo de comunicação. Não há um write no banco separado para coordenar.

O trade-off é substancial: event sourcing é um modelo de programação, não apenas um padrão. Exige repensar como você consulta estado (event stores não são relacionais), como você trata evolução de schema (formatos de evento precisam ser versionados com cuidado), e como você raciocina sobre tempo e causalidade. É a escolha certa para domínios onde auditabilidade, consultas temporais e trilhas de auditoria completas são requisitos de primeira classe. É pesado demais para serviços que só precisam de publicação confiável de eventos.


Qual Padrão Usar?

Para a maioria dos times construindo arquiteturas orientadas a eventos hoje, o Transactional Outbox Pattern é a escolha pragmática.

Ele resolve o problema central sem exigir mudanças de infraestrutura (CDC) ou uma mudança completa de modelo de programação (Event Sourcing). Funciona com qualquer banco relacional e qualquer broker de mensagens, se encaixa naturalmente junto com o desenvolvimento database-first já existente, e seus modos de falha são compreensíveis e testáveis. Para times que precisam garantir consistência de dados em microsserviços sem reformular o stack, o padrão outbox é o ponto de entrada pragmático.

Se você está dividindo um monolito em microsserviços, o padrão outbox pode ser adotado de forma incremental — serviço por serviço, sem reestruturar toda a sua camada de dados.

CDC vale a avaliação se você já opera infraestrutura Kafka Connect e quer publicação de eventos transparente para a aplicação. Event Sourcing vale o investimento se o seu domínio tem requisitos fortes de trilha de auditoria e seu time tem disponibilidade para se comprometer com o modelo de programação.


O dual write problem não se anuncia. Sistemas rodam por meses parecendo saudáveis enquanto acumulam drift silencioso. Então uma falha parcial expõe anos de inconsistência de uma vez: um Evento Perdido que deixou serviços downstream fora de sincronia, um Evento Fantasma que gerou cobranças para pedidos que nunca existiram, um Evento Duplicado que reservou estoque em excesso e mandou e-mails duplicados. O dano é de negócio, não só técnico. Projetar contra esses modos de forma deliberada — escolhendo o padrão certo para o seu contexto — é o que separa microsserviços que escalam dos que silenciosamente saem de sincronia.

O Que Ler a Seguir

O lab Event-Driven Architecture in Practice reproduz os três modos de falha contra infraestrutura real: PostgreSQL e Kafka rodando ao vivo, com testes que forçam cada condição de falha e mostram exatamente o que quebra. É o ponto de partida mais direto para sair da teoria e ver o problema acontecendo.

Para ter o ambiente local pronto agora, clone o mars-enterprise-kit-lite e suba a stack com Docker Compose — PostgreSQL, Kafka e Spring Boot configurados, sem setup manual.

A Parte 2 desta série cobre o Transactional Outbox Pattern em profundidade: design da tabela outbox, processo de relay com FOR UPDATE SKIP LOCKED, idempotência nos consumidores, e testes de integração com Testcontainers. Em breve.

Tags:
ArquiteturaEvent-DrivenMicroservices
  • Política de Privacidade
  • Termos de Serviço
  • Contato
© 2025 Programming On Mars. Todos os direitos reservados.