03 - Exactly-once
video: https://youtu.be/... no frontmatter da nota.Exactly-once
Exactly-once é a garantia de entrega mais forte e mais mal compreendida da mensageria: cada mensagem produz exatamente um efeito, nem perdido, nem duplicado. A promessa parece natural e o cumprimento é caro, porque exige coordenação atômica entre os pontos onde a mensagem vive: o produtor que publica, o broker que retém e o consumidor que processa. Em produção, a garantia existe de forma completa apenas dentro de sistemas que oferecem mecanismos de transação e deduplicação, como o par de transações e o idempotent producer do Kafka, e o limite prático é conhecido: exactly-once de ponta a ponta, atravessando serviços e bancos distintos, não é entregue por nenhum broker, é construído pela aplicação com idempotência. Para o arquiteto, o tema é decisão econômica: entender onde a garantia nativa vale o custo, onde at-least-once com idempotência entrega o mesmo resultado por menos, e reconhecer o modo de falha específico de quem assume exactly-once onde ele não existe.
Conceito e o custo real da promessa
A semântica formal pede que cada mensagem seja processada exatamente uma vez, combinando as duas propriedades que as garantias anteriores separam: nenhuma perda, do at-least-once, e nenhuma duplicata, do at-most-once. É a interseção das duas garantias, e a dificuldade está exatamente na conjunção.
O custo vem da mecânica necessária. Garantir nada perdido pede confirmação; garantir nada duplicado pede que confirmação e processamento sejam atômicos; e a atomicidade entre processamento do consumidor e confirmação ao broker é, formalmente, um problema de consenso distribuído. Cada alternativa para resolvê-lo, transação no broker, transação entre broker e destino, ou deduplicação na aplicação, adiciona latência, estado e modos de falha próprios.
O efeito prático sobre o desempenho é mensurável. Pipelines Kafka com transações e leitura de committed apenas operam com throughput menor e latência maior que o mesmo pipeline em at-least-once, e a diferença cresce com o tamanho da transação. O preço é justificável quando a duplicata custa caro e não pode ser evitada mais barato, e desperdício puro quando uma chave de idempotência resolveria.
A dificuldade fundamental tem nome na literatura: é um caso de consenso entre o consumidor e o broker sobre se o processamento aconteceu. Não existe mensagem mágica que confirme o efeito sem custo; alguém precisa lembrar o que já foi feito, e essa memória precisa estar no mesmo domínio de falha do efeito. Toda implementação de exactly-once é, no fundo, uma resposta para onde essa memória mora e quanto ela custa consultar e atualizar.
Vale distinguir exatidão declarada de exatidão útil. Um sistema pode ser formalmente exato no transporte e ainda assim produzir efeitos errados, se a lógica de negócio não for determinística; e pode ser formalmente fraco no transporte e produzir efeitos exatos, se a aplicação deduplica. O que interessa ao negócio é o efeito, e é por isso que a discussão técnica correta começa pelo efeito e caminha para trás até o transporte.
Funcionamento no Kafka: idempotent producer e transações
O Kafka entrega a garantia em dois níveis complementares. O primeiro é o idempotent producer: com enable.idempotence ativado, cada produtor recebe um identificador de sessão e cada lote um número sequencial, e o broker rejeita duplicatas causadas por retry do produtor dentro da sessão. É o fim das duplicatas de publicação por timeout, a origem mais comum de repetição, com custo desprezível de desempenho.
O segundo nível são as transações: o produtor agrupa escritas em múltiplos tópicos e partições em uma transação, marca o estado com commit ou abort, e o consumidor configurado com isolation.level read_committed enxerga apenas o que foi commitado. O mecanismo resolve o problema clássico do consumo-processa-produz, em que o consumidor lê, transforma e escreve em outro tópico, e a reentrega produziria resultado duplicado no destino.
O suporte transacional depende do transaction coordinator do cluster e do epoch fencing: cada transactional.id tem um número de época, e um produtor novo com o mesmo id invalida a sessão anterior, impedindo que duas instâncias commitam sobre as mesmas partições. É esse cercamento que dá segurança à reeleição de produtores, e é também o mecanismo por trás do aborto em cascata quando ids colidem por erro de configuração.
No lado da leitura, o isolation read_committed funciona com marcações: o broker expõe o último offset estável do tópico, e o consumidor filtra registros pertencentes a transações abortadas ou em andamento. O custo é um atraso de visibilidade proporcional à duração das transações abertas, o que reforça a recomendação de transações curtas e frequentes em vez de lotes gigantes.
O bloco a seguir mostra a sequência transacional completa:
# Pipeline Kafka com exatamente uma vez dentro do cluster
produtor = KafkaProducer(
enable_idempotence=True, # sessão e sequência por produtor
transactional_id="enriquecedor-pedidos-1",
)
produtor.init_transactions()
produtor.begin_transaction()
try:
for evento in lote:
enriquecido = enriquecer(evento) # transformação
produtor.send("pedidos.enriquecidos", enriquecido)
offsets_para_commit[evento.partition] = evento.offset + 1
produtor.send_offsets_to_transaction( # offset no mesmo commit
offsets_para_commit, group_metadata
)
produtor.commit_transaction() # tudo ou nada
except Exception:
produtor.abort_transaction() # lote descartado
# Consumidor lê com isolation.level=read_committed e vê
# apenas lotes commitados; reprocesso não duplica o destino
| Mecanismo | Escopo da deduplicação | Custo | Cobertura |
|---|---|---|---|
| Idempotent producer | Retry do produtor, por sessão | Mínimo | Publicação |
| Transações Kafka | Pipeline interno ao cluster | Throughput e latência | Consumo-processa-produz |
| Dedup por janela (SQS FIFO) | Envelopes idênticos em 5 minutos | Baixo | Publicação e entrega |
| Idempotência na aplicação | Todo efeito colateral | Lógica e armazenagem | Ponta a ponta |
O limite: pipeline versus ponta a ponta
A garantia nativa termina na borda do sistema que a oferece. Dentro do cluster Kafka, transações cobrem o ciclo de ler, transformar e escrever; no momento em que o consumidor chama um banco externo, uma API de pagamento ou dispara um e-mail, nenhuma configuração do broker torna aquela chamada atômica com o commit de offset. O resultado é a verdade incômoda do tema: exactly-once de ponta a ponta é propriedade da aplicação, e não do transporte.
O modo clássico de quebra: consumidor transacional no Kafka lê um evento, envia cobrança ao gateway, e o processo morre antes do commit. O lote aborta, o evento é relido pelo próximo consumidor, e a cobrança é enviada de novo. Tudo dentro do Kafka foi formalmente exato, e o efeito externo duplicou. A defesa segue sendo a chave de idempotência no consumidor, idêntica à do at-least-once.
Esse é o argumento de ponta a ponta aplicado à mensageria: as propriedades do sistema final só podem ser garantidas nos extremos que conhecem o significado do dado, e o transporte é cego ao efeito. Nenhum flag no broker sabe que o evento 42 significa cobrar o cartão do cliente; só a aplicação sabe, e só a aplicação pode garantir que a cobrança aconteça uma vez.
Outro limite frequente é a janela de retenção do estado transacional. Coordenadores de transação, prazos de timeout e log de transação adicionam superfície operacional, e configurações erradas de transactional.id em réplicas do mesmo serviço produzem abortos em cascata. Exactly-once nativo tem runbook, e não é só um flag.
Há exceções onde a ponta se estende de forma confiável: sinks que suportam transação de verdade, como conectores que escrevem no mesmo cluster Kafka ou destinos com commit em duas fases. Um sink que transaciona offset e gravação no mesmo banco é o caso mais bem resolvido, pois a atomicidade fica inteira dentro de um sistema único. Quando a atomicidade do destino é real, a garantia atravessa a fronteira com propriedade; quando não é, a idempotência da aplicação é a única saída.
A consequência de desenho é mapear a fronteira da garantia em cada pipeline, com um diagrama simples: onde as transações valem, onde a idempotência defende e onde a duplicata é absorvida por natureza do destino. Esse mapa, mantido junto ao código, é a diferença entre um pipeline com garantia real e um pipeline com uma flag ativada e fé de que a garantia é de ponta a ponta.
Quando at-least-once com idempotência é suficiente
A decisão racional compara o custo do mecanismo com o custo do risco que ele elimina. Na maioria dos fluxos de negócio, a combinação at-least-once com chave de idempotência no mesmo commit do efeito produz o mesmo resultado observável de exactly-once, com infraestrutura trivial e complexidade confinada ao consumidor. É o desenho chamado de effectively once, e domina a indústria por um motivo simples: funciona.
A comparação de custo raramente é próxima. A defesa aplicacional é uma checagem e um insert a mais no commit que o consumidor já faz; as transações nativas somam coordenação, marcação de offsets via transação, leitura filtrada e superestrutura operacional. Quando as duas acabam sendo necessárias, como é comum em pipelines com efeitos externos, pagar pelas transações e manter a idempotência é o cenário mais caro possível, e é o que acontece quando a garantia nativa é adotada sem mapear o fluxo completo.
Exactly-once nativo ganha o teste quando o pipeline é interno e fechado, como agregações Kafka Streams, sinks que suportam transação e reconstrução de estado a partir do log. Ali, as transações evitam duplicação em cada estágio, e o custo é pago uma vez, dentro de um sistema que a suporta de ponta a ponta.
| Alternativa | Complexidade | Escopo efetivo | Quando escolher |
|---|---|---|---|
| Transações Kafka | Alta, com coordenação | Dentro do cluster | Pipeline fechado de alto valor |
| Idempotência aplicacional | Baixa, no consumidor | Ponta a ponta | Efeitos externos e reprocessos |
| Dedup por janela no broker | Baixa, configurável | Publicação e entrega | Cortar ruído de retry |
| Commit local do consumidor | Mínima | Efeito e offset juntos | Destino transacional próprio |
A heurística de decisão cabe em três perguntas. O efeito externo escapa do cluster? Se sim, idempotência na aplicação é inevitável, e exactly-once nativo não resolve o problema todo. O pipeline é fechado e de alto volume? Se sim, transações pagam o próprio custo. A duplicata é aceitável por natureza do negócio, como em projeções que se corrigem sozinhas? Se sim, at-least-once simples basta, e a garantia forte é desperdício.
| Cenário | Recomendação | Motivo |
|---|---|---|
| Pipeline Kafka Streams interno | Transações nativas | Escopo fechado, custo pago uma vez |
| Consumidor grava em banco próprio | At-least-once + idempotência | Commit local cobre efeito e offset |
| Consumidor chama gateway de pagamento | At-least-once + idempotency key | Efeito externo só se protege na origem |
| Projeção de leitura que se reconstrói | At-least-once simples | Duplicata é inofensiva e autocalibrante |
| Ingestão em data lake com commit por partição | Transações ou dedup por lote | Escopo fechado e janelas grandes |
A tabela condensa a prática da indústria: exactly-once nativo é nichado, e a combinação de garantia fraca com defesa de aplicação é a norma. Times que invertem a ordem, começando pelas transações, frequentemente descobrem que ainda precisam da idempotência, e pagam os dois custos.
Há ainda o custo cognitivo, que costuma ficar fora da conta. Transações adicionam um modelo mental novo ao time, com estados de transação, timeouts e debug de lotes abortados, e esse modelo precisa ser dominado por todo mundo que toca o pipeline. At-least-once com idempotência usa apenas conceitos que o time já domina de bancos de dados, e essa familiaridade reduz incidentes causados por mal-entendido de configuração.
Erros comuns
- Assumir exactly-once de ponta a ponta a partir de transações do broker: a garantia nativa termina na fronteira do cluster, e o efeito externo segue vulnerável.
- Reutilizar transactional.id entre instâncias concorrentes: o fence mata a sessão da outra réplica, e lotes inteiros abortam em produção sem causa aparente no código.
- Ler com read_uncommitted em pipeline transacional: o consumidor vê lotes em andamento e abortados, reintroduzindo exatamente o problema que as transações resolvem.
- Transações com lotes gigantes: timeout de transação estoura, o coordenador aborta tudo, e o lag cresce em degraus com picos periódicos de retrabalho.
- Trocar idempotência no consumidor por flag de transação em efeito externo: a cobrança duplicada no gateway continua acontecendo, e o incidente é atribuído errado ao broker.
- Ativar idempotence sem transactional_id e acreditar em deduplicação entre reinícios do produtor: a garantia de sessão termina no restart do processo, e a duplicata volta.
- Usar transação para cobrir efeito em banco externo sem commit em duas fases: o lote commita no broker depois que a gravação externa já aconteceu, e o aborto não desfaz o que já saiu.
Boas práticas
- Comece o desenho por at-least-once com idempotência no efeito, e suba para transações nativas apenas onde o pipeline é fechado e o custo é justificado.
- Dedique transactional.id por instância, com numeração estável e gerenciada pela orquestração, evitando colisão entre réplicas do mesmo serviço.
- Padronize isolation.level=read_committed em todo consumidor de tópicos transacionais, como configuração de plataforma e não como decisão por aplicação.
- Meça o custo real das transações no seu volume, comparando throughput e latência com e sem a garantia, e registre a medição no ADR da decisão.
- Mantenha a chave de idempotência no consumidor mesmo com transações nativas, pois ela é a única defesa que cobre efeitos externos e reprocessos operacionais.
- Documente explicitamente o escopo da garantia em cada fluxo, distinguindo exatidão no transporte de exatidão no efeito, que é a confusão que gera os incidentes do tema.
- Configure transaction.timeout coerente com o pior lote, e monitore aborts e tempo médio de transação como indicadores de primeira classe do pipeline.
- Em migrações entre garantias, teste o caminho de falha deliberadamente, matando o consumidor no meio do lote, para confirmar que a duplicata é absorvida como previsto.