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.: DELETE em 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