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.
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:
- Commit depois do trabalho → nunca perde (at-least-once).
- Dedupe transacional → reprocessar não duplica o efeito.
- Guarda de versão → reentrega fora de ordem não sobrescreve estado novo.
- DLQ com retry limitado → uma mensagem ruim não trava a partição.
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
- 2. Kafka
- 3. Partições, Offsets e Consumer Groups
- 1. Mensageria e Desacoplamento
- Idempotent Operations
- Idempotência e Ordenação
- 6. Transações e ACID
- 3. Transações, Replica Set e Durabilidade
Parte de MOC Kafka · Semântica de entrega (Kafka)