trilha

Tecnologias/03 - Filas/01 - Kafka7 min

Garantias de Entrega e Idempotência

Conceito

O problema é antigo e não tem solução perfeita:

A rede pode perder a mensagem ou perder a confirmação. Quem envia não distingue os dois casos. Reenviar pode duplicar; não reenviar pode perder.

Não há como escapar disso — só como escolher qual erro você prefere.

Os três níveis

Garantia Como se obtém Risco Quando serve
at-most-once confirma antes de processar perde métrica, telemetria, log
at-least-once confirma depois de processar duplica o padrão de quase tudo
exactly-once transação coordenada complexidade e latência fluxos internos ao Kafka

Em Kafka isso é literalmente onde você coloca o commit:

// at-most-once
commit(offset); processar(msg)   // se cair no meio: mensagem perdida

// at-least-once  ← o certo em 95% dos casos
processar(msg); commit(offset)   // se cair no meio: mensagem reprocessada

"Exactly-once" — o que ele é e o que ele não é

Kafka tem EOS (exactly-once semantics) de verdade, com duas peças:

1. Produtor idempotente (enable.idempotence=true, padrão desde o 3.0) Cada produtor recebe um PID e numera as mensagens por partição. O broker descarta duplicatas de retry. Isso resolve o caso "enviei, não recebi o ack, mandei de novo" — e, de bônus, preserva a ordem mesmo com várias requisições em voo.

2. Transações (transactional.id, read_committed) Permitem escrever em várias partições/tópicos e commitar o offset de consumo de forma atômica:

cl.BeginTransaction()
  // ... produz N mensagens de saída ...
  // ... e commita o offset das mensagens de entrada ...
cl.EndTransaction(ctx, kgo.TryCommit)   // tudo ou nada

Consumidores com isolation.level=read_committed não veem mensagens de transações não confirmadas. É o que faz o Kafka Streams oferecer processing.guarantee=exactly_once_v2 com uma linha de configuração.

O limite que quase todo material omite

EOS só vale para efeitos dentro do Kafka. Se o seu consumidor grava num Postgres, chama uma API de pagamento ou envia um e-mail, esse efeito não participa da transação Kafka. Você pode ter exactly-once no log e cobrar o cliente duas vezes.

Ou seja: para o mundo real, o consumidor precisa ser idempotente. Não tem atalho.

Idempotência: os quatro padrões que funcionam

1. Chave natural + upsert — o mais simples e o melhor:

INSERT INTO pedidos (id, status, atualizado_em) VALUES ($1, $2, now())
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, atualizado_em = now();

Reprocessar produz o mesmo estado final. Ver Idempotent Operations.

2. Tabela de deduplicação — quando o efeito não é um upsert:

BEGIN;
  INSERT INTO mensagens_processadas (id_mensagem) VALUES ($1);  -- PK: falha se repetido
  -- ... o efeito colateral, na MESMA transação ...
COMMIT;
-- violação de unique = já processada; ignore e commite o offset

Também chamado de inbox pattern. Guarde só o id e um timestamp, com política de expiração.

3. Operação naturalmente idempotente — reescreva a operação, não a infraestrutura:

❌ UPDATE contas SET saldo = saldo - 100      (aplicar 2× debita 200)
✅ INSERT INTO lancamentos (id_evento, conta, valor) VALUES (...)  -- PK evita duplicata
   -- saldo é a soma dos lançamentos, ou é atualizado a partir deles

Trocar "modificar um total" por "registrar um fato com id" é a mudança mais poderosa desta nota — é a mesma ideia de event sourcing e de estado derivado.

4. Guarda por versão / número de sequência — para "só aplique se for mais novo":

UPDATE pedidos SET status = $2, versao = $3
 WHERE id = $1 AND versao < $3;   -- evento antigo reentregue não sobrescreve o novo

Resolve reentrega fora de ordem, que é o caso que os outros três não cobrem.

E do lado do produtor: o problema da escrita dupla

Gravar no banco e publicar no Kafka são duas operações sem transação comum:

✅ grava pedido       ✅ grava pedido        ❌ falha
❌ publica (crash)    ✅ publica             ✅ publicou
→ evento nunca sai    → certo               → evento sem dado

A solução é o outbox pattern (1. Mensageria e Desacoplamento): grave o evento numa tabela outbox na mesma transação do dado, e deixe um publicador (ou o Debezium lendo o WAL) enviá-lo depois. A atomicidade volta a ser local, e a publicação fica at-least-once — o que o consumidor já tolera.

Trade-offs

Idempotência custa estado. Tabela de dedupe cresce e precisa de expiração; upsert exige uma chave natural que às vezes você não tem; versionamento exige que o produtor emita versão.

EOS custa latência e throughput. Transações Kafka adicionam coordenação e obrigam o consumidor a read_committed (que só lê até o last stable offset). Para fluxo Kafka→Kafka, o custo é aceitável e o ganho é real. Para Kafka→banco, você paga o custo e ainda precisa de idempotência — o pior dos dois mundos.

DLQ é obrigatória e é uma dívida. Sem DLQ, uma mensagem envenenada trava a partição. Com DLQ, você criou um lugar onde mensagens vão morrer em silêncio se ninguém alertar sobre ele. DLQ sem alarme é uma pasta de dados perdidos.

O trade-off central: exactly-once de ponta a ponta não existe num sistema distribuído — o que existe é at-least-once + idempotência, que produz o mesmo resultado observável por um caminho mais simples e mais barato. Persegui-lo por configuração é gastar complexidade num lugar onde o design resolveria.

Exemplo prático

Consumidor completo em Go: at-least-once, idempotente, com retry limitado e DLQ.

func consumir(ctx context.Context, cl *kgo.Client, db *sql.DB) {
	for {
		fetches := cl.PollRecords(ctx, 200)
		if fetches.IsClientClosed() { return }

		fetches.EachRecord(func(r *kgo.Record) {
			for tentativa := 1; tentativa <= 3; tentativa++ {
				err := processarIdempotente(ctx, db, r)
				if err == nil {
					return
				}
				if !errors.Is(err, errTemporario) { // erro permanente: nem tenta de novo
					enviarParaDLQ(ctx, cl, r, err)
					return
				}
				time.Sleep(time.Duration(tentativa) * 200 * time.Millisecond) // backoff
			}
			enviarParaDLQ(ctx, cl, r, errEsgotou) // 3 falhas: sai do caminho principal
		})

		if err := cl.CommitUncommittedOffsets(ctx); err != nil {
			slog.Error("commit falhou", "err", err) // reprocessa: é seguro, é idempotente
		}
	}
}

// A idempotência mora aqui, e é o coração do desenho.
func processarIdempotente(ctx context.Context, db *sql.DB, r *kgo.Record) error {
	var ev PedidoPago
	if err := json.Unmarshal(r.Value, &ev); err != nil {
		return fmt.Errorf("payload inválido: %w", errPermanente) // não é retryable
	}

	tx, err := db.BeginTx(ctx, nil)
	if err != nil { return err }
	defer tx.Rollback()

	// 1) dedupe pela chave do evento
	_, err = tx.ExecContext(ctx,
		`INSERT INTO eventos_processados (id_evento) VALUES ($1)`, ev.IDEvento)
	if isUniqueViolation(err) {
		return nil // já processado: sucesso silencioso
	}
	if err != nil { return err }

	// 2) o efeito, com guarda de versão para reentrega fora de ordem
	if _, err := tx.ExecContext(ctx,
		`UPDATE pedidos SET status = $2, versao = $3
		  WHERE id = $1 AND versao < $3`, ev.PedidoID, ev.Status, ev.Versao); err != nil {
		return err
	}
	return tx.Commit()
}

Quatro propriedades desse consumidor, e cada uma cobre um modo de falha diferente:

  1. Commit depois do trabalho → nunca perde (at-least-once).
  2. Dedupe transacional → reprocessar não duplica o efeito.
  3. Guarda de versão → reentrega fora de ordem não sobrescreve estado novo.
  4. DLQ com retry limitado → uma mensagem ruim não trava a partição.
Alerta de DLQ é parte da entrega

dlq_messages > 0 deve gerar alarme. Uma DLQ que ninguém observa é indistinguível de perder a mensagem — com o agravante de que você acredita estar protegido.

Aprofundamento aplicado

Exactly-once precisa de uma fronteira explícita

Uma transação Kafka pode coordenar produção em tópicos e progresso de consumo dentro do mecanismo suportado. Uma chamada HTTP a uma cobrança externa não entra automaticamente nessa atomicidade. Cada sistema externo precisa participar por protocolo ou ter idempotência e reconciliação próprias.

Identidade do evento e identidade da tentativa

eventoId deve permanecer estável entre tentativas do mesmo fato. Gerar um novo UUID em cada retry impede o consumidor de reconhecer repetição. Em contrapartida, reutilizar o mesmo ID para fatos diferentes pode descartar trabalho legítimo.

Exercício: desenhe as falhas antes da escrita no destino, depois dela e antes do commit do offset. Resultado esperado: o caso intermediário provoca reentrega e a chave idempotente protege o efeito, com retenção suficiente para o período de replay.

Ao reprocessar meses de histórico, uma tabela de deduplicação retida só por sete dias não cobre toda a operação. Replays podem exigir uma projeção nova ou outro protocolo.

Referência: garantias de processamento em Streams.

Relacionado


Parte de MOC Kafka · Semântica de entrega (Kafka)

Buscar

Busca por título, seção e texto das notas