githublinkedinemail
← /articles

O mito do "exactly once"

Exactly-once não existe na rede. O que existe é at-least-once com idempotência. Pare de perseguir mito de marketing — projete para a realidade e durma à noite.

4 mindistributed

O mito do "exactly once"

Marketing de fila adora uma frase: "exactly-once delivery".

Toda vez que você lê isso, alguém está mentindo, omitindo, ou usando termos com asterisco do tamanho de um parágrafo.

Spoiler: na rede, exactly-once não existe.


A frase que vendeu muito SaaS

"Our queue guarantees exactly-once delivery."

Aparece em landing page de Kafka, SQS, RabbitMQ, Pulsar, e nas suas alternativas.

Em letras pequenas, sempre tem um caveat. Geralmente do tamanho do produto.

A verdade é incômoda: exactly-once delivery é matematicamente impossível em sistemas distribuídos.

Não é limitação de implementação. É limitação física.


O problema dos dois generais, em português claro

Dois generais precisam atacar uma cidade ao mesmo tempo. Só conseguem se atacarem juntos.

Eles se comunicam por mensageiros que podem ser capturados.

General A manda: "ataque às 6h".

A mensagem chegou em B? A não sabe. Espera confirmação.

B responde: "ok, ataque às 6h".

A confirmação chegou em A? B não sabe. Espera confirmação da confirmação.

E assim infinitamente.

Conclusão formal: não existe protocolo que garanta certeza mútua sobre uma mensagem em um canal não confiável.

Agora troca "general" por "sistema" e "mensageiro" por "rede TCP".

Toda fila, todo broker, toda chamada HTTP, vive nesse problema.


O que pode dar errado entre A e B

Produtor (A)              Broker
   |                         |
   |---- msg ---------------->|
   |                         |  (escreve)
   |<--- ack ----------------|
   |                         |

Quatro coisas podem falhar em qualquer ponto:

  1. A msg não chegou no broker → produtor não recebeu ack → reenvia (duplica se chegou e o ack se perdeu).
  2. A msg chegou, broker escreveu, ack se perdeu → produtor reenvia → duplicado.
  3. A msg chegou, broker caiu antes de escrever → perda.
  4. A msg foi escrita, consumida, mas ack do consumidor se perdeu → será reentregue.

Você tem duas escolhas honestas:

  • at-most-once: nunca duplica, pode perder
  • at-least-once: nunca perde, pode duplicar

Não existe terceira opção no nível da rede.


O que "exactly-once" quer dizer no marketing

Quando alguém diz exactly-once, está se referindo a uma de três coisas:

1. Exactly-once processing

Mensagem pode chegar duas vezes, mas o efeito colateral acontece uma vez.

Isso é resolvido na sua aplicação, com idempotência. Não no broker.

2. Exactly-once dentro de uma fronteira fechada

Kafka faz isso. Dentro do Kafka, mensagem produzida com transação aparece exatamente uma vez nos consumers que leem da mesma transação.

Não atravessa a fronteira: se você consome do Kafka e escreve no Postgres, o exactly-once acabou.

3. Mentira pura

Acontece. Muito.


Kafka "exactly-once" — os caveats

Kafka tem enable.idempotence=true e transações. Funciona, mas só sob regras estritas:

# producer
enable.idempotence=true
acks=all
max.in.flight.requests.per.connection=5
transactional.id=meu-producer-1
producer.beginTransaction();
producer.send(record1);
producer.send(record2);
producer.sendOffsetsToTransaction(offsets, "meu-grupo");
producer.commitTransaction();

Funciona se:

  • produtor, broker e consumer estão todos no Kafka
  • consumer roda em isolation.level=read_committed
  • você não escreve em sistema externo no meio
  • não cruza clusters

Cruzou a fronteira do Kafka? Acabou a garantia.

E "acabou" é sutil: você ainda recebe a mensagem uma vez no consumer, mas se seu consumer falha entre processar e commitar o offset, a próxima leitura traz a mesma mensagem.

Pronto, voltou pra at-least-once.


Todo sistema que você constrói é at-least-once

Pega seu webhook handler, sua fila Sidekiq, seu Kafka consumer escrevendo no Postgres, seu SQS lambda.

Todos têm a mesma estrutura:

1. lê mensagem
2. processa (faz efeito colateral)
3. confirma (ack, commit offset, delete da fila)

Se o passo 3 falhar depois do 2, a mensagem volta.

Você vai processar de novo.

Não tem como evitar isso sem fazer o passo 2 e 3 na mesma transação atômica — e isso só é possível se o efeito colateral for no mesmo sistema do ack.

Spoiler: quase nunca é.


O único padrão honesto: at-least-once + idempotência

A solução de adulto:

  1. Aceite que mensagens podem chegar mais de uma vez.
  2. Faça seu processamento ser idempotente.

Idempotente = aplicar duas vezes tem o mesmo efeito que aplicar uma vez.

Padrão 1: chave de idempotência

class PaymentProcessor
  def charge(payment_id, idempotency_key)
    return if Charge.exists?(idempotency_key: idempotency_key)

    ActiveRecord::Base.transaction do
      Charge.create!(
        idempotency_key: idempotency_key,
        payment_id: payment_id,
        amount: 100
      )
      Stripe::Charge.create(
        amount: 100,
        idempotency_key: idempotency_key
      )
    end
  end
end

Mensagem chegou duas vezes? Segunda vez bate no exists? e sai.

A chave tem que vir do produtor, não do consumer. Se o consumer gera, duas entregas geram chaves diferentes.

Padrão 2: upsert determinístico

INSERT INTO orders (id, user_id, amount)
VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING;

Mesma mensagem, mesma linha, mesmo resultado.

Padrão 3: transactional outbox

Você quer publicar evento + escrever no banco atômicamente?

Não publica direto. Escreve numa tabela outbox na mesma transação:

BEGIN;
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox (event_type, payload) VALUES ('order.created', '{...}');
COMMIT;

Um worker separado lê outbox e publica no broker. Se publica duplicado depois de uma falha, o consumer trata via idempotência.

Você acabou de transformar "exactly-once distribuído" (impossível) em "at-least-once + idempotência" (possível).


Diferença entre senior e junior

Junior:

"O Kafka garante exactly-once, então não preciso me preocupar com duplicatas."

Senior:

"O Kafka pode prometer o que quiser. Eu tenho coluna idempotency_key com índice único e ON CONFLICT DO NOTHING. Mensagem duplicada não faz mal."

Um confia no broker. O outro sabe que confiança nesse nível é negligência.


A arquitetura honesta

Produtor
   ↓
[gera idempotency_key determinístico]
   ↓
Broker (at-least-once)
   ↓
Consumer
   ↓
[checa idempotency_key]
   ↓ não vista        ↓ já vista
processa              ignora
   ↓
escreve resultado + marca chave
(mesma transação)
   ↓
ack

Falha em qualquer ponto:

  • antes do ack: mensagem volta, idempotência detecta
  • depois do ack: nada acontece, já processou

Você acabou de construir "effectively-once". Que é o que o marketing chama de exactly-once, mas honestamente.


Números que importam

Sistema sem idempotência, fila com at-least-once:

  • ~0.01% das mensagens duplicam em condições normais
  • ~5-15% duplicam durante incidente (failover, deploy, retry storm)

Em 10 milhões de eventos/dia:

  • normal: 1.000 duplicatas/dia
  • incidente: até 1,5 milhão de duplicatas

Sem idempotência, isso é 1,5 milhão de cobranças duplas, emails repetidos, pedidos clonados.

Com idempotência, é 1,5 milhão de SELECT em índice único. Custo: irrelevante.


Conclusão

Exactly-once é uma fantasia.

Existe no marketing. Existe dentro de fronteiras muito específicas de produto.

Não existe no seu sistema, que cruza rede, banco, broker, API externa.

A sua escolha não é entre at-least-once e exactly-once.

É entre at-least-once com idempotência e at-least-once sem idempotência rezando pra dar certo.

Pare de caçar exactly-once.

Desenhe para at-least-once e durma de noite.

O mito do "exactly once"
O mito do "exactly once"