Pular para o conteúdo
9 min de leitura

Idempotência de eventos de conversão: como garantir que a mesma venda conte uma vez só

Por Equipe Owiew ·

Por que entrega at-least-once faz a mesma venda ser processada mais de uma vez e como uma chave de idempotência com check-and-set atômico impede a contagem dupla.

Neste artigo

Um relatório de conversão que mostra faturamento maior do que o caixa costuma ter uma origem invisível: o mesmo evento foi processado duas vezes. Não é bug de código, não gera exceção, não aparece em nenhum log de erro. É a consequência natural de sistemas de entrega que reenviam eventos — webhooks, filas, jobs de reprocessamento — batendo contra uma camada de conversão que trata cada recebimento como se fosse único. A venda #8842 chega, é enviada como Purchase; a rede oscila, a plataforma de entrega reenvia, a mesma venda chega de novo e é enviada de novo. Duas conversões, uma compra.

A defesa contra isso tem um nome preciso: idempotência. Uma operação idempotente produz o mesmo resultado quer seja executada uma vez, quer seja executada dez. É a propriedade que separa uma camada de conversão que resiste à realidade da entrega de uma que multiplica receita fantasma a cada reentrega. E ela é frequentemente confundida com deduplicação de canais — que é outra coisa.

Idempotência não é deduplicação Pixel + CAPI#

Vale começar desfazendo a confusão, porque as duas técnicas usam ideias parecidas e resolvem problemas diferentes.

A deduplicação Pixel + CAPI trata do mesmo evento chegando por dois canais distintos: o navegador dispara o Purchase pelo pixel e o servidor dispara o mesmo Purchase pelo CAPI. Um event_id compartilhado diz à plataforma que os dois são a mesma conversão, e ela conta um. É um problema de duplicação entre canais, resolvido do lado da plataforma.

A idempotência de processamento trata do mesmo evento chegando duas vezes pelo mesmo canal: o webhook do pedido pago é entregue, e depois reentregue, e o seu sistema processa os dois. É um problema de reprocessamento, resolvido do seu lado, antes de qualquer envio. Você pode ter deduplicação perfeita entre pixel e CAPI e ainda assim enviar a conversão em dobro, porque foram dois envios server-side idênticos disparados por duas entregas do webhook. Este artigo é sobre esse segundo plano.

O problema: entrega at-least-once#

Quase todo mecanismo de entrega confiável na internet oferece a garantia de at-least-once: o evento é entregue pelo menos uma vez, e sob falha pode ser entregue mais de uma vez. Faz sentido do ponto de vista de quem entrega — melhor mandar de novo do que arriscar perder. As três garantias possíveis são:

  • at-most-once — entrega no máximo uma vez; sob falha, pode perder o evento. Simples, mas inaceitável para conversão (perder venda é perder dinheiro na otimização).
  • at-least-once — entrega pelo menos uma vez; sob falha, pode duplicar. É o padrão de webhooks e filas.
  • exactly-once — entrega exatamente uma vez. É o ideal, e "exactly-once de verdade" não existe de forma pura em sistemas distribuídos. O que se faz é aproximar: entrega at-least-once mais processamento idempotente. O consumidor absorve as duplicatas e o efeito líquido é de uma vez só.

A conclusão é libertadora: você não precisa que a entrega seja perfeita. Precisa que o processamento seja idempotente. É mais barato e mais confiável construir a defesa no consumidor do que exigir garantia impossível do produtor.

A chave de idempotência#

Tudo gira em torno de uma boa chave de idempotência: um identificador que é o mesmo para todas as entregas do mesmo evento de negócio, e diferente para eventos de negócio diferentes.

Uma boa chave é estável e única por evento de negócio. O id do pedido, o id do pagamento, o identificador da transação — valores que a origem gera uma vez e repete em toda reentrega. Uma chave ruim é qualquer coisa gerada no momento do processamento: um uuid novo a cada execução (muda a cada reentrega, não protege nada), um timestamp de recebimento (idem), um hash do payload inteiro (frágil, muda se um campo irrelevante variar entre entregas).

O teste é simples: se o mesmo evento de negócio chegar duas vezes, as duas entregas produzem a mesma chave? Se não, a chave não serve.

Onde a idempotência mora#

A idempotência vive em dois planos, e é útil separá-los.

O plano interno é a sua camada de conversão: antes de enviar qualquer coisa às plataformas, você decide se este evento já foi processado. É aqui que você para o reprocessamento na origem.

O plano da plataforma é o event_id que você envia no CAPI: ele dá idempotência do lado da plataforma, protegendo contra o caso de você enviar dois payloads. É uma segunda linha de defesa, não a primeira. Depender só dela significa gastar chamada de rede, cota de API e latência para enviar algo que você já enviou — e confiar que a plataforma vai deduplicar corretamente. A idempotência interna resolve mais barato, sem a viagem.

Implementar: o registro de processados#

A implementação canônica é um registro de chaves processadas: uma tabela (ou uma loja de chave-valor como o Redis) onde você marca cada chave assim que a trata. Antes de processar, verifica; se já está lá, ignora.

O ponto crítico — e onde a maioria das implementações erra — é a atomicidade. A versão ingênua faz duas operações:

`` existe = SELECT 1 FROM processados WHERE chave = :id se não existe: processa() INSERT INTO processados (chave) VALUES (:id) ``

Sob concorrência, isso falha. Duas entregas do mesmo evento chegam quase juntas, as duas rodam o SELECT, as duas veem "não existe", as duas processam. A janela entre o SELECT e o INSERT é uma condição de corrida clássica, e ela se abre exatamente sob a carga em que a duplicação é mais provável.

A correção é fazer o check e o set numa única operação atômica. Em SQL:

``sql INSERT INTO processados (chave, estado, criado_em) VALUES (:id, 'pending', now()) ON CONFLICT (chave) DO NOTHING; ``

Se a linha foi inserida, este processo é o dono do evento e segue. Se o ON CONFLICT disparou (a chave já existia), outro processo já pegou — este para. No Redis, o equivalente é SET chave valor NX (set if not exists), que devolve sucesso apenas para o primeiro. Em ambos os casos, a decisão "eu processo ou ignoro" é atômica, e a corrida desaparece.

Idempotência com efeitos colaterais#

Marcar "processado" resolve o caso simples, mas há uma armadilha quando o processamento tem efeito colateral externo — como enviar a conversão à plataforma. A pergunta incômoda: você marca antes ou depois de enviar?

  • Marca depois de enviar — se o sistema cair entre o envio e a marcação, a próxima entrega não encontra a marca e envia de novo. Duplicou.
  • Marca antes de enviar — se o sistema cair entre a marcação e o envio, a próxima entrega encontra a marca e ignora. Perdeu.

A saída é um estado intermediário. Insere a chave com estado pending (isso já reserva o evento de forma atômica), tenta o envio, e só então move para sent. Uma entrega que encontra pending sabe que outro processo está no meio do caminho e pode esperar/reenfileirar; uma que encontra sent ignora. Combinado a um padrão de outbox — persistir a intenção de envio na mesma transação do dado de domínio e ter um worker que a drena — você fecha a janela: o envio é registrado atomicamente com o processamento, e um processo de reconciliação reprocessa pending órfãos com segurança, justamente porque o destino é idempotente pelo event_id.

TTL e janela de proteção#

O registro de processados não precisa crescer para sempre. A duplicação acontece dentro de uma janela: os retries de um webhook se esgotam em horas ou dias, não em meses. Guardar as chaves por uma janela que cobre com folga o período de reentrega (por exemplo, alguns dias) é suficiente, e um TTL expira as antigas. O trade-off é claro: janela curta demais reabre a porta para uma reentrega tardia duplicar; janela longa demais só custa um pouco de armazenamento. Na dúvida, prefira a janela mais longa — armazenamento é barato, conversão duplicada é cara.

Idempotência ponta a ponta#

O desenho maduro usa um só identificador atravessando todas as camadas. O id do pedido entra como chave de idempotência no consumidor do webhook, viaja na mensagem da fila, vira a chave do registro de processados, e finalmente é enviado como event_id à plataforma. O mesmo valor protege contra reprocessamento interno e contra duplicação de canal, de ponta a ponta. Quando um identificador de negócio faz esse papel único, cada camada pode ser at-least-once sem medo, porque o efeito combinado é exactly-once.

Erros comuns#

  • Gerar id novo a cada retry — a chave muda, a proteção evapora.
  • Chave não única — usar algo que colide entre eventos distintos faz você descartar conversão legítima como se fosse duplicata.
  • Check-and-set não atômico — SELECT seguido de INSERT perde a corrida sob carga.
  • Confiar só no dedupe da plataforma — funciona às vezes, gasta rede sempre, e não protege efeitos colaterais internos.
  • Janela curta demais — uma reentrega tardia fura a proteção.

Idempotência não é só para webhooks#

Vale generalizar, porque o mesmo mecanismo protege muito além do webhook de pedido. Qualquer ponto do sistema em que um evento pode ser processado mais de uma vez pede a mesma defesa:

  • Reprocessamento de fila — um worker que pega uma mensagem, cai antes de confirmar, e a mensagem volta para a fila e é entregue a outro worker. Sem idempotência, os dois processam.
  • Jobs de reconciliação — uma rotina que varre pedidos e reenvia os que "parecem não ter sido enviados" vai, mais cedo ou mais tarde, reenviar algo que já foi. A idempotência é o que torna essa varredura segura de rodar.
  • Reenvio manual — alguém reprocessa um lote para corrigir uma falha pontual e, sem querer, inclui pedidos que já estavam certos. A chave de idempotência absorve o excesso.
  • Retentativa do cliente — uma requisição de API que o cliente repete por timeout, achando que a primeira falhou, quando na verdade ela chegou.

Em todos esses casos, o padrão é idêntico: uma chave estável de negócio, um registro de processados com check-and-set atômico, e um estado que sobrevive a quedas. Construir esse padrão uma vez, como um componente reutilizável da camada de conversão, é mais barato do que descobrir cada furo individualmente — e cada furo, quando descoberto, já custou conversão duplicada em produção. A idempotência bem-feita é uma propriedade da arquitetura, não um remendo por endpoint.

Idempotência não é sofisticação opcional; é o requisito mínimo para que uma camada de conversão sobreviva ao fato de que eventos chegam mais de uma vez. Construída em torno de uma boa chave, com check-and-set atômico e atenção aos efeitos colaterais, ela transforma uma entrega imperfeita em uma contagem exata — que é, no fim, o único número em que o marketing pode confiar.#

Leituras relacionadas

Nenhum comentário ainda

Seja o primeiro a comentar.

Deixe seu comentário

Entre com sua conta Canverly para comentar. Você pode usar a mesma conta em qualquer site da rede.

Entrar com Canverly