DEV Community

Cover image for Kafka sem duplicação – 2 padrões pra você dormir em paz
Fabio Rocha
Fabio Rocha

Posted on

Kafka sem duplicação – 2 padrões pra você dormir em paz

🔁 Kafka sem duplicação – 3 padrões? Não, 2 bastam! (os que realmente funcionam)

E aí, pessoa desenvolvedora de microsserviços? Já teve pesadelo com mensagem duplicada no Kafka? Já processou o mesmo evento duas vezes e olhou pro banco de dados com aquele suor frio descendo a espinha? Pois é. Duplicação é a pedra no sapato de qualquer sistema distribuído.

Mas calma. Hoje a gente vai focar nos dois padrões que realmente resolvem o problema sem firula: Consumidor Idempotente e Transactional Outbox. Nada de Kafka Transaction API (que, entre nós, mais atrapalha do que ajuda quando tem banco relacional no meio). Pega um café, bora codar com menos susto e mais confiança! 🚀


O cenário clássico que dá pesadelo

Imagine que seu serviço consome uma mensagem de um tópico, faz o seguinte pipeline:

  1. 🔁 Faz um POST em uma API externa
  2. 💾 Insere um registro no banco de dados
  3. 📤 Publica um evento em um tópico de saída

Seu código consome a mensagem, processa e só depois de tudo isso commit do offset no Kafka.

Aí o sistema morre bem no meio. O que acontece? A mensagem ainda não foi marcada como consumida. Kafka acha que o consumidor morreu e rebalanceia a partição para outra instância, que vai pegar exatamente a mesma mensagem e processar de novo. Resultado: POST duplicado, INSERT duplicado, evento duplicado. E você na UTI do sistema às 3 da manhã. ☠️

Mas calma, que temos dois padrões matadores para resolver isso. Bora?


🔑 Padrão 1: Consumidor Idempotente

A ideia aqui é simples: seu consumidor pode receber a mesma mensagem várias vezes, mas só processa uma vez por meio de IDs únicos armazenados.

Como funciona na prática:

1. ID único (Idempotency Key): Sua mensagem precisa carregar um identificador único, tipo transactionId ou eventId. Esse ID pode vir no header do Kafka ou dentro do payload.

2. Tabela de mensagens processadas: No mesmo banco onde você faz as escritas do seu negócio, cria uma tabelinha "processed_messages" com a chave primária sendo esse ID.

3. Processamento atômico: Você insere o ID nessa tabela na mesma transação que as outras escritas do banco. Se der pau no meio, tudo rola junto!

@Transactional
public void processarEvento(ConsumerRecord<String, OrderEvent> record) {
    String eventId = record.headers().lastHeader("eventId");

    // Se já existe, é duplicata
    if (processedMessageRepo.existsById(eventId)) {
        log.info("📌 Evento {} já processado. Pulando!", eventId);
        return;
    }

    // Processa o negócio
    orderService.criarPedido(record.value());

    // Marca como processado na MESMA transação
    processedMessageRepo.save(new ProcessedMessage(eventId));
}
Enter fullscreen mode Exit fullscreen mode

Se criarPedido falhar, a inserção na tabela processed_messages também rola de volta automaticamente. O @Transactional garante a atomicidade.

Vantagem: Simples, funciona com qualquer banco relacional.

Desvantagem: Não resolve sozinho a publicação atômica de eventos no Kafka (pra isso entra o padrão 2).


📦 Padrão 2: Transactional Outbox

Esse padrão resolve um outro problema clássico: você precisa publicar um evento no Kafka e gravar no banco de dados, mas as duas ações não podem ser atômicas usando o mesmo contexto transacional.

A solução é:

  1. Tabela Outbox: Em vez de publicar direto no Kafka, você insere o evento em uma tabela outbox na mesma transação que suas escritas de negócio.
  2. CDC (Change Data Capture): Ferramentas como Debezium monitoram a tabela outbox e publicam os eventos no tópico final de forma garantida e idempotente.
BEGIN;
  -- 1. Escrita de negócio
  INSERT INTO pedidos (id, valor, cliente_id) VALUES (123, 100.00, 1);

  -- 2. Evento vai pra tabela outbox (na mesma transação!)
  INSERT INTO outbox (aggregate_id, event_type, payload)
  VALUES (123, 'PedidoCriado', '{"valor": 100.00}');
COMMIT;
Enter fullscreen mode Exit fullscreen mode

Com CDC, o evento vai parar no tópico final sem risco de inconsistência entre banco de dados e Kafka.

Vantagem: Publicação confiável e atômica com o banco.

Desvantagem: Requer infra adicional (Debezium, Kafka Connect), e o CDC pode ter latência de segundos.


👑 O combo campeão: consumidor idempotente + outbox

Agora junta os dois: o padrão 1 garante que a mesma mensagem não vai ser processada duas vezes no consumidor, e o padrão 2 garante que os eventos de saída serão publicados de forma confiável sem duplicação no lado do produtor.

Juntos, eles formam a base de qualquer microsserviço que se preze:

@Transactional
public void processarEventoIntegrado(ConsumerRecord<String, Evento> record) {
    // 1. Idempotent Consumer: verifica se já processou
    String eventId = record.headers().lastHeader("eventId");
    if (processedMessageRepo.existsById(eventId)) {
        return;
    }

    // 2. Chama API externa (não tem jeito, aqui pode dar duplicata)
    //    Mas aí é problema da API externa ser idempotente, não nosso :)
    apiExterna.criarRecurso(record.value().getDados());

    // 3. Escrita de negócio + outbox (tudo na mesma transação)
    pedidoService.criarPedido(record.value().getPedido());
    outboxRepo.save(criarEventoOutbox(record.value()));

    // 4. Marca como processado (ainda na mesma transação)
    processedMessageRepo.save(new ProcessedMessage(eventId));
}
Enter fullscreen mode Exit fullscreen mode

Pronto. Você dorme em paz.


⚠️ Melhores práticas e dicas quentes que ninguém conta

1.🔑 Idempotency Keys bem feitas

  • UUID gerado no cliente: Mais seguro. O produtor gera, e o ID viaja até o consumidor.
  • Chave composta de negócio: Exemplo: customerId:orderId:timestamp. Funciona se a combinação for verdadeiramente única.
  • Coordenadas do Kafka: Usar topic-partition-offset como ID. Simples, mas quebra se você reproduzir o evento a partir de um tópico diferente.

2.⏱️ Cuidado com timeouts e rebalanceamento

Configuração max.poll.interval.ms e max.poll.records precisa estar alinhada com o tempo médio de processamento do seu lote. Se o consumidor demorar demais e não conseguir chamar poll() dentro do tempo, o broker acha que ele morreu e redistribui as partições – causando reprocessamento.

3.🧹 Limpeza da tabela processed_messages

Essa tabela pode crescer infinitamente. Crie uma rotina de limpeza assíncrona (ex: job diário que apaga registros com mais de 7 dias).

4.🧪 Outbox com polling simples (sem CDC)

Se você não quiser usar Debezium, pode implementar um polling publisher – um scheduler que lê da tabela outbox e publica no Kafka em lotes. Mas aí você assume responsabilidade de idempotência e transação.

5.🚫 O que não fazer

  • Não tente coordenar transações entre Kafka e banco com XA ou JTA. É lento, frágil e geralmente um pesadelo.
  • Não use Kafka Transaction API junto com transações de banco. Já vimos que a combinação não é atômica e pode causar perda de dados.

🧭 Fluxo de decisão: qual padrão escolher?

Você só precisa gravar no banco e ponto?
  ├─ Banco relacional → Idempotent Consumer com tabela processed_messages
  └─ NoSQL (Redis, MongoDB) → Use lock distribuído (SETNX) ou tabela auxiliar

Você precisa gravar no banco E publicar eventos no Kafka:
  ├─ Tem budget e infra para CDC (Debezium)? → Transactional Outbox + Idempotent Consumer (recomendado)
  ├─ Quer algo mais leve sem CDC? → Outbox com polling + Idempotent Consumer
  └─ Precisa de latência baixíssima (milissegundos)? → Idempotent Consumer + transação manual com idempotent producer (cuidado!)

Cenário ideal para microsserviços críticos:
  ✅ Idempotent Consumer + Transactional Outbox (com CDC)
Enter fullscreen mode Exit fullscreen mode

💬 Dúvida comum: por que não usar Kafka Transaction API?

Essa pergunta aparece muito. A resposta direta: Kafka Transaction API foi feita para cenários de stream processing onde a única fonte de verdade é o próprio Kafka (ex: Kafka Streams). Quando você tem um banco relacional no meio, tentar unir as duas transações não traz atomicidade real e ainda introduz complexidade sem necessidade.

Os padrões 1 e 2 resolvem o problema de forma comprovada, com ferramentas maduras (Debezium, PostgreSQL, etc.) e sem surpresas. Por que inventar moda? 😎


🎯 Conclusão

Duplicação de mensagem não é questão de "se" vai acontecer, mas de "quando". As garantias de "at-most-once" e "at-least-once" do Kafka são otimistas demais para sistemas reais. O que salva é o design idempotente do seu consumidor.

Lembre-se:

  • A base de tudo é o Idempotent Consumer (eventId + tabela de controle)
  • O Transactional Outbox garante a publicação confiável de eventos sem duas fases de commit
  • O combo Idempotent Consumer + Outbox é o mais recomendado, robusto e dorme-se em paz
  • Esqueça Kafka Transaction API se você tem banco de dados – não é pra você

Agora é sua vez: já passou por algum apagão de duplicação? Me conta nos comentários como resolveu. E se ainda não passou, já sabe onde mirar quando o caos chegar. Bora codar com menos duplicata e mais paz de espírito! 🛡️🔥

Quer mais conteúdo sobre Kafka, arquitetura de microsserviços e resiliência? Segue o blog e ativa as notificações – o próximo post vai ser sobre "Dead Letter Queues e como não perder eventos". Não perde!

Top comments (0)