Blog

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

Cadeia produtor confiável, broker e consumidor idempotente. Fluxo da outbox ao efeito: a transação do produtor grava o evento, o relay publica no broker com at-least-once, e o consumidor deduplica pelo id antes de gravar o efeito.

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.

Decisão do consumidor ao receber uma mensagem. Fluxograma do listener: INSERT do id na tabela de processadas, conflito confirma sem efeito, inserção nova executa o efeito e confirma, falha faz rollback e decide entre nova tentativa e dead-letter topic.
Decisão do consumidor ao receber uma mensagem. Fluxograma do listener: INSERT do id na tabela de processadas, conflito confirma sem efeito, inserção nova executa o efeito e confirma, falha faz rollback e decide entre nova tentativa e dead-letter topic.
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

  1. Trate at-least-once como o contrato padrão e projete todo consumidor para receber duplicatas.
  2. Insira o identificador da mensagem na tabela de processadas na mesma transação do efeito.
  3. Use o identificador gerado pelo produtor, como o id da outbox, e nunca a posição no broker.
  4. Separe exceções transitórias de determinísticas e envie as determinísticas direto para a DLQ.
  5. Limite as tentativas com backoff e um teto explícito, e registre o motivo da última falha na mensagem enviada à DLQ.
  6. Monitore consumer lag e profundidade da DLQ com alertas, e defina quem reprocessa.
  7. 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

Assuntos: Apache Kafka, Spring Boot, Messaging, Distributed Systems, Software Architecture

Continue lendo

Quer aplicar isto no seu sistema? Fale com a MFactor: contato ou WhatsApp +55 (19) 99778-9831.