Blog

Exactly-once é mentira: o que fazer com isso.

Em toda discussão sobre fila alguém pergunta se o broker garante entrega exatamente uma vez. E sempre existe uma página de documentação que parece dizer que sim.

Não existe, no sentido que a pergunta quer dizer. E a boa notícia é que isso não importa, desde que você entenda o que está sendo prometido de verdade.

Por que é impossível

Você manda uma mensagem. A rede cai antes da confirmação chegar.

Você não sabe se ela chegou. Duas opções: mandar de novo, arriscando duplicar, ou não mandar, arriscando perder.

Não existe terceira. Nenhum protocolo resolve isso, porque a informação que decidiria simplesmente não existe do seu lado. Isso é um resultado conhecido de sistemas distribuídos, não uma limitação de implementação que alguém vai consertar na próxima versão.

O mesmo vale do outro lado. O consumidor processa a mensagem e morre antes de confirmar. O broker não sabe se o trabalho foi feito, então reentrega.

As três garantias reais

No máximo uma vez. Confirma antes de processar. Nunca duplica, e perde quando o processamento falha. Serve pra métrica, telemetria, log de acesso: coisas em que perder um evento não muda nada.

Ao menos uma vez. Confirma depois de processar. Nunca perde, e duplica quando a confirmação falha. É o padrão de praticamente todo broker sério, e é o que você deve assumir.

“Exatamente uma vez”. Sempre com aspas. O que existe são mecanismos que dão efeito exatamente uma vez dentro de um domínio fechado, tipicamente quando entrada e saída estão no mesmo sistema transacional. Kafka faz isso com transações entre tópicos. É real e é limitado: assim que o efeito sai desse domínio, para o seu banco, pra uma API externa, pro envio de um e-mail, a garantia acaba.

E o efeito quase sempre sai.

O que resolve de verdade

Pare de procurar entrega exatamente uma vez. Construa processamento idempotente.

A pergunta muda de “como garantir que a mensagem chegue uma vez?” para “o que acontece se ela chegar duas vezes?”. A segunda tem resposta.

Identificador estável na mensagem. Não gerado no momento do envio: derivado do fato.

// frágil: cada publicação gera um id diferente
{ id: randomUUID(), type: "payment.approved", paymentId }

// robusto: o mesmo fato produz o mesmo id
{ id: `payment.approved:${paymentId}`, type: "payment.approved", paymentId }

Constraint no banco, não verificação na aplicação.

create table processed_message (
  message_id   text primary key,
  processed_at timestamptz not null default now()
);

Verificar com SELECT e depois inserir tem uma corrida no meio, e essa corrida acontece exatamente quando duas cópias da mensagem chegam em paralelo, que é o caso que você está tentando cobrir.

A marcação junto com o efeito, na mesma transação.

await db.transaction(async (tx) => {
  await tx.insert("processed_message", { message_id: msg.id });  // falha se repetido
  await tx.insert("ledger_entry", entries);                      // o efeito
});

Se a transação der rollback, os dois somem e a reentrega processa normalmente. Se der commit, os dois existem e a reentrega bate na constraint.

Separar as duas escritas reintroduz o problema com passos extras.

Quando o efeito é externo

O caso difícil: o efeito não está no seu banco. Uma cobrança num gateway, um e-mail, uma chamada pra API de um parceiro.

Aí a idempotência precisa existir do outro lado. É pra isso que serve o cabeçalho de chave de idempotência das APIs de pagamento, e é por isso que a chave precisa ser derivada do fato e não gerada por tentativa, como escrevi em idempotência.

Se o outro lado não oferece idempotência, você tem um problema sem solução limpa. As mitigações: registrar a intenção antes de chamar, consultar o estado remoto antes de repetir, e reconciliar periodicamente. Nenhuma é perfeita, e reconciliação é o que fecha a lacuna na prática.

Ordem: o outro mito

Junto com entrega exatamente uma vez vem a expectativa de ordem garantida.

Broker entrega em ordem dentro de uma partição, com um consumidor. Assim que você paraleliza pra ganhar vazão, a ordem entre partições acaba.

Duas saídas.

Particione por chave de agregado. Todos os eventos de um pedido vão pra mesma partição, então a ordem dentro daquele pedido está preservada. A ordem global não, e quase nunca importa.

Ou torne o consumidor tolerante a desordem, usando número de versão. Se chegar a versão 5 e você está na 3, guarde e espere. Se chegar a versão 2 e você está na 5, descarte.

O segundo é mais robusto e mais trabalhoso. O primeiro cobre a maioria dos casos.

O teste que revela tudo

Um teste, e ele derruba mais implementação do que parece:

it("processa o mesmo evento duas vezes sem efeito duplicado", async () => {
  const event = paymentApprovedFixture();

  await Promise.all([handle(event), handle(event)]);

  const entries = await ledger.entriesFor(event.paymentId);
  expect(entries).toHaveLength(2);   // as pernas de uma transação só
});

O Promise.all é o detalhe. Chamar duas vezes em sequência passa em código com corrida. Em paralelo é onde o bug aparece.

Se o seu consumidor nunca passou por esse teste, ele provavelmente duplica sob carga, e você ainda não descobriu.

← Voltar pro blog