Upserts idempotentes e a ilusão de exactly-once

Entrega exactly-once de mensagens é mentira em que todos concordam em fingir. Sistemas honestos assumem entrega at-least-once e usam upserts idempotentes no Postgres, chaves de dedup e restrições unique para tornar duplicatas seguras.

Engenharia6 min de leitura
PostgresIdempotênciaUpsertSistemas distribuídosFilas de mensagens
Compartilhar

O webhook disparou duas vezes. Stripe retenta em timeout; o handler não tinha chave de idempotência. Duas linhas de assinatura, dois emails de boas-vindas, reconciliação financeira defasada em um MRR. O engenheiro disse que a fila prometia exactly-once. A fila mentiu — ou melhor, fez marketing. Upserts idempotentes no Postgres e tabelas de dedup são como sistemas em produção sobrevivem à verdade: redes retentam, consumidores crasham no meio do handler, e at-least-once é a única garantia de entrega pela qual vale projetar.

Processamento exactly-once exige entrega exactly-once mais handlers idempotentes mais transactional outbox mais infraestrutura cooperativa — um stack que a maioria das equipes implementa parcialmente e assume por completo. Upserts idempotentes colapsam o problema em: se esta mensagem chegou antes, o estado do banco não muda; se não, aplica uma vez.

Garantias de entrega na prática

GuaranteeReality
At-most-onceMensagens podem se perder — escolha rara
At-least-onceMensagens podem duplicar — padrão para filas, webhooks, Kafka
Exactly-onceExige consumidores idempotentes + dedup + muitas vezes semântica transacional — "efetivamente uma vez"

Projetar para at-least-once. Tornar efeitos idempotentes. Medir duplicatas em métricas — se zero para sempre, a observabilidade pode estar errada.

Exactly-once é resultado que se constrói, não checkbox no broker.

Chaves de idempotência na camada de aplicação

Cliente ou mensagem carrega chave estável:

Idempotency-Key: stripe_evt_1A2b3C...

Lógica do handler:

  1. Begin transaction
  2. INSERT INTO processed_events (idempotency_key) VALUES ($1) ON CONFLICT DO NOTHING RETURNING id
  3. Se não retornar linha → duplicata → commit vazio, return 200
  4. Else aplicar lógica de negócio + commit

Esquema de tabela:

CREATE TABLE processed_events (
  idempotency_key TEXT PRIMARY KEY,
  processed_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  payload_hash    TEXT
);

Política de retenção: manter chaves tempo suficiente para cobrir janela de retry — Stripe retenta por dias; filas internas podem precisar 7–30 dias. Particionar ou TTL de chaves antigas.

Padrões de upsert no Postgres para aplicar estado

INSERT ... ON CONFLICT DO UPDATE — aplicar último estado de forma idempotente:

INSERT INTO subscription_status (stripe_subscription_id, status, updated_at)
VALUES ($1, $2, now())
ON CONFLICT (stripe_subscription_id) DO UPDATE
SET status = EXCLUDED.status,
    updated_at = EXCLUDED.updated_at
WHERE subscription_status.updated_at < EXCLUDED.updated_at;

A cláusula WHERE no update evita que retries obsoletos sobrescrevam estado mais novo — versionamento monotônico ou comparação de updated_at é crítico para entrega fora de ordem.

ON CONFLICT DO NOTHING — primeira escrita vence; duplicatas ignoradas — bom para semântica create-once.

Restrições unique definem o escopo de idempotência

  • Uma linha por ID de evento externo
  • Uma linha por (user_id, operation_type, business_key)
  • Índices unique parciais para padrões soft-delete

Design de índices intersecta padrões de índices compostos — buscas de idempotência são point queries em colunas chave; primary key ou unique index obrigatório.

Webhooks, outbox e dupla publicação

Webhooks de entrada: chave de idempotência do ID de evento do provedor.

Eventos de saída: padrão transactional outbox — escrever linha de negócio e linha outbox na mesma transação; publisher lê outbox. Duplicatas do publisher possíveis — consumidores continuam idempotentes.

BEGIN;
  UPDATE orders SET status = 'paid' WHERE id = $1;
  INSERT INTO outbox (event_id, type, payload) VALUES ($1, 'OrderPaid', ...);
COMMIT;

Consumidor processando OrderPaid usa o mesmo event_id na tabela dedup processed_events.

Ilusões exactly-once que causam incidentes

"Nosso SDK trata idempotência." Verificar o que deduplica — muitas vezes só camada HTTP, não a linha de negócio.

Upsert sem guarda monotônica. Segundo retry com payload mais antigo sobrescreve estado de pagamento mais novo.

Colisão de chave de idempotência. Reutilizar chaves entre operações diferentes — chaves devem escopar operação + entidade.

Transações longas segurando locks. Insert de idempotência mais lógica de negócio lenta em uma transação — manter check de dedup rápido, lógica de negócio otimizada.

Deletar linhas dedup agressivamente demais. Retry após janela TTL cria efeitos duplicados.

Testar idempotência antes de produção provar

Bugs de idempotência são invisíveis até entrega duplicada — testar deliberadamente:

Teste de integração double-submit. Enviar mesmo payload de webhook duas vezes; afirmar uma linha, um efeito colateral, segunda resposta 200 sem job de email duplicado enfileirado.

Simulação de crash. Matar consumidor após escrita de negócio mas antes do ack — redeliver mensagem; estado inalterado, sem duplicata.

Entrega fora de ordem. Aplicar evento mais antigo após mais novo; guarda monotônica de updated_at preserva último estado.

Auditoria de colisão de chaves. Revisar construção de chave de idempotência — garantir que operações diferentes na mesma entidade usem namespaces de chave distintos.

Load test de taxa de insert na tabela dedup — chaves de idempotência quentes sob entrega concorrente não devem virar gargalo de contenção de locks. Particionar processed_events por semana se taxa de insert exceder throughput confortável de tabela única.

Falha parcial: quando idempotência não basta

Idempotência previne aplicação duplicada bem-sucedida. Falhas parciais precisam de padrões complementares:

  • Efeitos em duas fases. Reservar com linha pending idempotente; confirmar em segundo passo — primeiro passo duplicado inofensivo.
  • Ações compensatórias. Cobrança idempotente; reembolso também idempotente com chave separada — pensamento de rollback se aplica a fluxos message-driven.
  • Padrão inbox. Armazenar mensagem bruta de forma idempotente; processar em transação separada — replay de processamento sem re-fetch da fila.

Crash do handler após chamada a API externa mas antes do commit dedup é a janela clássica — insert dedup deve estar na mesma transação que a mudança de estado de negócio, ou chamadas externas devem ser idempotentes no provedor (chaves de idempotência Stripe na API, não só webhook).

Como as equipes devem implementar handlers idempotentes no Postgres

Checklist de implementação para consumidores de mensagens e webhooks.

De onde devem vir as chaves de idempotência?

ID de evento do provedor externo para webhooks; UUID gerado pelo cliente para APIs; hash de (operação, entidade, versão) para jobs internos. Chaves devem ser estáveis entre retries da mesma operação lógica.

Uma tabela dedup basta?

Muitas vezes uma processed_events mais restrições unique específicas de domínio. Sistemas de alto volume particionam por data. Tabelas separadas por consumer group se isolamento necessário.

Como isso se relaciona com saga e compensação?

Idempotência previne efeitos forward duplicados. Sagas tratam falha parcial entre serviços — complementares. Transações compensatórias também devem ser idempotentes — reembolsar duas vezes é o mesmo modo de falha que cobrar duas vezes.

A common argument runs the other way

Um argumento frequente sustenta que overhead de idempotência é desnecessário se consumidores são cuidadosos e retries são raros.

Retries não são raros — são garantidos sob at-least-once. Código "cuidadoso" sem dedup falha ao crashar entre efeito colateral e ack — a janela clássica de duplicata. Custo de idempotência é um insert indexado por mensagem; custo de incidente por duplicata é confiança do cliente e horas de finanças.

Key takeaways

  • Projetar para entrega at-least-once; exactly-once se constrói via efeitos idempotentes.
  • Tabela dedup com primary key de chave de idempotência — insert na mesma transação que a escrita de negócio.
  • Upserts precisam de guardas monotônicas — não deixar retries obsoletos sobrescreverem estado mais novo.
  • Testar com double-submit, simulação de crash e entrega fora de ordem antes de produção.
  • Falhas parciais precisam de padrão inbox ou ações compensatórias idempotentes — dedup sozinho insuficiente.
  • Handlers de webhook usam ID de evento do provedor como chave; reter chaves durante janela de retry.
  • Transactional outbox para eventos de saída; consumidores continuam deduplicando.

Conclusion

Entrega exactly-once é história reconfortante que brokers contam e engenheiros repetem até webhooks duplicados atingirem produção. Upserts idempotentes no Postgres tornam duplicatas visíveis e inofensivas — uma chave, um efeito, retries bem-vindos.

Auditar handlers de webhook e fila de maior volume: há insert de chave dedup na mesma transação que a escrita de negócio? Insert faltando é idempotência faltando — corrigir antes da próxima tempestade de retries.

Artigos relacionados

Paleta de comandos

Pesquise um comando para executar...