先声明我下面要讲的这套玩法本身就是我在实际业务里被“逼”出来的。当时做的一个模拟电商订单系统有个场景用户下单后十分钟内未支付订单就得自动取消库存还得补回去。一开始用定时任务扫表数据量一上来DB压力大不说扫表频率和实时性之间怎么平衡都是个麻烦。后来换成了RabbitMQ的延迟消息方案——其实严格来说是“TTL过期消息 死信队列”的组合拳一次性把事情解决得明明白白。这篇博文就把我实践下来的完整思路、核心配置、以及那些光看官方文档绝对学不到的坑全部拆开来讲。1. 死信队列到底解决了什么问题先纠正一个常见误解死信队列不是“消息消费失败后的重试队列”它的本质是一个消息转运站。RabbitMQ里有一条规则当一条消息在特定情况下没有被正常消费它不会被直接丢弃而是会被转发到你预先指定的另一个exchange里那个exchange再路由到对应的queue这个queue就是死信队列。触发死信的条件就三种区分清楚非常关键消息被消费者显式拒绝消费时调用basic.reject或basic.nack并且requeue参数设置为false。注意一旦带上requeuefalse消息就进死信如果requeuetrue消息是重新放回原队列这种情况不会触发死信转发。消息TTL过期给消息或队列设置了x-message-ttl消息在队列里待的时间超过这个阈值就自动变成死信。队列长度溢出队列设置了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消息正常发送时走的交换机负责把消息路由到业务队列。死信ExchangeDLX专门用来接收死信的交换机。它不过是给普通队列业务队列设置了一个属性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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-amqp/artifactId /dependencyapplication.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.exchangeService 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-ttl60000又在消息上设置了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回调。这三种触发方式背后有个统一的响应链条消息源队列先把死信扔给它配置的DLXDLX再根据自身的绑定规则推送到对应队列。所以死信队列能不能收到消息核心变量就是源队列上的x-dead-letter-exchange和x-dead-letter-routing-key配置得对不对。5. 实战踩坑我用这套方案时遇到的四个坑光跑通流程不算完生产环境里能平安无事才是真本事。我总结几个自己实际踩过或者帮别人擦过的坑非常有代表性。5.1 坑一消息过期了但死信队列里一直收不到这是出现频率最高的问题。我当时排查过一个小伙伴的代码他配置看起来全对业务队列也声明了死信交换机但消息过期后就像人间蒸发了一样哪都没去。排查链路是这样的先看RabbitMQ控制台进入Queues页面找到业务队列点进去看Message TTL那栏发现数字还在说明TTL配置被成功读取了。再看业务队列的Dead Letter Exchange那栏发现指向的DLX没错。最后看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是AUTOSpring会帮你自动确认一旦抛异常它会把消息当成“未确认”但其实是“重回队列”处理。如果你想让失败的消息进死信队列必须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 MapString, 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依赖时方法名变了很容易出现意外行为别问我是怎么知道的。
阅读完成 · 觉得有帮助?