Mensageria e streaming de eventos
RabbitMQ com Spring AMQP e Spring Boot: RabbitTemplate, @RabbitListener e retries
Intermediário8 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
O Spring AMQP envolve o cliente Java do RabbitMQ nas abstrações que um desenvolvedor Spring espera: um template para publicar, métodos listener anotados para consumir e beans que declaram a topologia do broker na inicialização. Ao final deste guia, você vai saber construir um serviço Spring Boot 3 que publica eventos JSON com confirmação, consome esses eventos de forma concorrente com acknowledgements manuais, faz retry de falhas transitórias, manda mensagens envenenadas para dead letter e testa tudo contra um broker real com Testcontainers.
O que você vai aprender
- Adicionar o Spring AMQP a um projeto Spring Boot 3 e configurar a conexão por propriedades
- Declarar exchanges, filas e bindings como beans para que o RabbitAdmin os crie na inicialização
- Publicar mensagens JSON com RabbitTemplate e reagir aos publisher confirms e aos returns
- Consumir com @RabbitListener controlando concorrência, prefetch e acknowledgement manual
- Configurar retry com backoff e o roteamento para dead letter das mensagens que falharam
- Implementar request-reply e escrever testes de integração com Testcontainers
O cliente Java do RabbitMQ oferece channels, arrays de bytes e callbacks. O Spring AMQP oferece um RabbitTemplate que publica objetos, uma anotação @RabbitListener que transforma um método em um consumidor escalável e um RabbitAdmin que cria os exchanges e as filas descritos pelos seus beans. O Spring Boot acrescenta a autoconfiguração e as propriedades por cima. O resultado é um serviço em que o código de mensageria cabe em algumas dezenas de linhas, e este guia mostra essas linhas junto com as configurações que as tornam seguras sob carga. O alvo é o Spring Boot 3.x com Spring AMQP 3.x; as ideias valem sem mudanças para o Spring AMQP 4, embora alguns nomes de classes sejam diferentes.
Configuração e conexão
Um único starter traz o Spring AMQP e o cliente do RabbitMQ:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
O Boot cria uma CachingConnectionFactory — uma única conexão cujos channels ficam em pool e são reutilizados entre threads, que é o padrão correto no AMQP — e a configura a partir de propriedades:
spring:
rabbitmq:
addresses: amqps://rabbit-1.internal:5671,amqps://rabbit-2.internal:5671 # cluster nodes, TLS
username: orders
password: ${RABBITMQ_PASSWORD}
virtual-host: shop
connection-timeout: 5s
publisher-confirm-type: correlated # enable confirms with a CorrelationData callback
publisher-returns: true # get unroutable messages back
template:
mandatory: true # required for returns to fire
retry:
enabled: true # retry a failed publish (broker unavailable)
max-attempts: 3
initial-interval: 500ms
listener:
simple:
acknowledge-mode: auto # ack on success, nack on exception
prefetch: 20
concurrency: 2
max-concurrency: 8
default-requeue-rejected: false # rejected messages go to the DLX, not back to the queue
A lista addresses dá ao cliente vários nós para tentar; a factory com cache se reconecta ao próximo quando um nó cai, e os consumidores refazem a assinatura automaticamente. Todo o resto deste arquivo é explicado ao longo do guia.
Declarando a topologia como beans
O Spring AMQP modela exchanges, filas e bindings como beans. Na primeira conexão, o RabbitAdmin (autoconfigurado pelo Boot) declara todos os Declarable do contexto e os declara de novo após uma reconexão. As declarações são idempotentes, então várias instâncias do serviço podem subir em qualquer ordem.
@Configuration
class OrdersTopology {
static final String EXCHANGE = "orders";
static final String QUEUE = "fulfilment";
static final String DLX = "orders.dlx";
@Bean TopicExchange ordersExchange() { return ExchangeBuilder.topicExchange(EXCHANGE).durable(true).build(); }
@Bean DirectExchange ordersDlx() { return ExchangeBuilder.directExchange(DLX).durable(true).build(); }
@Bean Queue fulfilmentQueue() {
return QueueBuilder.durable(QUEUE)
.quorum() // x-queue-type=quorum
.deadLetterExchange(DLX)
.deadLetterRoutingKey(QUEUE)
.deliveryLimit(5) // quorum queues: dead-letter after 5 redeliveries
.build();
}
@Bean Queue fulfilmentDlq() { return QueueBuilder.durable(QUEUE + ".dlq").quorum().build(); }
@Bean Binding fulfilmentBinding() {
return BindingBuilder.bind(fulfilmentQueue()).to(ordersExchange()).with("order.*.placed");
}
@Bean Binding fulfilmentDlqBinding() {
return BindingBuilder.bind(fulfilmentDlq()).to(ordersDlx()).with(QUEUE);
}
}
Dois hábitos mantêm isso gerenciável à medida que o serviço cresce. Coloque os nomes em constantes (ou em uma classe @ConfigurationProperties) para que publicadores, listeners e testes usem os mesmos valores, e agrupe declarações relacionadas em um bean Declarables quando forem muitas, em vez de um bean por binding. Lembre-se de que os argumentos de uma fila são imutáveis: se mais tarde você mudar deliveryLimit em uma fila que já existe, o RabbitMQ recusa a declaração com um erro PRECONDITION_FAILED e o listener não sobe. Apague a fila ou expresse a configuração como policy.
Publicando com RabbitTemplate
RabbitTemplate.convertAndSend serializa um objeto com o MessageConverter configurado, define o content type e publica em um exchange com uma routing key. Por padrão, o conversor é o SimpleMessageConverter, que lida com strings, arrays de bytes e serialização Java; declare um conversor JSON e o Boot o injeta tanto no template quanto na factory de listener containers:
@Bean
MessageConverter jsonMessageConverter() {
return new Jackson2JsonMessageConverter(); // Spring AMQP 3.x; JacksonJsonMessageConverter in 4.x
}
@Service
class OrderEventPublisher {
private final RabbitTemplate rabbit;
OrderEventPublisher(RabbitTemplate rabbit) {
this.rabbit = rabbit;
rabbit.setConfirmCallback((correlation, ack, cause) -> {
if (!ack) log.error("broker rejected {}: {}", correlation != null ? correlation.getId() : "?", cause);
});
rabbit.setReturnsCallback(returned ->
log.error("unroutable message to {} with key {}: {}",
returned.getExchange(), returned.getRoutingKey(), returned.getReplyText()));
}
public void orderPlaced(OrderPlaced event) {
rabbit.convertAndSend(OrdersTopology.EXCHANGE, "order." + event.region() + ".placed", event,
message -> {
message.getMessageProperties().setMessageId(event.orderId()); // for consumer-side de-duplication
message.getMessageProperties().setHeader("schemaVersion", 2);
return message;
},
new CorrelationData(event.orderId()));
}
}
Com publisher-confirm-type: correlated, o broker confirma ou rejeita cada publicação e o template chama o seu ConfirmCallback com o CorrelationData que você passou. Um nack significa que o broker não conseguiu assumir a responsabilidade pela mensagem — um nó caiu no meio da publicação, ou uma quorum queue ficou sem maioria — e a reação certa é fazer retry ou registrar a falha, não logar e esquecer. O returns callback dispara quando mandatory é true e a routing key não bateu com nenhum binding, e é a única forma de perceber um erro de digitação em uma routing key.
A lambda MessagePostProcessor é onde se definem headers, identificadores de mensagem, prioridades e expirações. O modo de entrega persistente é o padrão do template.
Consumindo com @RabbitListener
Um método @RabbitListener vira um consumidor apoiado em um listener container. O Boot cria um SimpleMessageListenerContainer por anotação a partir das propriedades spring.rabbitmq.listener.simple.*; o payload é convertido pelo mesmo conversor JSON, e os headers podem ser injetados.
@Component
class FulfilmentListener {
@RabbitListener(queues = OrdersTopology.QUEUE, concurrency = "2-8")
public void onOrderPlaced(OrderPlaced event,
@Header(AmqpHeaders.MESSAGE_ID) String messageId,
@Header(name = "x-delivery-count", required = false) Long deliveryCount) {
if (fulfilments.alreadyProcessed(messageId)) return; // idempotent: duplicates are harmless
fulfilments.fulfil(event); // throwing here → nack → DLX (requeue disabled)
}
}
concurrency = "2-8" inicia duas threads consumidoras e escala até oito enquanto a fila tiver backlog. Cada thread tem o próprio channel com o prefetch configurado, então oito consumidores com prefetch de 20 podem ter 160 mensagens sem ack ao mesmo tempo — dimensione o prefetch com essa multiplicação em mente.
Com acknowledge-mode: auto (o padrão), o container faz o ack depois que o método retorna e envia um acknowledgement negativo quando ele lança uma exceção. Como default-requeue-rejected é false na configuração acima, uma mensagem rejeitada vai para o dead-letter exchange em vez de voltar para o início da fila. Para rejeitar uma mensagem de propósito independentemente dessa configuração, lance AmqpRejectAndDontRequeueException; para pedir que ela volte para a fila quando um sistema downstream estiver brevemente indisponível, lance ImmediateRequeueAmqpException.
Acknowledgement manual
Algumas cargas de trabalho precisam fazer o ack em um ponto preciso — depois de um commit no banco de dados, ou em lotes. Mude o container para AcknowledgeMode.MANUAL e injete o channel e o delivery tag:
@RabbitListener(queues = OrdersTopology.QUEUE, ackMode = "MANUAL")
public void onOrderPlaced(OrderPlaced event, Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
try {
fulfilments.fulfil(event);
channel.basicAck(tag, false);
} catch (DownstreamUnavailable e) {
channel.basicNack(tag, false, true); // requeue: another instance may succeed
} catch (RuntimeException e) {
channel.basicNack(tag, false, false); // dead-letter it
}
}
No modo manual, o container não faz o ack por você, então um método que esquece o ack deixa a mensagem sem ack até o channel ser fechado — e o consumer timeout do broker o fecha depois de trinta minutos, com erro.
Declarando a fila no listener
Em serviços pequenos é prático declarar o binding na própria anotação; o RabbitAdmin o reconhece como qualquer outro declarable:
@RabbitListener(bindings = @QueueBinding(
value = @Queue(value = "notifications", durable = "true",
arguments = @Argument(name = "x-queue-type", value = "quorum")),
exchange = @Exchange(value = "orders", type = ExchangeTypes.TOPIC),
key = "order.#"))
public void notify(OrderPlaced event) { ... }
Retries e dead letters
Há três camadas em que uma mensagem com falha pode passar por retry, e um serviço robusto usa duas delas.
| Camada | Mecanismo | Indicado para | Limitações |
|---|---|---|---|
| Listener container | Interceptor do Spring Retry: propriedades listener.simple.retry.* | Falhas transitórias breves (um timeout em uma chamada HTTP) | Os retries acontecem em memória, bloqueando a thread consumidora; perdidos na reinicialização |
| Broker | Dead-letter exchange, opcionalmente via uma fila de "espera" com TTL que devolve a mensagem | Atrasos mais longos, sobrevive a reinicializações, visível na interface de management | Exige topologia; é preciso verificar o contador de entregas para evitar loops |
| Dead-letter queue | Um listener separado ou reprocessamento manual | Mensagens envenenadas que precisam de uma pessoa ou de uma correção | Exige ferramentas e monitoramento |
O retry no nível do container é só uma mudança de propriedades:
spring:
rabbitmq:
listener:
simple:
retry:
enabled: true
max-attempts: 4 # the original delivery plus three retries
initial-interval: 1s
multiplier: 2.0 # 1 s, 2 s, 4 s
max-interval: 10s
default-requeue-rejected: false
Quando os retries se esgotam, o MessageRecoverer do container decide o destino da mensagem. O RejectAndDontRequeueRecoverer padrão a rejeita sem requeue, o que a envia para o dead-letter exchange configurado na fila. RepublishMessageRecoverer é uma alternativa que publica a mensagem em um exchange à sua escolha com headers descrevendo a exceção e a stack trace, algo muito mais útil quando alguém abre a dead-letter queue depois para entender o que deu errado:
@Bean
MessageRecoverer messageRecoverer(RabbitTemplate template) {
return new RepublishMessageRecoverer(template, OrdersTopology.DLX, OrdersTopology.QUEUE);
}
Seja qual for o mecanismo, uma dead-letter queue precisa de um dono: um alerta sobre a profundidade da fila, um listener que registre a falha em algum lugar visível e uma forma documentada de reprocessar depois que o bug for corrigido — normalmente o recurso move messages da interface de management (com o plugin shovel) ou um pequeno endpoint administrativo que lê a dead-letter queue e republica.
Request-reply
RabbitTemplate.convertSendAndReceive implementa chamadas de procedimento remoto sobre AMQP: publica uma requisição, espera uma resposta com o identificador de correlação correspondente e converte o resultado. Por padrão, usa a pseudofila direct reply-to do RabbitMQ (amq.rabbitmq.reply-to), que evita criar uma fila temporária por chamada e é rápida o bastante para uso interativo.
PriceQuote quote = rabbit.convertSendAndReceiveAsType(
"pricing", "quote.request", new QuoteRequest("A-100", 3),
new ParameterizedTypeReference<PriceQuote>() {});
No lado do servidor, um método @RabbitListener simplesmente retorna um valor; o container o envia para o replyTo da requisição com o mesmo correlationId:
@RabbitListener(queues = "pricing.requests")
public PriceQuote quote(QuoteRequest request) {
return pricing.quote(request.sku(), request.quantity());
}
Defina spring.rabbitmq.template.reply-timeout (cinco segundos por padrão) com um valor compatível com o timeout do próprio chamador, e lembre-se de que o request-reply reintroduz o acoplamento temporal que a mensageria deveria eliminar: se o serviço de preços estiver fora do ar, o chamador falha depois do timeout exatamente como faria com uma chamada HTTP.
Testando contra um broker real
Mockar o RabbitTemplate não testa nada sobre routing keys, conversores ou acknowledgements. O Testcontainers sobe um RabbitMQ real no Docker, e o @ServiceConnection do Boot (3.1+) preenche as propriedades de conexão sem nenhuma configuração manual:
@SpringBootTest
@Testcontainers
class FulfilmentFlowTest {
@Container @ServiceConnection
static RabbitMQContainer rabbit = new RabbitMQContainer("rabbitmq:4.0-management");
@Autowired OrderEventPublisher publisher;
@Autowired FulfilmentRepository fulfilments;
@Test
void placedOrderIsFulfilled() {
publisher.orderPlaced(new OrderPlaced("o-1", "eu", 149.90));
Awaitility.await().atMost(Duration.ofSeconds(5))
.untilAsserted(() -> assertThat(fulfilments.find("o-1")).isPresent());
}
}
Como os beans de topologia declaram tudo na inicialização, o teste roda contra um broker vazio, sem fixtures. Use a tag de imagem -management quando quiser abrir a interface na porta mapeada enquanto depura um teste que está falhando.
Erros comuns
- Esquecer o conversor JSON. Os listeners recebem
byte[]ou falham com umaClassCastException; os publicadores enviam payloads com serialização Java que consumidores não Java não conseguem ler. Declare o bean conversor uma única vez. - Deixar
default-requeue-rejectedemtrue. Um listener que lança exceção diante de uma mensagem malformada fica girando a toda velocidade na mesma mensagem para sempre. Defina comofalsee use dead letter. - Ignorar os callbacks de confirm e return. As publicações somem em silêncio diante de uma instabilidade momentânea do broker ou de uma routing key digitada errado. Ative os confirms
correlatedemandatory, e registre em log ou faça retry nos callbacks. - Alterar argumentos de uma fila existente.
PRECONDITION_FAILEDna inicialização. Apague a fila fora de produção; use policies em produção. - Retry bloqueante com intervalos longos. Uma thread de listener dormindo minutos entre retries é uma thread que você não tem mais. Mantenha curtos os retries do container e leve as esperas longas para uma fila com TTL.
- Compartilhar estado mutável entre threads de listener concorrentes.
concurrency = "2-8"significa que o seu bean listener é chamado por oito threads ao mesmo tempo; mantenha-o sem estado ou sincronize corretamente.
Principais conclusões
- O
spring-boot-starter-amqpdo Boot entrega uma connection factory com cache, umRabbitTemplate, umRabbitAdmine listener containers configurados pelas propriedadesspring.rabbitmq.*. - Declare exchanges, filas e bindings como beans; eles são criados de forma idempotente na primeira conexão, e quorum queues com um dead-letter exchange deveriam ser o formato padrão.
- Publique com um
MessageConverterJSON, confirms correlacionados e returns commandatorypara saber o que o broker aceitou. - O
@RabbitListenerescala comconcurrency, é limitado pelo prefetch e faz o ack automaticamente no retorno ou rejeita em caso de exceção; use o modo manual só quando o ponto de acknowledgement precisar ser preciso. - Faça retries curtos no container e depois mande para dead letter via broker; garanta que
default-requeue-rejectedsejafalsee que alguém seja dono da dead-letter queue. - Teste o fluxo real com Testcontainers e
@ServiceConnection; mocks não pegam erros de roteamento nem de serialização.
Fontes e leituras complementares
Perguntas frequentes
- Preciso declarar as filas no código ou um administrador pode criá-las?
- As duas abordagens funcionam. Declarar beans Queue, Exchange e Binding deixa a aplicação autossuficiente e permite que os testes partam de um broker vazio; o RabbitAdmin os declara quando a primeira conexão é aberta, e a declaração é idempotente. Em ambientes onde os desenvolvedores podem não ter a permissão configure, deixe esses beans de fora e deixe que a equipe de operações crie a topologia com policies e a API de management: o listener simplesmente não sobe se a fila não existir, que é exatamente o que você quer.
- Por que meus métodos listener recebem um array de bytes em vez do meu objeto?
- Porque não há nenhum MessageConverter JSON configurado, então o Spring AMQP recorre ao SimpleMessageConverter, que entrega bytes a menos que o content type seja text/plain ou o payload seja um objeto Java serializado. Declare um bean conversor JSON do Jackson; o Spring Boot o conecta automaticamente tanto ao RabbitTemplate quanto à factory de listener containers.
- Devo usar Spring Retry no listener ou uma dead-letter queue com TTL?
- Os dois, para falhas diferentes. O retry com backoff no nível do listener cobre indisponibilidades curtas de uma dependência downstream sem sair do consumidor. Um dead-letter exchange captura o que continua falhando depois dos retries, para que possa ser inspecionado e reprocessado. Para esperas longas entre tentativas, uma fila com TTL que devolve as mensagens via dead letter para a fila de trabalho deixa o consumidor livre e sobrevive a reinicializações, algo que o retry em memória não faz.
- O que faz o default-requeue-rejected?
- Ele decide o que acontece quando um listener lança uma exceção. Com o valor padrão true, a mensagem volta para a fila e é entregue de novo imediatamente, o que transforma um bug determinístico em um loop apertado. Defina como false para que as mensagens rejeitadas sejam descartadas ou enviadas para dead letter, e lance AmqpRejectAndDontRequeueException no listener quando quiser rejeitar uma mensagem específica de forma explícita, independentemente dessa configuração.