Home Comunidade Eventos YouTube Networks Desafios // produtos Academy News CTI | CVE Contato

03 - Exactly-once

#deduplicacao#exactly-once#idempotent-producer#kafka-transactions#pipeline
video explicativo
Video explicativoEspaco reservado para o video desta anotação — adicione 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.