Idempotent consumers: processando eventos duplicados com segurança
1. O problema da duplicação em sistemas assíncronos
1.1. Causas comuns de eventos duplicados
Em sistemas distribuídos baseados em mensageria, a duplicação de eventos é uma realidade inevitável. As principais causas incluem:
- Retries do produtor: quando o produtor não recebe confirmação de entrega, ele reenvia a mensagem, gerando duplicatas.
- Falhas de rede: pacotes perdidos ou atrasados podem levar ao reenvio de mensagens já entregues.
- Rebalanceamento de partições: em sistemas como Apache Kafka, o rebalanceamento pode causar reprocessamento de mensagens já consumidas.
1.2. Impactos no consumidor
Eventos duplicados podem causar:
- Inconsistência de dados: saldo duplicado em conta bancária, estoque incorreto.
- Efeitos colaterais indesejados: envio duplicado de e-mails, notificações ou cobranças.
- Violação de regras de negócio: criação duplicada de registros com chaves únicas.
1.3. Exemplo concreto
Considere um sistema de pagamentos que processa eventos de cobrança:
Evento original: { "eventId": "abc123", "orderId": "ORD-001", "amount": 100.00 }
Evento duplicado: { "eventId": "abc123", "orderId": "ORD-001", "amount": 100.00 }
Sem idempotência, o consumidor processaria o pagamento duas vezes, debitando R$200,00 em vez de R$100,00.
2. Conceito de idempotência no contexto de consumidores
2.1. Definição formal
Uma operação é idempotente quando, aplicada múltiplas vezes, produz o mesmo resultado que uma única aplicação. No contexto de consumidores, isso significa que processar o mesmo evento duas vezes não altera o estado final do sistema.
2.2. Idempotência no consumidor vs. produtor
- Idempotência no produtor: garante que o produtor não insira duplicatas no sistema de mensageria.
- Idempotência no consumidor: garante que o consumidor trate duplicatas de forma segura, mesmo que cheguem até ele.
2.3. Níveis de idempotência
- Operação pura: idempotente por natureza (ex.:
DELETEem REST). - Com estado externo: requer mecanismos de dedup (ex.: verificar se um pedido já foi processado).
3. Estratégias de identificação de duplicatas
3.1. Identificadores únicos de evento
Cada evento deve conter um eventId ou messageId único e imutável:
{
"eventId": "550e8400-e29b-41d4-a716-446655440000",
"eventType": "PaymentProcessed",
"payload": { "orderId": "ORD-001", "amount": 100.00 }
}
3.2. Armazenamento de IDs processados
A estratégia mais comum é manter um registro de eventos já processados:
Tabela de dedup (banco relacional):
CREATE TABLE processed_events (
event_id VARCHAR(64) PRIMARY KEY,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Cache distribuído (Redis):
SET processed:event_id:abc123 "true" EX 86400 NX
3.3. TTL e limpeza de registros
A janela de retenção deve ser dimensionada com cuidado:
- Janela curta (1 hora): risco de duplicatas tardias serem processadas novamente.
- Janela longa (7 dias): maior custo de armazenamento e verificação mais lenta.
4. Abordagens de implementação prática
4.1. Idempotência baseada em chave de negócio
Usar campos como orderId ou transactionId como chave de idempotência:
-- Upsert condicional: insere apenas se não existir
INSERT INTO payments (order_id, amount, status)
VALUES ('ORD-001', 100.00, 'COMPLETED')
ON CONFLICT (order_id) DO NOTHING;
4.2. Idempotência com versionamento otimista
Garantir que atualizações sejam aplicadas apenas na versão correta:
UPDATE orders
SET status = 'PAID', version = version + 1
WHERE order_id = 'ORD-001' AND version = 3;
4.3. Combinação de eventId + estado do recurso
-- Verifica se o evento já foi processado
SELECT 1 FROM processed_events WHERE event_id = 'abc123';
-- Se não existir, processa e registra
BEGIN;
INSERT INTO processed_events (event_id) VALUES ('abc123');
UPDATE accounts SET balance = balance - 100.00 WHERE account_id = 'ACC-001';
COMMIT;
5. Integração com padrões de entrega e retry
5.1. Relação com o Outbox Pattern
O Outbox Pattern garante que o eventId seja gerado no mesmo banco de dados da transação de negócio, assegurando unicidade desde a origem:
-- Tabela outbox
INSERT INTO outbox (event_id, event_type, payload)
VALUES ('abc123', 'PaymentProcessed', '{"orderId":"ORD-001"}');
-- Consumidor processa e registra na tabela de dedup
5.2. Tratamento de retries com backoff exponencial
Tentativa 1: aguarda 1 segundo
Tentativa 2: aguarda 2 segundos
Tentativa 3: aguarda 4 segundos
...
Máximo de tentativas: 5
Cada retry reenvia o mesmo eventId, permitindo que o consumidor detecte a duplicata.
5.3. Dead Letter Queue para falhas persistentes
Eventos que falham mesmo após dedup devem ser enviados para uma DLQ:
DLQ: topic: payments-dlq
Mensagem: { "eventId": "abc123", "error": "Conta inexistente", "retryCount": 5 }
6. Considerações de arquitetura e desempenho
6.1. Impacto no throughput
Cada verificação de duplicata adiciona latência:
- Redis: ~1-5ms por verificação, suporta >100k ops/s.
- Banco relacional: ~5-20ms por verificação, limitado por conexões.
6.2. Escolha do armazenamento de dedup
| Armazenamento | Prós | Contras |
|---|---|---|
| Redis | Baixa latência, TTL nativo | Volátil (RDB/AOF mitigam) |
| DynamoDB | Escalável, TTL integrado | Custo por operação |
| PostgreSQL | Consistência forte, transações | Latência maior |
6.3. Estratégias de sharding
Particione a tabela de dedup pelo eventId para escalabilidade horizontal:
-- Particionamento por hash do eventId
CREATE TABLE processed_events_0 PARTITION OF processed_events
FOR VALUES WITH (MODULUS 4, REMAINDER 0);
7. Testes e validação de idempotência
7.1. Testes de entrega duplicada
Cenário: reprocessar o mesmo evento duas vezes
1. Enviar evento com eventId = "abc123"
2. Aguardar processamento
3. Reenviar mesmo evento
4. Verificar que estado final não mudou
7.2. Testes de concorrência
Cenário: dois consumidores processam o mesmo evento simultaneamente
1. Disparar evento com eventId = "abc123"
2. Dois workers consomem ao mesmo tempo
3. Verificar que apenas um processou (atomicidade da dedup)
7.3. Monitoramento e métricas
Métricas recomendadas:
- dedup.duplicates_detected: total de duplicatas encontradas
- dedup.check_latency_ms: latência da verificação
- dedup.cache_hit_ratio: taxa de acerto no cache de dedup
8. Boas práticas e armadilhas comuns
8.1. Não depender apenas de idempotência do banco
Efeitos colaterais fora do banco (envio de e-mail, chamadas HTTP) precisam de proteção adicional:
// Incorreto: e-mail enviado mesmo se evento for duplicado
processPayment(event);
sendEmail(event); // Efeito colateral não idempotente
8.2. Cuidado com janelas de tempo muito curtas
Duplicatas tardias (após dias) podem passar despercebidas se o TTL for muito curto:
TTL de 1 hora: evento duplicado após 2 horas será processado novamente
TTL de 7 dias: maior segurança, maior custo de armazenamento
8.3. Documentação e contratos
Defina claramente no schema registry qual campo garante idempotência:
Schema Registry (Avro):
{
"type": "record",
"name": "PaymentEvent",
"fields": [
{"name": "eventId", "type": "string", "doc": "ID único do evento para idempotência"},
{"name": "orderId", "type": "string"},
{"name": "amount", "type": "double"}
]
}
Referências
- Idempotent Consumer Pattern - Microsoft Azure Architecture Center — Documentação oficial do padrão Idempotent Consumer com exemplos práticos.
- Exactly-Once Semantics are Possible: Here’s How Kafka Does It — Artigo técnico da Confluent sobre semântica exactly-once e idempotência em Kafka.
- Idempotency in Event-Driven Systems - Martin Fowler — Padrão de Idempotent Receiver por Martin Fowler, com discussão teórica e exemplos.
- Redis as a Deduplication Cache - Redis Documentation — Guia prático sobre uso de Redis para deduplicação em consumidores idempotentes.
- Outbox Pattern: Reliable Microservices Messaging - AWS Prescriptive Guidance — Padrão Outbox para garantir unicidade de eventos na origem.