Semântica de entrega em sistemas de mensageria: at-least-once, idempotência e dead-letter queues
Por que o broker entrega mais de uma vez, como o consumidor converte repetição em efeito único com uma tabela de mensagens processadas na mesma transação, e o que fazer com a mensagem que falha sempre.
Por Alexandre Martins · · 8 min de leitura
Um broker de mensagens promete entregar o que recebeu, mas não promete entregar uma única vez. Entre processar uma mensagem e confirmar o processamento existe uma janela de falha, e qualquer queda nela faz o broker entregar a mesma mensagem de novo. A duplicata não é um defeito do broker. É a consequência de uma escolha que prefere repetir a perder.
O artigo sobre o padrão Transactional Outbox tratou do lado do produtor e concluiu que o relay também pode publicar duas vezes. O artigo sobre idempotência em APIs antecipou a resposta para consumidores de fila, com o identificador da mensagem no papel da Idempotency-Key. Falta o resto: o que fazer com a mensagem que falha sempre e como impedir que ela bloqueie as demais.
Como projetar um consumidor que receba a mesma mensagem N vezes, produza o efeito uma única vez e não trave a fila quando uma mensagem não puder ser processada?
As três garantias e onde cada uma falha
A documentação do Kafka, na seção Message Delivery Semantics, define as três garantias. Em at-most-once, o consumidor confirma antes de processar, e uma queda durante o processamento perde a mensagem. Em at-least-once, o consumidor processa e só então confirma, e uma queda entre as duas etapas provoca a reentrega. Em exactly-once, cada mensagem produz efeito uma única vez.
O broker sozinho não entrega exactly-once a um sistema externo. O Kafka oferece o produtor idempotente, que deduplica reenvios por número de sequência, e transações, que cobrem o caminho de Kafka para Kafka. Quando o efeito do consumidor é uma linha em um banco relacional, o broker não participa dessa escrita. A própria documentação indica a saída: guardar o offset, ou o identificador da mensagem, no mesmo lugar do efeito.
Exactly-once fim a fim é uma propriedade do consumidor. O broker entrega at-least-once, e o consumidor converte repetição em efeito único.
Dois modelos de broker
O Kafka é um log particionado. Cada partição é uma sequência imutável de registros com offsets crescentes, e cada grupo de consumidores guarda, por partição, o offset da próxima leitura. A mensagem não é removida ao ser lida: consumir significa avançar o offset. A chave da mensagem decide a partição, e a ordem é garantida dentro dela, então todos os eventos de um pedido são lidos na ordem de produção.
O SQS e o RabbitMQ são filas. A mensagem é entregue a um consumidor, fica invisível para os demais por um prazo (visibility timeout no SQS, 30 segundos por padrão) e é removida quando o consumidor a confirma. Se o prazo expira sem confirmação, a mensagem volta à fila. A documentação do SQS avisa que uma mensagem pode ser entregue mais de uma vez mesmo dentro do prazo de visibilidade. O paralelismo é livre, e a ordem não é garantida.
Os dois modelos coincidem no ponto que interessa: a confirmação é posterior ao processamento, e a queda entre um e outro produz a reentrega.
O consumidor idempotente
O padrão Idempotent Consumer, catalogado por Chris Richardson em microservices.io, resolve a reentrega com uma tabela de mensagens processadas. O consumidor insere o identificador da mensagem nessa tabela na mesma transação que grava o efeito. Se a inserção viola a chave primária, a mensagem já foi processada, e o consumidor a confirma sem fazer nada.
A chave primária tem duas colunas: o identificador da mensagem e o nome do consumidor, porque a mesma mensagem é legitimamente processada por consumidores diferentes. O identificador vem do produtor. Com Transactional Outbox, é a coluna id da tabela outbox, que o Debezium propaga como cabeçalho da mensagem. Sem um identificador do produtor, resta derivar um do conteúdo (hash) ou da posição no broker, e a segunda opção não sobrevive à republicação.
A ordem das operações é a mesma do artigo sobre idempotência: inserir primeiro e tratar o conflito como duplicata, sem SELECT prévio.
Mensagem envenenada e dead-letter queue
Uma mensagem envenenada (poison message) é a que falha a cada tentativa, por payload malformado ou por uma regra de negócio que nunca vai se resolver. Com reentrega indefinida, ela bloqueia a partição inteira no Kafka, porque o offset não avança enquanto ela não for confirmada.
A dead-letter queue (DLQ) é o destino das mensagens que esgotaram as tentativas. No SQS, a redrive policy associa a fila de origem a uma DLQ e define maxReceiveCount, o número de recebimentos sem exclusão a partir do qual a mensagem é movida. No RabbitMQ, o argumento x-dead-letter-exchange redireciona mensagens rejeitadas, expiradas ou excedentes, e o cabeçalho x-death registra o histórico. No Kafka não há DLQ nativa. O cliente publica a mensagem em outro tópico, e o Spring Kafka faz isso com DeadLetterPublishingRecoverer, por padrão no tópico de origem com sufixo -dlt.
A falha transitória (banco indisponível, timeout de rede) merece novas tentativas com backoff. A falha determinística (JSON inválido, esquema desconhecido) não melhora com repetição e deve ir direto para a DLQ.
A DLQ não encerra o problema, ela o adia. Alguém precisa inspecionar, corrigir a causa e reprocessar: o SQS oferece redrive da DLQ para a fila de origem, e no Kafka um consumidor dedicado lê o tópico -dlt e republica. Nos dois casos, a tabela de processadas protege o reprocessamento contra efeito duplo.
Backpressure e paralelismo
O consumidor puxa mensagens no ritmo em que consegue processar, e o broker acumula o resto. O acúmulo aparece como consumer lag no Kafka e como ApproximateNumberOfMessagesVisible no SQS, e essas métricas são o sinal para escalar.
No Kafka, escalar tem um teto: o número de partições. Um tópico com 6 partições suporta até 6 consumidores ativos no grupo, e o sétimo fica ocioso. Processar uma partição com várias threads quebra a ordem por chave, então o número de partições é uma decisão de capacidade tomada na criação do tópico. Os parâmetros max.poll.records e max.poll.interval.ms limitam o lote e o tempo máximo entre polls: um consumidor que ultrapassa o intervalo é considerado morto, e o grupo redistribui as partições dele.
Exemplo em Java com Spring
O listener a seguir consome os eventos publicados pela outbox. O id do evento chega no cabeçalho id, que o Outbox Event Router do Debezium copia da coluna de mesmo nome. A anotação @Transactional abre a transação do banco antes do método e a confirma no retorno. O Spring Kafka confirma o offset depois disso, e uma queda entre os dois commits provoca a reentrega, que a tabela absorve.
Ver o fluxograma em texto (Mermaid)
flowchart TD
msg["Mensagem entregue ao listener"] --> tx["Abre transação no banco"]
tx --> insere["INSERT do id em mensagem_processada"]
insere --> dup{"Conflito de chave?"}
dup -- "sim" --> ack1["Confirma o offset sem efeito"]
dup -- "não" --> efeito["Executa o efeito na mesma transação"]
efeito --> ok{"Sucesso?"}
ok -- "sim" --> commit["Commit do banco"]
commit --> ack2["Confirma o offset"]
ok -- "não" --> rollback["Rollback: id e efeito desfeitos"]
rollback --> tipo{"Exceção repetível?"}
tipo -- "sim, tentativas restam" --> espera["Backoff e nova entrega"]
espera --> msg
tipo -- "não ou esgotou" --> dlt["Publica no tópico .dlt"]
CREATE TABLE mensagem_processada (
consumidor TEXT NOT NULL, -- qual handler processou
mensagem_id UUID NOT NULL, -- id do evento, gerado pelo produtor
processada_em TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (consumidor, mensagem_id)
);
@Component
@RequiredArgsConstructor
public class PedidoCriadoListener {
private static final String CONSUMIDOR = "estoque.reserva";
private final JdbcTemplate jdbc;
private final EstoqueService estoque;
private final ObjectMapper mapper;
@KafkaListener(topics = "pedido.eventos", groupId = "estoque")
@Transactional
public void aoReceber(ConsumerRecord<String, String> registro) throws IOException {
String mensagemId = cabecalho(registro, "id");
int inseridas = jdbc.update("""
INSERT INTO mensagem_processada (consumidor, mensagem_id)
VALUES (?, ?::uuid) ON CONFLICT DO NOTHING""", CONSUMIDOR, mensagemId);
if (inseridas == 0) return; // duplicata: confirma o offset sem repetir o efeito
var evento = mapper.readValue(registro.value(), PedidoCriado.class);
estoque.reservar(evento.pedidoId(), evento.itens()); // efeito, na mesma transação
}
private static String cabecalho(ConsumerRecord<String, String> r, String nome) {
var h = r.headers().lastHeader(nome);
if (h == null) throw new MensagemInvalidaException("cabeçalho ausente: " + nome);
return new String(h.value(), StandardCharsets.UTF_8);
}
}
Uma exceção desfaz a transação inteira, inclusive a linha em mensagem_processada, e a próxima tentativa parte do zero. O DefaultErrorHandler repete a entrega conforme o backoff e, esgotadas as tentativas, delega ao DeadLetterPublishingRecoverer. Exceções registradas como não repetíveis vão direto para o tópico de dead-letter.
@Configuration
public class KafkaConsumidorConfig {
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> template) {
var recoverer = new DeadLetterPublishingRecoverer(template,
(registro, ex) -> new TopicPartition(registro.topic() + ".dlt", registro.partition()));
// 3 novas tentativas com 1 s de intervalo; depois, publica no tópico .dlt
var handler = new DefaultErrorHandler(recoverer, new FixedBackOff(1000L, 3L));
handler.addNotRetryableExceptions(MensagemInvalidaException.class, JsonProcessingException.class);
return handler;
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory(
ConsumerFactory<String, String> consumerFactory, DefaultErrorHandler errorHandler) {
var factory = new ConcurrentKafkaListenerContainerFactory<String, String>();
factory.setConsumerFactory(consumerFactory);
factory.setCommonErrorHandler(errorHandler);
factory.setConcurrency(3); // até 3 threads, uma por partição atribuída
return factory;
}
}
Com o resolvedor que mantém a partição, o tópico de dead-letter precisa ter pelo menos tantas partições quanto o de origem. No SQS, o equivalente a essa configuração é a redrive policy da fila, com o ARN da DLQ e o maxReceiveCount.
Desafios
A tabela de mensagens processadas cresce com o tráfego. A retenção dela precisa cobrir a janela em que uma duplicata ainda pode chegar, incluído o prazo de reprocessamento da DLQ. Fora dessa janela, um job remove as linhas antigas.
As novas tentativas no Kafka bloqueiam a partição enquanto duram. Três tentativas com 1 segundo de intervalo atrasam em 3 segundos todas as mensagens seguintes daquela partição. Tópicos de retry não bloqueantes, que o Spring Kafka também oferece, eliminam o atraso ao custo de quebrar a ordem por chave.
A tabela protege efeitos no mesmo banco. Uma chamada HTTP disparada pelo consumidor não entra na transação, e a reentrega a repete, a menos que a chave de idempotência seja propagada ao serviço chamado. Uma DLQ sem alerta, por fim, é uma lixeira: mensagens acumulam por semanas, e o reprocessamento tardio encontra um estado de negócio que já mudou.
Boas práticas
- Trate at-least-once como o contrato padrão e projete todo consumidor para receber duplicatas.
- Insira o identificador da mensagem na tabela de processadas na mesma transação do efeito.
- Use o identificador gerado pelo produtor, como o id da outbox, e nunca a posição no broker.
- Separe exceções transitórias de determinísticas e envie as determinísticas direto para a DLQ.
- Limite as tentativas com backoff e um teto explícito, e registre o motivo da última falha na mensagem enviada à DLQ.
- Monitore consumer lag e profundidade da DLQ com alertas, e defina quem reprocessa.
- Escolha o número de partições pela capacidade prevista e pela ordem exigida, não pelo número atual de instâncias.
Conclusão
A garantia de entrega é uma divisão de responsabilidades. O broker se compromete a não perder e paga com repetições. O consumidor se compromete a reconhecer a repetição e paga com uma tabela e uma transação. Produtor com outbox, broker at-least-once e consumidor idempotente formam a cadeia que entrega efeito único fim a fim sem two-phase commit.
A dead-letter queue completa o desenho ao dar à mensagem que não pode ser processada um destino diferente de bloquear as demais. Com tentativas limitadas e uma DLQ monitorada, a falha de uma mensagem vira um item de operação, e não um incidente.
Referências
- Apache Kafka, Design: Message Delivery Semantics. https://kafka.apache.org/43/design/design/#message-delivery-semantics
- AWS, Using dead-letter queues in Amazon SQS. https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html
- AWS, Amazon SQS visibility timeout. https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html
- Spring, Spring for Apache Kafka: Handling Exceptions. https://docs.spring.io/spring-kafka/reference/kafka/annotation-error-handling.html
- Chris Richardson, Pattern: Idempotent Consumer. https://microservices.io/patterns/communication-style/idempotent-consumer.html
- RabbitMQ, Dead Letter Exchanges. https://www.rabbitmq.com/docs/dlx
Assuntos: Apache Kafka, Spring Boot, Messaging, Distributed Systems, Software Architecture
Continue lendo
- Idempotência em APIs distribuídas: chaves, persistência e janelas de validade — Como garantir que uma operação de escrita repetida por retry produza exatamente um efeito, com Idempotency-Key, armazenamento transacional e tratamento de concorrência.
- Transactional Outbox: publicação confiável de eventos a partir do banco de dados — Como gravar o evento na mesma transação do agregado e entregá-lo ao broker por polling ou por captura do log de transações, com garantia at-least-once, ordem por agregado e deduplicação no consumidor.
- Entrega progressiva: feature flags, canary e blue-green — Como separar deploy de release com flags avaliadas por hash determinístico, escolher entre blue-green e canary, e deixar métricas de guarda decidirem a promoção ou o rollback com Argo Rollouts.
Quer aplicar isto no seu sistema? Fale com a MFactor: contato ou WhatsApp +55 (19) 99778-9831.