☰
RabbitMQ死信队列与TTL实现延迟消息的Spring Boot实战
2026/10/21 16:26:21 网站建设 项目流程

先声明,我下面要讲的这套玩法,本身就是我在实际业务里被“逼”出来的。当时做的一个模拟电商订单系统,有个场景:用户下单后十分钟内未支付,订单就得自动取消,库存还得补回去。一开始用定时任务扫表,数据量一上来,DB压力大不说,扫表频率和实时性之间怎么平衡都是个麻烦。后来换成了RabbitMQ的延迟消息方案——其实严格来说,是“TTL过期消息 + 死信队列”的组合拳,一次性把事情解决得明明白白。这篇博文就把我实践下来的完整思路、核心配置、以及那些光看官方文档绝对学不到的坑,全部拆开来讲。

1. 死信队列到底解决了什么问题

先纠正一个常见误解:死信队列不是“消息消费失败后的重试队列”,它的本质是一个消息转运站。RabbitMQ里有一条规则,当一条消息在特定情况下没有被正常消费,它不会被直接丢弃,而是会被转发到你预先指定的另一个exchange里,那个exchange再路由到对应的queue,这个queue就是死信队列。

触发死信的条件就三种,区分清楚非常关键:

  1. 消息被消费者显式拒绝:消费时调用basic.reject或basic.nack,并且requeue参数设置为false。注意,一旦带上requeue=false,消息就进死信;如果requeue=true,消息是重新放回原队列,这种情况不会触发死信转发。
  2. 消息TTL过期:给消息或队列设置了x-message-ttl,消息在队列里待的时间超过这个阈值,就自动变成死信。
  3. 队列长度溢出:队列设置了x-max-length,塞满了之后新的消息就会变成死信。老的还是新的变死信,取决于x-overflow参数的配置,默认是drop-head,也就是队头的旧消息被丢进死信。

这些机制单看晦涩,我拿快递柜举例子就很好懂了。队列就是快递柜,消费者是取件人。TTL过期就是快递超时未取被退回,消费者显式拒绝等于收件人当面拒收,队列满了就是柜子满了投递员只能把后面的件先挪到别处暂存。死信队列就是这个“退回/暂存中心”。

在Spring Boot里实现这套东西有个天然的优势:你不需要额外引入任何组件,只用Spring Boot自带的spring-boot-starter-amqp,把RabbitMQ的连接信息配上,再声明几个Bean级别的Exchange、Queue、Binding,剩下的逻辑全交给RabbitMQ服务器去执行。这个方案对小型项目和中等规模的服务都足够稳,我会在后面的章节把Bean声明的写法、参数的坑一次性讲透。

2. 核心原理:TTL、死信Exchange与RoutingKey的三角关系

很多人看死信队列配置一头雾水,就是没搞懂三个角色之间的协作流程。

先明确一个概念,死信队列不是单独存在的。它必须依赖两套交换机(Exchange)体系:

  • 业务Exchange:消息正常发送时走的交换机,负责把消息路由到业务队列。
  • 死信Exchange(DLX):专门用来接收死信的交换机。它不过是给普通队列(业务队列)设置了一个属性x-dead-letter-exchange,当该队列里的消息变成死信,RabbitMQ会自动把这条消息重新发布到DLX上,再由DLX根据自身的路由规则投递到死信队列。

一个常见的误区是,很多人以为给队列声明了死信Exchange就够了,其实还缺一个属性:x-dead-letter-routing-key。这个参数决定消息死信后以什么路由键转发给DLX。如果不设置,RabbitMQ默认会沿用消息原本的routingKey。

那延迟消息的玩法又是怎么来的?其实就是利用TTL和死信转发的时间差:消息先进入一个没有任何消费者的“等待队列”,同时给这个队列配上很短的TTL(比如10秒,代表业务上10秒后要处理的事),消息一到时间就自动过期,被转发进死信队列;这时候真正的消费者只监听死信队列,一收到消息就立刻处理。业务侧看起来就是:发一条消息,10秒后生效,妥妥的延迟队列。

这里面最难理解的是RoutingKey在死信转发时怎么变化。我拆一个实际项目里的配置给你看:

假设我订单模块里有一个业务队列order.delay.queue,它声明时指定的死信交换机是order.dead.exchange,死信路由键是order.dead.key。会有以下情况:

  • 如果业务消息进入队列时路由键就是order.dead.key,那么死信后直接命中。
  • 如果业务路由键和死信路由键不一致,那消息过期后,RabbitMQ会使用x-dead-letter-routing-key的值重新投递。
  • 如果不设置x-dead-letter-routing-key,那么就用原消息的路由键。

就因为这个转发规则的存在,实际开发时有一条不成文的习惯:业务队列和死信队列最好是同一套Exchange的绑定关系,或者至少保证DLX的绑定额能覆盖死信路由键,否则消息死信后要么进不了队列,要么直接丢失。很多人死信队列“没生效”,排查到最后八成都栽在这个RoutingKey的匹配上。

3. Spring Boot完整落地:从依赖到Bean声明的全流程

接下来直接上手,我把在用的一套完整配置贴出来,跟着做就能跑通。我这里用的Spring Boot版本是2.7.x,对应的amqp starter没问题。

3.1 引入依赖和基础配置

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>

application.yml里,连接信息按需填写:

spring: rabbitmq: host: 127.0.0.1 port: 5672 username: guest password: guest virtual-host: / publisher-confirm-type: correlated publisher-returns: true template: mandatory: true

这里解释下两个容易忽视的配置:

  • publisher-confirm-type: correlated:开启发送端确认,能够准确知道每一条消息有没有被Broker成功接收。这是排查“消息发了却没收到”的第一道防线。
  • template.mandatory: true:配合发送端的ReturnCallback使用。如果消息发送到交换机但路由不到任何队列,Broker会回调返回这条消息,生产环境必须开。

3.2 核心配置类:Exchange、Queue、Binding一次声明

这是整个方案的核心。我习惯专门用一个DeadLetterConfig类来管理所有死信相关组件。直接看代码:

@Configuration public class DeadLetterConfig { public static final String DELAY_EXCHANGE = "order.delay.exchange"; public static final String DELAY_QUEUE = "order.delay.queue"; public static final String DELAY_ROUTING_KEY = "order.delay.key"; public static final String DEAD_EXCHANGE = "order.dead.exchange"; public static final String DEAD_QUEUE = "order.dead.queue"; public static final String DEAD_ROUTING_KEY = "order.dead.key"; // 业务交换机:用来接收发送过来的消息 @Bean public DirectExchange delayExchange() { return new DirectExchange(DELAY_EXCHANGE); } // 死信交换机:专门接收死信 @Bean public DirectExchange deadExchange() { return new DirectExchange(DEAD_EXCHANGE); } // 业务队列:不设消费者,只用来存消息并等待TTL过期 @Bean public Queue delayQueue() { return QueueBuilder.durable(DELAY_QUEUE) .withArgument("x-dead-letter-exchange", DEAD_EXCHANGE) .withArgument("x-dead-letter-routing-key", DEAD_ROUTING_KEY) .withArgument("x-message-ttl", 60000) .build(); } // 死信队列:业务真正消费的队列 @Bean public Queue deadQueue() { return QueueBuilder.durable(DEAD_QUEUE).build(); } // 绑定:消息先进入业务队列 @Bean public Binding delayBinding() { return BindingBuilder.bind(delayQueue()) .to(delayExchange()) .with(DELAY_ROUTING_KEY); } // 绑定:死信交换机路由消息进死信队列 @Bean public Binding deadBinding() { return BindingBuilder.bind(deadQueue()) .to(deadExchange()) .with(DEAD_ROUTING_KEY); } }

注意看delayQueue()的声明里那两个.withArgument,一个是x-dead-letter-exchange,一个是x-dead-letter-routing-key,这才是这条队列死信转发的核心设定。我这里TTL设了60秒,意思是消息在这个队列里最多待60秒,之后自动死信。如果你想要每条消息拥有不同的延迟时间,那得用消息级别的TTL属性,而不是在队列上统一指定,这个区分后面我会专门说。

3.3 消息发送端怎么写

写一个消息发送的服务,把消息投递到order.delay.exchange:

@Service public class OrderMessageSender { @Autowired private RabbitTemplate rabbitTemplate; public void sendDelayMessage(String orderId, String payload) { MessageProperties properties = new MessageProperties(); // 业务上:这条消息应该在10秒后被消费 properties.setExpiration("10000"); Message message = MessageBuilder.withBody(payload.getBytes(StandardCharsets.UTF_8)) .setContentType(MessageProperties.CONTENT_TYPE_JSON) .setMessageId(orderId) .setExpiration("10000") .build(); rabbitTemplate.convertAndSend( DeadLetterConfig.DELAY_EXCHANGE, DeadLetterConfig.DELAY_ROUTING_KEY, message ); log.info("延迟订单消息已发送: orderId={}", orderId); } }

这里如果你在队列配置里已经设了x-message-ttl=60000,又在消息上设置了setExpiration("10000"),那要搞清楚:队列级别的TTL是兜底,消息级别的TTL优先。消息如果带了expiration属性,以消息的为准;如果消息没带,才用队列的默认TTL。我上面的代码为了演示灵活延迟,发送端每条消息都指定了10秒,相当于把队列级别的60秒给覆盖掉了。

注意,convertAndSend只是把消息发出去,但消息有没有成功到达Broker、有没有成功路由到队列,需要配合ConfirmCallback和ReturnCallback才能知道。这一步强烈建议在生产环境做,后面我会演示代码。

3.4 死信消费者怎么写

消费者监听的是order.dead.queue,不是原来的业务队列。这里是最容易搞混的地方。

@Component public class DeadLetterConsumer { @RabbitListener(queues = DeadLetterConfig.DEAD_QUEUE) public void handleOrderExpired(String messageContent, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) throws IOException { try { // 转成业务对象 OrderExpireEvent event = JsonUtil.parse(messageContent, OrderExpireEvent.class); // 处理过期订单:更新订单状态、释放库存等 orderService.cancelExpiredOrder(event.getOrderId()); log.info("订单已取消: orderId={}", event.getOrderId()); // 手动确认 channel.basicAck(deliveryTag, false); } catch (Exception e) { log.error("处理过期订单失败", e); // 处理失败,不重新放回队列,进死信 channel.basicNack(deliveryTag, false, false); } } }

这里要注意,消费者一旦处理失败,basicNack的第三个参数要传false。因为你消费的是死信队列,这个消息再重回队列没有意义,它本来就是“被淘汰”的消息。如果业务上确认处理失败,通常就是记录日志、告警,然后人工介入,或者再转发到另一个兜底队列。

我见过一些新人把消费者写在原来的业务队列order.delay.queue上,结果等了半天一头雾水:TTL过了,消息在死信队列里,你在原队列监听当然没有反应。死信队列的消费者才是真正的业务处理方。

4. 死信消息从哪来回哪去:三种触发场景实操

上面的代码演示了TTL触发的死信流转。这节我把三种触发场景分别用代码列一下,你们以后可以根据业务选不同触发方式。

场景一:TTL过期触发(延迟队列标准姿势)

上面已经写过了,不再重复。唯一补充一点:x-message-ttl可以加在队列上,也可以加在消息上。加在队列上是“队列里所有消息最多存活多久”,加在消息上是“单条消息存活多久”。从性能角度讲,队列级TTL更好,因为RabbitMQ可以快速判定整个队列头部消息是否过期;消息级TTL意味着每条消息要额外存储一个过期时间字段,而且只有消息处于队列头部时才能判断是否过期,如果有前面的消息挡着没消费,后面的消息即使先到期也得等着。这个特性在后面排查“延迟时间不准”的时候会用上。

场景二:消费者显式拒绝触发

很多流控场景会用到这个。消费者处理不过来,直接拒绝且不重新入队:

@RabbitListener(queues = "business.queue") public void handleMessage(String message, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) throws IOException { boolean isProcessed = tryProcessMessage(message); if (isProcessed) { channel.basicAck(deliveryTag, false); } else { // 消息被拒绝,并且不重新入队,直接进入该队列绑定的死信交换机 channel.basicReject(deliveryTag, false); } }

注意basicReject和basicNack的区别:reject只能逐条拒绝,nack支持批量拒绝且可以指定是否requeue。日常推荐用nack。

场景三:队列长度溢出触发

@Bean public Queue limitedQueue() { return QueueBuilder.durable("business.limited.queue") .withArgument("x-max-length", 1000) .withArgument("x-overflow", "drop-head") .withArgument("x-dead-letter-exchange", DEAD_EXCHANGE) .withArgument("x-dead-letter-routing-key", DEAD_ROUTING_KEY) .build(); }

当队列长度达到1000后,新的消息到来时,最老的那条消息会被死信转发。drop-head表示把队头丢了,reject-publish表示直接拒绝新消息入队(不会死信,只会让发布方收到Return回调)。

这三种触发方式背后有个统一的响应链条:消息源队列先把死信扔给它配置的DLX,DLX再根据自身的绑定规则推送到对应队列。所以死信队列能不能收到消息,核心变量就是源队列上的x-dead-letter-exchange和x-dead-letter-routing-key配置得对不对。

5. 实战踩坑:我用这套方案时遇到的四个坑

光跑通流程不算完,生产环境里能平安无事才是真本事。我总结几个自己实际踩过或者帮别人擦过的坑,非常有代表性。

5.1 坑一:消息过期了,但死信队列里一直收不到

这是出现频率最高的问题。我当时排查过一个小伙伴的代码,他配置看起来全对,业务队列也声明了死信交换机,但消息过期后就像人间蒸发了一样,哪都没去。

排查链路是这样的:

  1. 先看RabbitMQ控制台,进入Queues页面,找到业务队列,点进去看Message TTL那栏,发现数字还在,说明TTL配置被成功读取了。
  2. 再看业务队列的Dead Letter Exchange那栏,发现指向的DLX没错。
  3. 最后看DLX的Binding列表,发现和死信队列绑定的RoutingKey是order.dead.update,而业务队列里配置的x-dead-letter-routing-key写的是order.dead.key。

问题就出在最后一步,绑定键都对不上,消息怎么转发?死信交换机收到死信后,拿order.dead.key做路由,结果找不到任何匹配的队列,消息直接被丢弃了。排查死信问题,第一步永远应该看Exchange的Binding,而非Queue的Declaration。

5.2 坑二:消息TTL设为0,确实立刻变死信,但是消费端收到了前一条消息的重复数据

这个坑在设计延迟队列时尤其隐蔽。假设队列里有两条消息,消息A的TTL是30秒,消息B的TTL是10秒,按照“先进先出”的队列原理,只有队头的消息过期才能被判定,如果队头消息还没到期,即使后面的消息早该过期了,RabbitMQ也不会扫描它。等30秒过去,第一条过期了,第二条才跟着“顺延”过期,导致整体延迟时间是错的。

所以,消息级TTL实现延迟队列,只能做到“延时递增”的场景,做不到“后发先至”。想要不同消息精确控制延迟,就得给不同延迟时间建不同的队列,或者用专门的延迟插件方案。这也是网上为什么一直在争论RabbitMQ延迟队列的精度问题。

经验做法:如果延迟时间档位固定(比如5分钟、10分钟、30分钟那几档),直接按延迟档位拆队列,每个队列设不同的TTL,稳定高效。如果延迟时间杂而多变,建议评估是否上专门的延迟消息组件,不要硬扛。

5.3 坑三:消费失败,消息无限重新入队,死信队列形同虚设

有些人在消费方法上直接标了@RabbitListener,并且方法里抛异常,但没做手动ACK,默认行为是消息一直在队列里重试,永远不会进死信。因为默认的AcknowledgeMode是AUTO,Spring会帮你自动确认,一旦抛异常,它会把消息当成“未确认”但其实是“重回队列”处理。

如果你想让失败的消息进死信队列,必须:

spring: rabbitmq: listener: simple: acknowledge-mode: manual

然后用channel.basicNack(deliveryTag, false, false)手动确认。别把requeue参数手滑设置成true,那跟没设置一样。

5.4 坑四:死信队列消费者根本不在线,消息就藏在死信队列里

这个其实不能算RabbitMQ的坑,是业务设计要考虑的。死信队列如果不挂消费者,那消息就一直积压。尤其延迟队列场景,消息从业务队列转发到死信队列属于“时间到了”,此时消费者在线是正常情况。但有一种情况容易忽略:死信消费逻辑和业务代码共用同一个应用进程,当应用重启或者部署发布期间,业务队列还在不断产生死信,积压几十万条,等消费者起来后会瞬时拉取非常多消息,造成系统卡顿。

我的做法是死信队列的消费逻辑独立配置一个小的线程池,做一个轻量开关控制消费启动时机,错峰消费。

spring: rabbitmq: listener: simple: concurrency: 5 max-concurrency: 10 prefetch: 50

并发数别开太大,死信处理的业务往往比较重,5到10个消费者足够。prefetch设成50意思是最多一次性拉取50条未ACK的消息,防止内存撑爆。

6. 进阶补强:消息轨迹追踪与生产落地的细节

讲完坑,我再额外分享两个生产环境比较实用的点。

6.1 Confirm和Return回调,别让消息“失联”

发送端光执行convertAndSend,其实无法知道消息最终命运。RabbitMQ的完整投递链路是:客户端到交换机、交换机到队列这两段。

Spring Boot中开启Confirm和Return后,可以这样检测:

rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> { if (ack) { log.info("消息成功到达交换机: id={}", correlationData.getId()); } else { log.error("消息发送到交换机失败: id={}, cause={}", correlationData.getId(), cause); } }); rabbitTemplate.setReturnsCallback(returned -> { log.error("消息无法路由到队列: exchange={}, routingKey={}, replyText={}, message={}", returned.getExchange(), returned.getRoutingKey(), returned.getReplyText(), new String(returned.getMessage().getBody())); });

实战里配合CorrelationData使用,把业务订单号绑定进去,只要消息投递失败就能精确定位到哪一笔订单的消息出事了。

6.2 死信队列本身的监控

死信队列的堆积量往往直接反映业务的异常情况。如果延迟订单队列的死信队列持续增长,说明同事的消费端可能挂了;如果某个普通业务队列的死信突然暴涨,可能是消费逻辑出了bug导致大面积Nack。

我的习惯是定时任务或监控系统每两分钟统计一次各个死信队列的消息数,超过阈值直接告警。这里给出一个简单的REST API,可以用RabbitAdmin直接查询:

@RestController public class RabbitQueueMonitorController { @Autowired private RabbitAdmin rabbitAdmin; @GetMapping("/mq/dead/queue-info") public Map<String, Integer> getDeadQueueMessageCount() { Properties props = rabbitAdmin.getQueueProperties(DeadLetterConfig.DEAD_QUEUE); if (props == null) { return Map.of("count", -1); } Integer count = (Integer) props.get(QueueBuilder.X_QUEUE_TYPE); // 实际应取 QUEUE_MESSAGE_COUNT Integer messageCount = (Integer) props.get("QUEUE_MESSAGE_COUNT"); return Map.of("count", messageCount == null ? 0 : messageCount); } }

提示:QUEUE_MESSAGE_COUNT只是队列中Ready状态的消息数统计,不含未被确认的消息,监控时记得把Unacked也算上,或者直接用管理API获取聚合数据。生产环境可以用RabbitMQ自带的管理HTTP API来实现更细粒度的监控。

6.3 延迟档位拆队列的实践参考

我实际项目中订单超时关闭最终用了三个队列,分别是5分钟、30分钟、1天。不同延迟时间只让消息进入对应TTL的业务队列,由同一个死信队列兜底:

延迟队列TTL死信路由键业务用途
order.delay.5m.queue300000order.dead.key未支付提醒
order.delay.30m.queue1800000order.dead.key库存释放
order.delay.1d.queue86400000order.dead.key自动确认收货

三层队列共用同一个死信交换机order.dead.exchange,只是RoutingKey保持统一,死信队列只需写一个消费者。如果需要区分不同延迟消息的业务类型,可以在消息体里带上业务类型字段,消费端按类型分发逻辑。

7. 收尾:实践里的几条判断

开发这半年多,我对死信队列的理解可以浓缩成一句话:在RabbitMQ里,死信队列不是处理“死”消息的,而是处理“没有在正确时间被处理”的消息的机制。它最大的价值不是兜底失败,而是让你能控制消息流转节奏。

如果在项目里碰到以下几个信号,我的一般选择是:

  • 需要延迟执行,且延迟档位不超过几个固定的档位,优先用TTL+死信队列方案,零插件、零额外中间件。
  • 需要每条消息独立控制延迟时间,且频率高,优先考虑RabbitMQ延迟插件或专业消息队列。
  • 消费端处理失败后希望隔离异常数据,用manualACK +basicNack(deliveryTag, false, false)。
  • 需要监控消息链路是否完整,把ConfirmCallback、ReturnCallback、死信队列积压量三件套都用上。

关于代码本身的完整工程,我这边在模拟项目X里已经按这套思路封装好了,核心就是上面的配置类加一个死信消费者。实际业务接入时只要改Exchange、Queue、RoutingKey的名称,再在消费者里写业务逻辑即可。最后再提醒一句,配置Bean的名称不要随意改动,尤其当多个@Configuration类之间存在Bean依赖时,方法名变了很容易出现意外行为,别问我是怎么知道的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询