🔁 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:
- 🔁 Faz um POST em uma API externa
- 💾 Insere um registro no banco de dados
- 📤 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));
}
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 é:
-
Tabela Outbox: Em vez de publicar direto no Kafka, você insere o evento em uma tabela
outboxna mesma transação que suas escritas de negócio. -
CDC (Change Data Capture): Ferramentas como Debezium monitoram a tabela
outboxe 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;
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));
}
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-offsetcomo 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)
💬 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)