DEV Community

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

Posted on

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:

  1. Persistir um dado no banco.
  2. 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:

  1. Salvar o pedido no PostgreSQL.
  2. 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)
    );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Enquanto no Kafka:

OrderCreated

❌ Evento inexistente
Enter fullscreen mode Exit fullscreen mode

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)
    );
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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%
Enter fullscreen mode Exit fullscreen mode

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:

  1. os dados de negócio;
  2. 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
Enter fullscreen mode Exit fullscreen mode

Ela pode possuir campos como:

id
aggregate_id
event_type
payload
created_at
status
Enter fullscreen mode Exit fullscreen mode

Por exemplo:

id           evt-123
aggregate_id order-999
event_type   OrderCreated
status       PENDING
created_at   2026-09-02
Enter fullscreen mode Exit fullscreen mode

E o payload:

{
  "orderId": 999,
  "customerId": 42,
  "total": 199.90
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

durante a operação principal, fazemos:

orders
  +
outbox_events
Enter fullscreen mode Exit fullscreen mode

dentro da mesma transação PostgreSQL.

E posteriormente:

outbox_events
      ↓
Message Relay
      ↓
Kafka
Enter fullscreen mode Exit fullscreen mode

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


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)