Mensageria e streaming de eventos
Padrões de mensageria e garantias de entrega: idempotência, outbox, retries e ordenação
Avançado13 min de leituraAtualizado
Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês
Todo broker promete confiabilidade; nenhum consegue tornar sua aplicação confiável sozinho. Este guia explica o que at-most-once, at-least-once e exactly-once significam de fato e de onde vêm as duplicatas e as perdas, e depois percorre os padrões que fecham essas lacunas — consumers idempotentes, transactional outbox, retry com backoff e dead-letter queues, ordenação por chave, request-reply, competing consumers, claim check e versionamento de schemas — mostrando como cada um se aplica ao ActiveMQ, ao RabbitMQ e ao Kafka.
O que você vai aprender
- Explicar as três garantias de entrega e identificar exatamente onde uma duplicata ou uma perda pode ocorrer em um pipeline
- Projetar um consumer idempotente com um armazenamento de deduplicação e uma janela de retenção adequada
- Implementar o transactional outbox para que gravações no banco de dados e eventos publicados não possam divergir
- Construir um tratamento de retry, backoff e dead letters que não bloqueie as mensagens saudáveis
- Preservar a ordem onde ela importa usando chaves, message groups, partições e single active consumers
- Aplicar corretamente request-reply, competing consumers, claim check e evolução de schemas em cada broker
Os brokers anunciam durabilidade, acknowledgements e transações, e é fácil concluir que usar um deles torna um sistema confiável. Não torna. Confiabilidade é uma propriedade do caminho inteiro — producer, broker, consumer e cada efeito colateral que o consumer provoca — e o broker só controla o meio. Os padrões deste guia são os que tornam confiáveis também as pontas. Eles independem do broker; a última seção mostra como cada um se aplica ao ActiveMQ Classic, ao RabbitMQ e ao Kafka, que os guias anteriores desta trilha abordam em detalhe.
O que as três garantias significam de fato
As garantias de entrega descrevem o que acontece com uma mensagem quando algo falha entre o momento em que um producer a envia e o momento em que um consumer termina de tratá-la.
At-most-once. O producer envia sem esperar confirmação, ou o consumer dá ack antes de processar. Uma falha em qualquer ponto perde a mensagem; nada é duplicado. É a escolha certa para dados que ficam obsoletos rapidamente — amostras de métricas, atualizações de presença, invalidações de cache —, em que uma mensagem perdida é corrigida pela seguinte.
At-least-once. O producer espera a confirmação do broker e reenvia se ela não chegar; o consumer só dá ack depois de processar. Nada se perde, mas cada retry pode criar uma duplicata: o primeiro envio do producer pode ter dado certo antes de a confirmação se perder, e o consumer pode ter concluído o trabalho e cair sem chegar a dar o ack. É o que quase todos os sistemas em produção rodam.
Exactly-once. Cada mensagem afeta o sistema exatamente uma vez. Nenhum broker consegue garantir isso para um consumer qualquer, porque os efeitos colaterais do consumer — uma chamada HTTP, uma linha em outro banco — estão fora do controle do broker. O que os brokers podem oferecer é exactly-once dentro de uma fronteira: as transações do Kafka tornam atômico um ciclo read-process-write entre topics do Kafka, e uma sessão JMS transacionada torna atômicos o consumo e a produção em um mesmo broker. Fora da fronteira, exactly-once é algo que você constrói com entrega at-least-once e processamento idempotente.
Vale a pena memorizar de onde vêm as duplicatas e as perdas, porque cada padrão abaixo ataca um desses pontos:
| Ponto de falha | Sintoma sem padrão | Padrão |
|---|---|---|
| O producer cai depois do commit no banco, antes da publicação | Evento perdido, sistemas inconsistentes | Transactional outbox |
| A confirmação do producer se perde e ele reenvia | Mensagem duplicada | Producer idempotente (Kafka), deduplicação no broker ou consumer idempotente |
| O broker cai antes de persistir | Mensagem perdida | Entrega persistente, confirms, replicação |
| O consumer cai depois do efeito colateral, antes do ack | Efeito colateral duplicado | Consumer idempotente |
| O consumer não consegue processar uma mensagem | Fila bloqueada ou loop infinito | Retry com backoff, dead-letter queue |
| Consumers em paralelo | Reordenação | Ordenação por chave |
Consumer idempotente
Um consumer idempotente chega ao mesmo estado final processando uma mensagem uma vez ou várias. É o padrão mais valioso da mensageria, porque torna segura a entrega at-least-once — e at-least-once é o que você tem.
Algumas operações são naturalmente idempotentes: definir um status como SHIPPED, fazer upsert de uma linha pela chave do id do pedido, excluir um registro. Outras não são — incrementar um contador, cobrar um cartão, enviar um e-mail — e precisam de um armazenamento de deduplicação: uma tabela ou entrada chave-valor que registra quais identificadores de mensagem já foram processados, consultada e gravada na mesma transação que o efeito colateral.
@Transactional
public void handle(OrderPaid event, String messageId) {
// INSERT ... ON CONFLICT DO NOTHING; returns 0 rows when the id was already recorded
if (processedMessages.insertIfAbsent(messageId, Instant.now()) == 0) {
return; // duplicate: already handled, acknowledge and move on
}
ledger.credit(event.merchantId(), event.amount());
// commit writes the ledger entry and the de-duplication row atomically
}
Três detalhes decidem se isso funciona. O identificador precisa ser atribuído pelo producer — um id de mensagem que ele gera uma única vez, ou uma chave de negócio como o id do pedido mais o tipo de evento —, porque um id atribuído pelo broker muda quando o producer reenvia. A verificação e o efeito colateral precisam estar em uma única transação, senão uma queda entre os dois recria o problema. E o armazenamento precisa expirar as entradas depois de uma janela mais longa que qualquer reentrega possível, senão cresce para sempre. Quando o efeito colateral é uma chamada a outro sistema, repasse o mesmo identificador como chave de idempotência para que o sistema downstream também possa deduplicar; provedores de pagamento e a maioria das APIs bem projetadas aceitam uma chave dessas.
Transactional outbox
Um serviço que salva um pedido e publica um evento OrderPlaced tem um problema de escrita dupla: o banco de dados e o broker são dois sistemas, e não existe transação que abranja os dois. Publicar depois do commit arrisca um pedido salvo sem evento; publicar antes arrisca um evento de um pedido que sofreu rollback.
O transactional outbox tira o segundo sistema da transação. O serviço insere o evento em uma tabela outbox na mesma transação do pedido. Um relay separado lê as novas linhas do outbox e as publica, marcando cada uma como enviada. Se o relay cair depois de publicar e antes de marcar, o evento é publicado duas vezes — o que é at-least-once, e o consumer idempotente visto acima absorve isso.
CREATE TABLE outbox (
id UUID PRIMARY KEY, -- becomes the message id
aggregate_id TEXT NOT NULL, -- becomes the routing key or partition key
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;
O relay pode ser um poller agendado (SELECT ... WHERE published_at IS NULL ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED, publicar, atualizar) ou uma ferramenta de change data capture como o Debezium, que acompanha o log de transações do banco e publica no Kafka cada inserção no outbox, sem polling e com ordem estrita. O polling é mais simples e dá conta de volumes moderados; o CDC escala mais e não adiciona carga às tabelas. Em qualquer caso, exclua ou arquive as linhas publicadas para que a tabela continue pequena.
O outbox também entrega de graça a ordenação de eventos por agregado: as linhas são publicadas na ordem de inserção, e usar aggregate_id como message group ou chave de partição mantém essa ordem no lado do consumer.
Retries, backoff e dead letters
Um consumer que falha em uma mensagem tem três opções honestas: tentar de novo, pular ou deixar de lado. A opção errada — tentar de novo imediatamente e para sempre — é o comportamento padrão de muitos listener containers, e transforma uma única poison message em uma fila que nunca esvazia e em uma dependência martelada até cair.
Uma política sólida tem quatro partes:
- Classifique a falha. Um timeout ou um
503é transitório e merece nova tentativa. Um erro de validação ou um404de uma entidade referenciada é permanente; tentar de novo desperdiça tentativas. - Faça backoff exponencial com jitter. Um segundo, dois, quatro, oito, com uma fração aleatória somada para que cem consumers falhando juntos não tentem de novo em sincronia. Limite o atraso e o número de tentativas.
- Faça retry sem bloquear a fila. O retry em memória segura a thread do consumer e as mensagens que vêm atrás. Para atrasos acima de alguns segundos, republique a mensagem em um destino de retry com atraso — uma fila com TTL que devolve a mensagem via dead letter para a fila de trabalho no RabbitMQ, uma entrega agendada no ActiveMQ, um topic de retry consumido depois de uma espera no Kafka — e dê ack na original.
- Mande para dead letter o que sobrar. Depois da última tentativa, mova a mensagem para uma dead-letter queue (DLQ) com a exceção, a stack trace, o número de tentativas e o destino original anexados como headers. Uma DLQ sem ninguém lendo é um buraco negro: crie alertas sobre sua profundidade e mantenha uma ferramenta para inspecionar e reprocessar o conteúdo.
Pular merece uma menção: em streams nos quais um registro ruim não pode parar o pipeline — analytics, logs —, registrar o item em log e seguir em frente é uma política legítima, desde que seja uma decisão, e não um acidente.
Ordenação
Os brokers preservam a ordem em exatamente um lugar: uma única fila ou partição lida por um único consumer. Tudo o que aumenta o throughput — competing consumers, várias partições, reentrega depois de uma falha — a enfraquece. A posição viável é a ordenação por chave: todas as mensagens sobre uma mesma entidade chegam em ordem, enquanto mensagens sobre entidades diferentes podem se intercalar.
| Broker | Mecanismo | Lado do consumer |
|---|---|---|
| ActiveMQ Classic | Message groups com JMSXGroupID: todas as mensagens com um group id vão para um único consumer enquanto ele estiver vivo | Nada de especial; o broker fixa o grupo |
| RabbitMQ | Uma fila por faixa de chaves (plugin consistent-hash exchange) ou uma única fila com x-single-active-consumer | Um consumer ativo por fila |
| Kafka | Particionamento por chave; o partitioner padrão já faz isso | Um consumer por partição dentro de um grupo |
Mesmo com ordenação por chave restam duas sutilezas. Uma mensagem em retry pode ultrapassar mensagens posteriores da mesma chave, a menos que os retries também sejam por chave — a correção mais segura é interromper o processamento da chave até o retry dar certo, o que o Kafka faz naturalmente ao bloquear a partição. E o significado de um evento não deveria depender de ordem estrita quando isso puder ser evitado: levar um número de versão ou um timestamp em cada evento permite que o consumer ignore uma atualização obsoleta que chega atrasada, o que sai mais barato que uma ordenação perfeita.
Padrões de interação
Competing consumers
Vários consumers lendo uma mesma fila dividem suas mensagens, e adicionar um consumer aumenta o throughput até que o producer ou uma dependência downstream vire o limite. É o modelo de escala padrão para filas e, com os consumer groups, para o Kafka. Mantenha o prefetch ou o max.poll.records modesto para que o trabalho seja dividido de forma equilibrada e uma queda gere pouca reentrega.
Publish-subscribe
Um evento, vários leitores independentes, cada um com a própria posição e as próprias falhas. Implemente com topics e assinaturas duráveis no JMS, um exchange fanout ou topic alimentando uma fila por assinante no RabbitMQ, e consumer groups independentes no Kafka. Cada assinante precisa cuidar dos próprios retries e dead letters; um assinante lento jamais pode bloquear os outros.
Request-reply
Uma mensagem que espera resposta precisa de um identificador de correlação, que a resposta traz de volta, e de um endereço de resposta, para onde quem responde envia. Dê um timeout a cada requisição e decida o que o solicitante faz quando ele expira — uma resposta que chega depois do timeout precisa ser reconhecida como obsoleta e descartada. Como o request-reply reintroduz acoplamento temporal, reserve-o para casos em que quem chama realmente não pode continuar sem a resposta.
Claim check
Brokers são feitos para mensagens pequenas; um payload de 20 MB deixa cada salto mais lento e pode ultrapassar limites de frame. O padrão claim check armazena o payload em um object storage ou em um banco de dados e envia uma mensagem contendo apenas uma referência e os metadados de que um roteador precisa. O consumer busca o payload quando processa a mensagem. Expire os payloads armazenados com a mesma disciplina aplicada aos registros de deduplicação.
Roteamento baseado em conteúdo e tradução de mensagens
Quando uma mensagem precisa ir para consumers diferentes de acordo com seus atributos, roteie por headers ou routing keys no broker (um topic exchange, um selector, um topic por tipo), em vez de fazer todo consumer receber tudo e descartar a maior parte. Quando dois sistemas discordam quanto ao formato, coloque a tradução em um único lugar — um consumer dedicado que republica — e não em cada consumer.
Evolução de schemas
As mensagens sobrevivem ao código que as produziu. Uma fila pode conter o formato de ontem quando o consumer de hoje entra em produção, e um topic do Kafka guarda uma semana de histórico. Por isso toda mensagem precisa de uma versão de schema, e toda mudança precisa ser classificada como compatível ou incompatível:
- Compatível: adicionar um campo opcional com valor padrão, ampliar um tipo numérico, adicionar um novo tipo de evento em uma nova routing key. Os consumers ignoram campos desconhecidos.
- Incompatível: renomear ou remover um campo, mudar um tipo ou o seu significado, mudar a chave. Essas mudanças exigem um novo topic ou routing key, ou uma etapa de tradução, e um período em que as duas versões são produzidas ou consumidas.
Um schema registry (Avro, Protobuf ou JSON Schema) aplica as regras de compatibilidade no momento da publicação e vale a adoção assim que mais de um time consome um topic. Sem ele, um header schemaVersion e um consumer que valida antes de processar são o mínimo; use o formatador de JSON para inspecionar payloads e o decodificador Base64 quando uma ferramenta mostrar o corpo codificado.
Mapeando os padrões para os brokers
| Padrão | ActiveMQ Classic | RabbitMQ | Kafka |
|---|---|---|---|
| At-least-once | Entrega persistente, ack AUTO/CLIENT ou sessão transacionada | Quorum queue durável, mensagem persistente, confirms, ack manual | acks=all, producer idempotente, commit depois do processamento |
| Fronteira de exactly-once no broker | Sessão transacionada (consumo + produção em um broker), XA via servidor de aplicação | Nenhuma; use consumers idempotentes | Transações com consumers read_committed, EOS do Kafka Streams |
| Deduplicação no producer | Nenhuma; use um id de outbox | Nenhuma; use um id de outbox | Producer idempotente (por sessão) |
| Retry com atraso | Entrega agendada (AMQ_SCHEDULED_DELAY) | Fila com TTL e dead-letter exchange de volta para a fila de trabalho | Topics de retry consumidos depois de um atraso |
| Dead letters | ActiveMQ.DLQ ou DLQ por destino, automática após a redelivery policy | Dead-letter exchange, x-delivery-limit em quorum queues | Não nativo; topic de erros gravado pelo consumer ou pelo framework |
| Ordenação por chave | JMSXGroupID | Single active consumer, consistent-hash exchange | Chave de partição |
| Pub-sub | Topics, assinaturas duráveis e compartilhadas | Exchange fanout/topic com uma fila por assinante | Consumer groups |
| Replay | Não | Apenas streams | Reset de offsets |
| Request-reply | Filas temporárias, JMSReplyTo, JMSCorrelationID | Direct reply-to, propriedades reply_to e correlation_id | Topic de resposta com o id de correlação como chave; desajeitado |
A tabela deixa uma coisa evidente: o Kafka oferece as garantias mais fortes no producer e no broker e o ferramental mais fraco por mensagem, enquanto os brokers de filas oferecem um tratamento rico de cada mensagem e esperam que você resolva a deduplicação por conta própria. Nenhum deles elimina a necessidade de consumers idempotentes e de um outbox.
Erros comuns
- Confiar no “exactly-once” da embalagem. Ele vale dentro da fronteira do broker. Efeitos colaterais fora dela precisam de idempotência.
- Identificadores atribuídos pelo broker para deduplicação. Eles mudam quando o producer reenvia. Gere o id no producer ou derive-o de uma chave de negócio.
- Deduplicação fora da transação. Consultar o armazenamento, fazer o trabalho e só então registrar o id deixa uma janela de queda que reproduz a duplicata. Uma única transação.
- Publicar depois do commit sem outbox. Funciona até a primeira queda entre as duas etapas, que depois é quase impossível de reconstituir.
- Retries imediatos e ilimitados. Poison messages bloqueiam filas e retries amplificam incidentes. Backoff, limite, dead letter.
- Presumir ordem global. Ela não existe além de um consumer em uma fila ou partição. Projete por chave e carregue números de versão.
- Payloads grandes no barramento. Use um claim check; mantenha as mensagens na casa dos kilobytes.
Principais pontos
- At-least-once é o que você obtém de qualquer broker em produção; exactly-once só existe dentro da fronteira transacional de um broker, e at-most-once é uma escolha deliberada para dados descartáveis.
- As duplicatas vêm dos retries do producer e das quedas do consumer antes do ack; o consumer idempotente, com um id atribuído pelo producer e verificado dentro da transação do efeito colateral, absorve os dois casos.
- O transactional outbox torna consistentes as gravações no banco e os eventos publicados ao colocar o evento no banco e retransmiti-lo depois.
- Faça retry das falhas transitórias com backoff exponencial limitado, deixe de lado as falhas permanentes em uma dead-letter queue com o motivo anexado e nunca deixe uma mensagem ruim parar a fila.
- A ordem é preservada por fila ou partição com um único consumer; projete para ordenação por chave com message groups, single active consumers ou chaves de partição, e carregue números de versão.
- Escolha os padrões de interação de forma deliberada — competing consumers, pub-sub, request-reply com timeouts, claim check para payloads grandes — e versione todo schema de mensagem desde a primeira release.
Fontes e leituras complementares
Perguntas frequentes
- Se meu broker suporta exactly-once, ainda preciso de consumers idempotentes?
- Sim, a menos que todo efeito colateral do seu consumer esteja ele próprio dentro da transação do broker. O exactly-once do Kafka cobre o processamento de Kafka para Kafka; no momento em que um consumer chama uma API de pagamento ou grava uma linha no banco, esse efeito fica fora da transação e pode se repetir depois de uma falha. A idempotência no consumer é o único mecanismo que funciona independentemente do broker.
- Por que não simplesmente publicar o evento depois do commit no banco?
- Porque o processo pode morrer entre o commit e a publicação, deixando um pedido salvo sem evento, ou a publicação pode dar certo e o commit falhar, deixando um evento de um pedido que não existe. O transactional outbox grava o evento na mesma transação de banco que os dados e deixa um relay separado publicá-lo, de modo que os dois nunca podem divergir.
- Por quanto tempo devo guardar os registros de deduplicação?
- Por mais tempo do que a reentrega mais longa que seja plausível. Esse prazo é limitado pela retenção ou pela janela de reentrega do broker: algumas horas cobrem a maioria das reentregas de filas, enquanto consumers Kafka que voltam uma semana no histórico precisam de uma semana. Armazene o identificador da mensagem com um timestamp e expire as entradas com um job em segundo plano ou com um TTL do banco.
- Mensagens com falha devem ser reprocessadas imediatamente?
- Só uma ou duas vezes, para instabilidades rápidas de rede. Além disso, retries imediatos martelam uma dependência que já está com dificuldades e bloqueiam as mensagens que vêm atrás. Use backoff exponencial com jitter, limite as tentativas e mova a mensagem para um destino de dead letters com o motivo da falha anexado, para que as mensagens saudáveis continuem fluindo.