Padrões de Sistemas Distribuídos (#01): Dual-Write Problem — por que `@Transactional` sozinho não basta com Kafka
DEV Community

Padrões de Sistemas Distribuídos (#01): Dual-Write Problem - por que @Transactional sozinho não basta com Kafka

O banco salvou os dados. O Kafka não recebeu a mensagem. E agora? Em sistemas distribuídos, uma operação aparentemente simples pode esconder um problema sério de consistência. O Dual-Write Problem aparece quando uma aplicação precisa atualizar dois recursos independentes - por exemplo, persistir um pedido no PostgreSQL e publicar um evento no Kafka - sem possuir uma transação atômica entre eles. Imagine que uma aplicação precise executar duas operações: - Persistir um dado no banco. - Publicar um evento em um message broker. Por exemplo: Figura 1 - Dual write: a aplicação grava no banco de dados e publica no Kafka como duas operações independentes. Na maioria das vezes, tudo funciona. O problema começa quando alguma coisa falha exatamente entre essas duas operações. Esse cenário é conhecido como Dual-Write Problem. O problema Imagine uma aplicação responsável por criar pedidos. Quando um pedido é criado, precisamos: - Salvar o pedido no PostgreSQL. - Publicar um evento OrderCreated no Kafka. Uma implementação comum poderia ser semelhante a: public void createOrder(Order order) { orderRepository.save(order); // Database (Processo 1) kafkaTemplate.send( // Kafka (Processo 2) "order-created", new OrderCreatedEvent(order) ); } Visualmente: Figura 2 - Fluxo de criação do pedido: persistência no PostgreSQL e publicação do evento OrderCreated no Kafka. Parece simples. Mas existe uma pergunta extremamente importante: O que acontece se a aplicação falhar exatamente entre essas duas operações? Por exemplo: Figura 3 - Dual-Write Problem: o pedido é salvo no PostgreSQL, mas o evento OrderCreated não chega ao Kafka. Agora temos: PostgreSQL ORDER 123 STATUS = CREATED ✅ Pedido existente Enquanto no Kafka: OrderCreated ❌ Evento inexistente O banco sabe que o pedido existe. Os serviços que dependem do evento não sabem. Passamos a ter diferentes versões da realidade dentro do mesmo sistema. Por que isso é perigoso? Imagine que outros serviços dependam do evento OrderCreated . Por exemplo: Figura 4 - A ausência do evento no Kafka impede que serviços de pagamento, estoque e notificação reajam à criação do pedido. Se o pedido foi salvo, mas o evento não foi publicado: - o pagamento pode não ser iniciado; - o estoque pode não ser reservado; - o cliente pode não receber uma notificação; - outros processos de negócio podem nunca começar. O pedido existe no banco, mas parte do sistema simplesmente não sabe disso. O problema deixa de ser apenas técnico. Ele passa a afetar diretamente o negócio. Não adianta dizer: "Basta usar uma transação" Uma resposta comum para esse problema é utilizar uma transação. Por exemplo, utilizando Spring: @Transactional public void createOrder(Order order) { orderRepository.save(order); kafkaTemplate.send( "order-created", new OrderCreatedEvent(order) ); } Agora temos uma transação. Problema resolvido? Não necessariamente. Visualizando o problema: por que uma transação local não resolve automaticamente um ambiente distribuído? O @Transactional , em uma configuração tradicional utilizando um banco relacional, controla principalmente a transação associada ao banco de dados. Isso permite, por exemplo, realizar rollback das alterações referentes àquela transação. Algo semelhante a: BEGIN; INSERT INTO orders (...); COMMIT; Porém, PostgreSQL e Kafka continuam sendo dois recursos independentes. A aplicação está tentando coordenar duas operações que não fazem automaticamente parte da mesma transação atômica. Podemos ter diferentes cenários. Cenário 1 - Banco funciona e Kafka falha Figura 5 - Cenário 1: o banco confirma a operação, mas a publicação no Kafka falha, gerando inconsistência. Resultado: Banco possui o pedido. Kafka não possui o evento. Temos uma inconsistência. Cenário 2 - Kafka recebe, mas o banco falha Dependendo da forma como o código está estruturado, podemos criar o cenário inverso. Figura 6 - Cenário 2: o Kafka recebe o evento, mas a persistência no banco de dados falha. Agora o Kafka possui um evento informando que um pedido foi criado. Mas o pedido não existe no banco. Novamente temos duas versões diferentes da realidade. O verdadeiro problema O problema principal é que essas operações não compartilham a mesma atomicidade. Em uma operação realmente atômica esperamos: 0% ou 100% Ou tudo acontece. Ou nada acontece. Seria algo como: Figura 7 - Atomicidade ideal: banco de dados e broker concluiriam as duas operações como uma única unidade. Ou: Figura 8 - Em uma operação realmente atômica, uma falha faria ambas as operações serem revertidas. Porém, como vimos anteriormente, o fato de apenas um dos dois processamentos ser concluído é justamente o problema. Transactional Outbox Pattern Uma das soluções mais conhecidas para esse problema é o Transactional Outbox Pattern. A ideia principal é relativamente simples. Em vez de tentar atualizar o banco e publicar diretamente no Kafka durante a mesma operação: Figura 9 - Abordagem de dual write: a aplicação tenta atualizar o banco e publicar diretamente no Kafka durante a mesma requisição. Passamos a fazer: Figura 10 - Transactional Outbox Pattern: dados de negócio e evento são gravados na mesma transação local; a publicação no Kafka ocorre posteriormente. Perceba que a aplicação grava no banco de dados, em uma única operação: - os dados de negócio; - o evento que precisa ser publicado. Tudo dentro da mesma transação local do banco de dados. A Outbox Table Criamos uma tabela adicional no banco. Por exemplo: outbox_events Ela pode possuir campos como: id aggregate_id event_type payload created_at status Por exemplo: id evt-123 aggregate_id order-999 event_type OrderCreated status PENDING created_at 2026-09-02 E o payload: { "orderId": 999, "customerId": 42, "total": 199.90 } Agora temos uma única transação A criação do pedido pode executar algo semelhante a: BEGIN; INSERT INTO orders (...); INSERT INTO outbox_events (...); COMMIT; Visualmente: Figura 11 - Uma única transação PostgreSQL persiste o pedido em orders e o evento em outbox_events . Agora existe atomicidade entre essas duas escritas no banco. Ou ambos são persistidos: Figura 12 - Commit bem-sucedido: orders e outbox_events são persistidos atomicamente na mesma transação. Ou ambos sofrem rollback: Figura 13 - Rollback da transação: se a operação falhar, nem o pedido nem o evento da outbox são persistidos. Isso elimina a dupla escrita entre o banco de dados e o broker durante a requisição principal. Atomicidade A atomicidade existe especificamente na execução da operação de persistência. Isso acontece porque os dados de negócio e o registro da outbox estão dentro da mesma transação do banco de dados. A publicação no Kafka continua acontecendo posteriormente. Ou seja: Figura 14 - Após o commit, um Message Relay lê os eventos da Outbox Table e os publica no Kafka. Essa diferença é extremamente importante. A aplicação não está tentando transformar PostgreSQL e Kafka em uma única operação atômica. Ela está garantindo que: Pedido criado + Evento pendente para publicação sejam persistidos juntos. Depois disso, outro componente é responsável pela publicação do evento. Estratégias de acesso à tabela Outbox Agora precisamos de algum componente responsável por retirar os eventos da tabela Outbox e publicá-los no broker. Existem diferentes estratégias para fazer isso. Por exemplo: - polling publisher; - Change Data Capture (CDC); - ferramentas como Debezium; - processos dedicados de Message Relay. Mas esse será o tema do próximo artigo da série: Padrões de Sistemas Distribuídos (#02) - Message Relay: consumindo uma tabela Outbox para manter a atomicidade Nele, veremos como retirar os eventos da Outbox Table e publicá-los no Kafka de forma confiável. Conclusão O Dual-Write Problem aparece sempre que precisamos modificar dois recursos independentes e esperamos que ambos permaneçam consistentes. No nosso exemplo: PostgreSQL + Kafka Uma transação local no banco não transforma automaticamente os dois recursos em uma única operação atômica. O Transactional Outbox Pattern resolve esse problema mudando a estratégia. Em vez de: Banco + Kafka durante a operação principal, fazemos: orders + outbox_events dentro da mesma transação PostgreSQL. E posteriormente: outbox_events ↓ Message Relay ↓ Kafka Com isso, conseguimos garantir a atomicidade da escrita local e tornar a publicação do evento um processo confiável e recuperável. Referências AWS Prescriptive Guidance - Transactional Outbox Pattern https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.htmlDebezium - Outbox Event Router https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.htmlInspiração / estudo - Renato Augusto

Próximo artigo Padrões de Sistemas Distribuídos (#02) - Message Relay: consumindo uma tabela Outbox para manter a atomicidade Se você trabalha com Java, Spring Boot, Kafka, arquitetura orientada a eventos ou sistemas distribuídos, acompanhe a série para os próximos padrões. Top comments (0)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.