Os dois generais
Dois generais em morros opostos precisam atacar no mesmo horário e só podem se comunicar por mensageiros que atravessam o vale inimigo. O primeiro manda 'atacamos às 6h'. Mas ele não sabe se chegou — então o segundo confirma. Só que o segundo não sabe se a confirmação chegou. E assim por diante, para sempre. Nenhuma quantidade de mensagens produz certeza mútua. É um resultado provado, não uma limitação de tecnologia.
As três semânticas, e o que cada uma custa
At-most-once: confirma a mensagem antes de processar. Se o processo morrer no meio, ninguém reentrega — a mensagem se perdeu, e nada duplica.
At-least-once: confirma depois de processar. Se o processo morrer entre o efeito e a confirmação, a mensagem volta e o efeito acontece de novo.
Exactly-once: seria confirmar e processar como uma coisa só, atomicamente, através da rede. Não é possível.
A impossibilidade não é de engenharia, é matemática: é o problema dos dois generais. Entre dois sistemas que só se comunicam por um canal que pode falhar, não existe protocolo que produza acordo garantido em número finito de mensagens. Qualquer confirmação pode se perder, e a confirmação da confirmação também.
O que os brokers chamam de exactly-once é real, mas tem escopo. O Kafka consegue porque, num fluxo *ler-processar-escrever* inteiramente dentro do Kafka, o avanço do offset e a escrita do resultado entram na mesma transação do próprio Kafka — é um sistema fechado, não uma travessia de rede. No instante em que o efeito sai dali (seu banco, uma API de terceiro, um e-mail), a garantia acaba, e o problema volta a ser seu.
A escolha, portanto, é entre perder e duplicar. Para quase todo efeito de negócio — crédito de saldo, envio de nota, baixa de estoque — perder é inaceitável e duplicar é corrigível. Por isso at-least-once é o padrão de fato, e por isso Idempotência deixa de ser um refinamento e vira pré-requisito.
At-least-once com efeito idempotente
- A chave do evento é registrada e o efeito aplicado na mesma transação
- O processo morre entre o efeito e a confirmação
- O broker reentrega, igual
- A chave já existe: a reentrega não faz nada
A duplicação continua acontecendo — ela apenas deixou de ter consequência. É este o significado prático de 'exactly-once'.
A chave é do evento, e a transação é uma só
Deduplicar exige duas decisões que costumam ser tomadas erradas: qual é a chave, e onde ela é gravada em relação ao efeito.
A chave precisa identificar o evento, não a tentativa — se cada reentrega gerar uma chave nova, a deduplicação nunca encontra nada.
Gravar a chave e aplicar o efeito precisam acontecer na mesma transação. Separados, existe uma janela: marcou e morreu antes de aplicar (perdeu o efeito para sempre, porque a reentrega vai achar a chave e desistir), ou aplicou e morreu antes de marcar (duplica na reentrega). A atomicidade entre os dois é o que faz o mecanismo funcionar.
Quando o efeito não está no mesmo banco — mandar e-mail, chamar API de terceiro — não há transação possível, e a deduplicação vira melhor esforço com uma janela de risco declarada. Nesses casos, o certo é empurrar a idempotência para o outro lado: usar a chave de idempotência que o provedor oferece (o Stripe faz isso) em vez de tentar garantir do lado de cá.
E a tabela de processados precisa de expurgo. Sem TTL, ela cresce para sempre e vira o gargalo que o consumo não tinha. A janela de retenção se define pelo tempo máximo em que uma reentrega ainda é plausível — normalmente dias, não anos.
Como isso aparece na prática
O webhook de pagamento entregue duas vezes
Cenário
O provedor entrega o webhook de pagamento aprovado. O handler credita o saldo e leva 8 segundos para responder; o provedor considera timeout em 5 e reentrega. O saldo é creditado duas vezes.
Como o padrão entra
O id do evento do provedor vira a chave; a inserção na tabela de processados e o crédito acontecem na mesma transação. A reentrega passa a não fazer nada.
Trade-off: Aparece uma tabela nova, com escrita a cada evento e necessidade de expurgo. É custo real de armazenamento e manutenção, trocado pela impossibilidade de crédito duplicado.
A métrica que podia se perder
Cenário
Um coletor recebe milhões de amostras de telemetria por minuto. Deduplicar cada uma exigiria uma tabela de chaves maior que os próprios dados, e os painéis toleram bem uma amostra ausente.
Como o padrão entra
At-most-once assumido de propósito: confirma antes de processar, sem registro de deduplicação, com a perda documentada como decisão.
Trade-off: Contagens ficam levemente subestimadas em cenários de falha, o que inviabiliza usar essas métricas para cobrança. A decisão precisa estar escrita, ou alguém vai construir faturamento em cima delas.
Confiar no exactly-once do broker
Compila, roda, passa no code review. E quebra depois.
A configuração diz exactly-once, então o consumidor aplica o efeito direto, sem registro de deduplicação. A garantia existe — só que o escopo dela termina onde o seu efeito começa.
// "Configuramos exactly-once, entao nao precisa deduplicar."
consumidor.configurar({ entrega: "exactly-once" });
consumidor.escutar(async (msg) => {
// Nenhuma verificacao: confia na garantia do broker.
await creditarSaldo(msg.contaId, msg.valor);
});
// O que a garantia do broker realmente cobre:
// produtor -> broker -> consumidor (dentro do sistema dele)
//
// O que ela NAO cobre — e e onde mora o dinheiro:
//
// 1. creditarSaldo() roda e commita no banco
// 2. o processo morre ANTES de confirmar a mensagem
// 3. o broker, corretamente, reentrega
// 4. creditarSaldo() roda de novo
//
// O saldo foi creditado duas vezes, e o broker cumpriu o contrato:
// ele entregou uma vez para um consumidor que nao terminou.
// Efeito colateral fora do sistema do broker nao entra na garantia.O que quebra
- Ao reiniciar o podMensagens processadas cujo ACK não chegou antes do encerramento são reentregues, e o efeito é aplicado de novo.
- No rebalanceamentoAo redistribuir partições entre consumidores, mensagens em voo voltam para outro consumidor e são processadas duas vezes.
- Na conciliaçãoO saldo diverge de um jeito que não bate com nenhum evento registrado, porque houve dois efeitos para um evento só.
A saída: A garantia do broker cobre o caminho dentro do broker. Efeito colateral fora dele — banco, e-mail, API de terceiro — nunca esteve incluído. Assuma at-least-once e torne o efeito idempotente: chave do evento e efeito na mesma transação.
Onde isto dá errado
Acreditar no rótulo exactly-once
A garantia é real dentro do sistema do broker e termina onde o seu efeito começa. Quem lê o rótulo e dispensa a deduplicação descobre isso num incidente de conciliação, meses depois.
Chave da tentativa em vez do evento
Se a chave for gerada no consumidor a cada recebimento, toda reentrega produz uma chave nova e a deduplicação nunca encontra nada. A chave precisa vir do evento e ser estável entre reentregas.
Marcar e agir em transações separadas
Marcar antes e morrer perde o efeito para sempre — a reentrega acha a chave e desiste. Agir antes e morrer duplica. Só a atomicidade entre os dois elimina a janela.
Tabela de deduplicação sem expurgo
Sem TTL ela cresce indefinidamente, e o índice que garantia a unicidade vira o gargalo do consumo. A retenção se dimensiona pelo tempo máximo em que uma reentrega ainda é plausível.
Deduplicar por conteúdo
Usar um hash do corpo como chave confunde dois eventos legítimos e idênticos — duas transferências do mesmo valor, para a mesma conta, no mesmo segundo — com uma reentrega. Só um identificador do evento distingue os dois casos.
Confirmar antes de processar sem querer
Muitos clientes confirmam automaticamente ao entregar a mensagem ao handler. Isso é at-most-once por acidente: qualquer falha no processamento perde o dado em silêncio, e ninguém escolheu isso.
Chave do evento e efeito na mesma transação
// A unica garantia que atravessa a rede com seguranca e at-least-once.
// "Exactly-once" util se constroi assim: entrega repetida + efeito unico.
async function consumir(msg: Mensagem) {
// 1. A chave e do EVENTO, nao da tentativa: ela precisa ser a mesma
// em todas as reentregas da mesma mensagem.
const chave = msg.id;
// 2. Marcar e agir na MESMA transacao. Se estiverem separados,
// existe uma janela em que um aconteceu e o outro nao.
await db.transacao(async (tx) => {
const novo = await tx.processados.inserirSeAusente(chave);
if (!novo) return; // ja processamos: ignora em silencio
await creditarSaldo(tx, msg.contaId, msg.valor);
});
await msg.confirmar();
}
// Por que nao da para ter exactly-once de verdade:
// depois de escrever no banco e antes de confirmar a mensagem,
// o processo pode morrer. Nenhuma ordem das duas operacoes elimina
// essa janela — so a idempotencia a torna inofensiva.# Tres semanticas, e o que cada uma custa:
#
# at-most-once confirma ANTES de processar
# -> nunca duplica, pode PERDER
# at-least-once confirma DEPOIS de processar
# -> nunca perde, pode DUPLICAR
# exactly-once nao existe entre sistemas distintos
# -> at-least-once + efeito idempotente
def consumir(msg, db):
with db.transacao() as tx:
# INSERT ... ON CONFLICT DO NOTHING: o banco decide quem chegou
# primeiro, sem precisar de lock na aplicacao.
novo = tx.execute(
"INSERT INTO processados (chave) VALUES (%s) "
"ON CONFLICT DO NOTHING RETURNING chave",
(msg.id,),
).fetchone()
if novo is None:
return # reentrega: ja fizemos
creditar_saldo(tx, msg.conta_id, msg.valor)
msg.confirmar()
# A tabela 'processados' precisa de expurgo: sem TTL ela cresce
# para sempre e vira o gargalo que o consumo nao tinha.Quando usar × quando evitar
Use quando
3- Em todo consumo de fila, webhook recebido ou efeito disparado por rede.
- Ao integrar com terceiros, que entregam at-least-once na prática.
- Ao decidir explicitamente se um dado pode ser perdido.
Evite quando
2- Deduplicar quando o efeito já é naturalmente idempotente (um
UPDATEque fixa valor absoluto). - At-most-once para qualquer coisa que envolva dinheiro ou estado do cliente.
Testar o que ficou
6 armadilhas de Garantias de entrega viram perguntas, com o nome do padrão escondido, misturadas com padrões parecidos.
Onde isto aparece de verdade
Você já usa isto, talvez sem saber o nome.
SQS padrão × FIFOA fila padrão é at-least-once assumido; a FIFO oferece deduplicação numa janela de minutos, não para sempre.enable.idempotence do KafkaResolve duplicação do produtor para o broker — dentro do Kafka. O efeito no seu banco continua sendo seu problema.O ACK do TCPO protocolo retransmite quando não recebe confirmação, o que é at-least-once no nível do segmento.
O padrão em uma linha
Sem nome de domínio, sem cerimônia. Só a ideia.
// Entre sistemas, prepare-se para at-least-once.
// Idempotência no consumidor fecha o ciclo.O que ele cobra
1 salto a mais entre a chamada e o efeito.
- At-least-once obriga todo consumidor a ser idempotente para tolerar duplicatas
- Deduplicar de verdade exige guardar os ids já vistos, com estado e janela de retenção
Não vale se: a perda ocasional de uma mensagem é aceitável para o caso — aí at-most-once sai mais barato que qualquer garantia forte.
Onde isto aparece
Continue por aqui
- IdempotênciaExecutar a mesma operação duas vezes tem o mesmo efeito de executar uma: é o que torna o retry seguro. Em rede, onde 'não sei se foi' é resposta comum, é a diferença entre reenviar e cobrar em dobro.
- Transactional OutboxSalvar no banco e publicar um evento são duas operações em dois sistemas — e não há transação que cubra as duas. O outbox transforma o evento numa linha da mesma transação, e deixa a publicação para depois.
- Dead Letter QueueUma mensagem que nunca vai dar certo não pode ficar voltando para a fila: ela bloqueia a ordem, consome CPU e esconde o problema. A DLQ é o desvio para onde vai o que falhou demais — com contexto suficiente para diagnosticar e reprocessar.
- Retry com backoff e jitterFalhas passageiras se resolvem sozinhas se você esperar um pouco e tentar de novo. Mas repetir do jeito errado — sem espera crescente, sem ruído aleatório, sem filtrar o que é repetível — transforma um soluço em uma queda.