en
· 4 min de leitura

Exactly-once é uma promessa que ninguém cumpre

Entregar e processar são promessas diferentes. At-least-once mais consumidor idempotente é a garantia que você realmente tem.

system-designengineering

A cada poucos meses um broker, um processador de streams ou uma fila gerenciada anuncia entrega exactly-once. Toda vez, a letra miúda diz algo bem mais estreito. Parei de ler a manchete e passei a ler a letra miúda, porque a manchete é promessa e a letra miúda é onde mora a engenharia.

Entregar não é processar

Existem duas promessas diferentes escondidas na mesma frase. Entrega exactly-once significa que a mensagem chega ao consumidor uma vez. Processamento exactly-once significa que o efeito da mensagem acontece uma vez: a linha é inserida uma vez, o e-mail é enviado uma vez, o saldo é debitado uma vez. O fornecedor vende a primeira. Você precisa da segunda. E a primeira não entrega a segunda, porque seu consumidor pode morrer depois de fazer o trabalho e antes de avisar o broker que terminou.

Os dois generais, sem a matemática

Dois generais em colinas opostas precisam atacar ao mesmo tempo. Só se comunicam por mensageiro, e mensageiros são capturados. O general A manda 'ataque ao amanhecer'. Chegou? A só sabe quando B confirmar. A confirmação de B chegou? B só sabe quando A confirmar a confirmação. Não existe um número finito de mensagens depois do qual os dois têm certeza.

Esse é o problema inteiro, e ele se aplica a um consumidor dizendo ao broker 'processei a mensagem 4712'. O ack pode se perder. O broker então precisa reentregar (at-least-once) ou torcer pelo melhor (at-most-once). Não existe terceira opção sobre uma rede que perde pacotes. Exactly-once é algo que você constrói por cima, não algo que a rede te dá. O ack é a parte difícil, sempre.

Como o effectively-once funciona na prática

O padrão que funciona é sem graça: entrega at-least-once mais consumidor idempotente. O broker pode reentregar. O consumidor torna a reentrega inofensiva. Juntos produzem effectively-once, que é o nome honesto da garantia que todo mundo quer.

Idempotente quer dizer que rodar a mesma mensagem duas vezes deixa o sistema no mesmo estado de rodar uma vez. Algumas operações são naturalmente idempotentes: marcar status como enviado, fazer upsert por chave primária. A maioria não é: incrementar contador, acrescentar linha, disparar notificação, cobrar cartão. Para essas você precisa de uma chave de deduplicação.

A chave de dedup é um identificador estável da intenção, não da tentativa de entrega. Se o produtor gera um UUID novo a cada retry, você tem uma chave para nada. O id do pedido mais o tipo do evento é uma boa chave. O id que o broker atribui à mensagem muitas vezes não é, porque alguns brokers geram um novo na reentrega.

Guarde a chave na mesma transação de banco que o efeito colateral. Insert em processed_events e update do saldo numa transação só. Se o insert falhar na unique constraint, o trabalho já foi feito, então dá o ack e segue. Se a transação falhar, nada aconteceu, então deixa o broker tentar de novo. Esse é o único lugar onde 'mesma transação' não é opcional.

A janela de retenção é uma decisão de design

Você não pode guardar chaves de dedup para sempre, e é aqui que a maioria das implementações relaxa. A janela precisa ser maior que a reentrega mais longa possível. Isso inclui a política de retry do broker, replays da dead letter queue e o humano que reprocessa as mensagens da semana passada durante um incidente. Sete dias é um ponto de partida comum; já vi 24 horas darem errado na primeira vez que alguém reprocessou uma partição. Escolha o número de propósito e anote ao lado da configuração de retry, porque são uma decisão, não duas.

Produtores transacionais cobrem um trecho só

Produtores transacionais e idempotentes no estilo Kafka são reais e úteis. Eles garantem que um retry do produtor não grava o mesmo registro duas vezes no log, e que um ciclo consumir-transformar-produzir comita offsets e saídas de forma atômica. Isso é exactly-once dentro do log. Não diz nada sobre o momento em que seu consumidor chama uma API de pagamento, grava no Postgres ou envia um e-mail. Quando o efeito sai da fronteira transacional do broker, você volta para at-least-once mais idempotência. A maioria dos efeitos reais vive fora dessa fronteira.

Então minha regra é simples. Assuma que toda mensagem vai chegar pelo menos duas vezes, e às vezes fora de ordem. Faça a segunda chegada ser um no-op por construção. Coloque a chave de dedup e o efeito na mesma transação. Escolha uma janela de retenção maior que qualquer replay que você vá rodar. Só então leia a página de exactly-once do fornecedor, como uma boa otimização do trecho que ela realmente cobre.

Quando um time me diz que o sistema é exactly-once, faço uma pergunta: o que acontece se o consumidor morrer entre o commit no banco e o ack? Se a resposta for dar de ombros, a resposta é 'duas vezes'.