做订单系统这几年,最让我头疼的不是代码写不出来,而是消息处理失败之后那一堆“烂摊子”:下游库存服务超时,订单消息消费失败,把它重新塞回队列,几秒钟后又被同一个消费者捞起来,再处理一遍,再失败,再塞回——如果没人干预,这条消息能在队列里折腾一整天,业务日志刷得满屏报错。更烦人的是,它还会把排在后面的正常消息一起拖慢,消费者线程被它占着,整个系统的吞吐量肉眼可见地往下掉。
其实问题的根源不在消费者不够努力,而在于我们缺少一套“处理不了的消息”的去向机制。RabbitMQ 里恰好提供了对应的基础设施:死信队列(DLX)。这一篇我不打算讲基础概念,直接从异常消息的重试阶梯、失败消息归档这两个实战方向展开,最后分享几个我在生产环境里踩过的坑。如果你已经会用 RabbitMQ 收发消息,但被线上消息失败、堆积甚至丢消息的问题困扰,这篇文章应该能给你一个明确的落地方案。
1. 为什么说死信队列是消息系统的“故障保险丝”
1.1 死信消息到底长什么样
死信队列和普通业务队列没有本质区别,它同样是一个队列,有 exchange、routing key、消费者。唯一的区别在于消息来源:普通队列里的消息来自生产者投递,死信队列里的消息来自“另一个队列的转存”。
当一条消息在队列里被判为死信并被转发时,RabbitMQ 会在消息的 headers 里附加一条x-death记录,里面包含死信原因、原队列、时间和次数。举个例子:
x-death: [{ "reason": "expired", "queue": "order.pay.timeout.queue", "time": "2025-01-12 14:32:01", "count": 1 }]reason字段告诉你它为什么变成死信,queue字段记录它之前所在的队列名,count记录它是第几次因为同样的原因死掉。这些字段不是摆设,后面做延迟重试和重试次数控制时,全靠它们来判断当前消息已经“折腾”了几轮。
1.2 不用死信队列的三种尴尬结局
我见过几种不用死信队列的项目,结局都比较相似。
第一种是无限 requeue。消费者捕获异常后调用 basicNack 并把requeue参数设为 true,希望“过一会儿再处理”。但 RabbitMQ 的 requeue 是立即重新投递,不是延迟投递,所以一条失败消息会以每秒几十次甚至上百次的频率反复进入消费者,CPU 被打满,日志疯狂刷屏,正常消息也跟着遭殃。
第二种是直接丢弃消息。代码里 try-catch 吞掉异常,然后手动 ack。这个方案表面清净,实际上业务已经停留在中间状态——比如“订单已支付但库存没扣成功”,而且从 RabbitMQ 管理界面完全看不出曾经出过问题,只能翻业务日志慢慢找。
第三种是把失败消息全部塞进同一个 retry 队列,但没有做延迟分级。消息确实保住了,但消费者进程永远在重试同一批坏消息,重试队列不断膨胀,最后连 RabbitMQ 管理页面的队列列表刷出来都要卡几秒。这种方案本质上是把“处理失败”变成“永远在重试的路上”,问题没有真正被受理。
死信队列的存在,把“失败消息去哪了”这个问题从应用代码层面提升到了队列策略层面。它像消息系统的故障保险丝:反正你当前处理不了,先给你一个明确的地方存起来,再由人或者后续程序决定是重试、归档还是人工介入。
2. 死信的三条产生路径与触发参数解析
2.1 消息 TTL 过期触发的死信
RabbitMQ 的消息过期有两种设置方式:一种是声明队列时指定x-message-ttl,队列里所有消息超过该时间未消费就会被判定过期;另一种是在发布消息时给单条消息设置expiration属性,精确控制这一条消息的存活时长。
声明的队列参数大致是这样:
Map<String, Object> args = new HashMap<>(); args.put("x-message-ttl", 60000); args.put("x-dead-letter-exchange", "exchange.dead"); args.put("x-dead-letter-routing-key", "rk.dead");当队列里的消息过了 60 秒仍未被消费,RabbitMQ 会把它转投到你指定的exchange.dead,再根据rk.dead路由到死信队列。这里有一个关键认知:过期判断是在消息即将被投递给消费者之前做的,不是后台有一个定时器在扫队列。如果一个队列一直没有被消费,消息虽然过期了,但死信转发动作可能不会立刻发生,直到有消费者来拉取时才会触发。
2.2 队列积压触发的容量型死信
给队列设置x-max-length或x-max-length-bytes,当队列中的消息数量或字节数达到上限时,新消息的进入会逼迫老消息产生死信。默认情况下,是从队列头部(最早的消息)开始丢弃,这些被挤掉的消息会进入死信队列。
这个机制很适合做“丢弃旧消息、保留新消息”的场景。比如某个实时推送队列,如果消费端短暂故障导致消息积压,业务上可能宁愿处理最新的一批消息,也不愿意回头补很久之前的数据。这时限制队列长度,把超容量的老消息转入死信队列归档,既保护了队列不会无限增长,也为后续排查留下了线索。
不过要注意:队列满触发的死信,和 TTL 过期触发的死信,在x-death里的reason是不一样的,前者是maxlen,后者是expired。别在排查问题的时候把原因搞混。
2.3 消费端拒绝且不重回队列
这是最常用的方式。消费者处理消息时如果抛出了异常,可以调用basic.reject或basic.nack,并把requeue参数设置为 false,RabbitMQ 会认为你明确拒绝了这条消息,且不把它放回原队列,随即触发死信转发。
Spring AMQP 中对应的写法是:
@RabbitListener(queues = "order.business.queue") public void handleOrder(OrderMessage message, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException { try { // 业务处理 } catch (Exception e) { // 不重回原队列,转死信 channel.basicNack(tag, false, false); } }为什么我们不直接用requeue=true?原因在 1.2 里已经提到:requeue 是立刻重回原队列头部,既没有延迟,也没有次数控制,本质上等于“重启一个坏掉的程序,却不做任何等待”。
2.4 一个容易忽略的细节:死信转发时的消息属性变化
消息在被转发到死信队列时,并不是原封不动的。几个常见的属性变化值得关注:
expiration字段会被移除。如果一条消息因为 TTL 过期而死,转发到死信队列时它不会再带着原来的过期时间,防止它到了死信队列又被同样的 TTL 再次过期。- 消息会新增
x-death头,记录死信原因和原队列信息。 - 如果前一次已经成为死信的消息再次变成死信,RabbitMQ 会把新的死信信息合并进
x-death数组,count会累计。
这三点在做重试次数判断时非常关键。比如一条消息第一次进死信队列,x-death里 count 是 1;如果它从死信队列重新投递到业务队列又失败了,再次进入死信时,count 可能变成 2。把这个字段读出来,就能用来决定“还允许重试几次”。
3. 基于死信的延迟重试:用 TTL 搭出重试阶梯
3.1 为什么不能直接 requeue
先说清楚一个容易踩坑的认知点:RabbitMQ 的 requeue 是“马上重新入队”,不是“延迟一段时间再入队”。你可能会想,我在捕获到异常之后 sleep 几秒钟,再调用 requeue,是不是就有延迟效果了?
理论上能实现,但在生产环境里千万别这么做。你在消费者线程里 sleep,相当于把整条消费链路阻塞住,后面的消息全部排队等着。如果一个消费者同时订阅多个队列,其他队列的消息也会被阻塞掉。更不要说 sleep 状态下进程重启、消息重复投递、计数丢失这些连锁问题。
正确的思路是:让消息按照我们的意愿,经过一段时间后再重新回到业务队列。RabbitMQ 原生机制里实现延迟投递最稳妥的方式,就是借助“死信 + TTL”的组合——消息进入一个设置了 TTL 的队列,等它过期,自动转入业务队列。
3.2 一级重试队列的完整搭建配置
假设业务队列叫order.business.queue,它的死信交换机是exchange.dead,先看完整链路:
- 生产者把消息投递给
exchange.business,路由到order.business.queue。 order.business.queue消费失败,消费者basicNack且 requeue=false,消息进入exchange.dead。exchange.dead根据路由键把消息投递给order.retry.queue(重试队列)。order.retry.queue设置了x-message-ttl = 5000,消息进来 5 秒后过期。- 该队列的死信交换机重新指向
exchange.business,过期的消息再次回到order.business.queue,进入下一轮消费。
关键配置如下:
// 业务队列 Map<String, Object> bizArgs = new HashMap<>(); bizArgs.put("x-dead-letter-exchange", "exchange.dead"); bizArgs.put("x-dead-letter-routing-key", "rk.retry"); Map<String, Object> retryArgs = new HashMap<>(); retryArgs.put("x-message-ttl", 5000); retryArgs.put("x-dead-letter-exchange", "exchange.business"); retryArgs.put("x-dead-letter-routing-key", "rk.business");这个设计的巧妙之处在于:重试队列本身没有消费者,它只是充当一个“延迟等待区”。消息到了之后什么也不干,静置 5 秒,然后因为 TTL 过期自动回到业务队列。你不需要额外写任何定时任务。
注意:这里要求
exchange.dead必须是 Topic 类型的交换机,或者至少支持对应的路由。路由键rk.retry要把消息路由到重试队列,rk.business要把消息路由回业务队列。
3.3 从固定延时到指数退避的设计
固定 5 秒的延迟只适合简单的临时故障。如果下游服务已经宕机十分钟,消息每 5 秒就回来一次,10 分钟内重试了 120 次,对下游和消费者都是折磨。更合理的做法是分级延迟,也就是指数退避。
实现方式很直接:准备多级重试队列,每一级 TTL 不同。比如:
| 重试层级 | 队列名 | TTL | 说明 |
|---|---|---|---|
| 第一级 | order.retry.1s.queue | 1秒 | 快速重试,处理瞬时抖动 |
| 第二级 | order.retry.10s.queue | 10秒 | 短等待,处理小故障 |
| 第三级 | order.retry.1m.queue | 60秒 | 中等等待,处理较长故障 |
| 第四级 | order.retry.5m.queue | 300秒 | 长等待,预留恢复时间 |
消息第一次失败进入第一级,回来后如果还是失败,我们需要判断它该进第二级还是直接归档。这个判断可以通过读取x-death里的 count 次数来完成。比如:
int deathCount = getDeathCount(message); String retryQueue = selectRetryQueue(deathCount); // 根据次数投递到不同 TTL 的延迟队列每个层级的延迟队列,死信交换机都指向业务交换机,路由键也相同。但业务队列的死信路由键会根据当前重试次数动态决定,保证消息能流向下一级。
简单总结一下这套逻辑:消费失败不是简单地塞回原队列,而是先做一个“伤势评估”,决定下一次什么时候回来、还能再试几次。对系统而言,这是一种更节制的重试行为。
3.4 重试次数的控制:x-death 捕获数组与业务计数字段
x-death虽然记录了死信次数,但在生产环境里依赖它做一些逻辑判断要谨慎。因为 count 是累计的死信次数,如果一条消息先因为过期死了一次,又因为拒绝死了一次,数组里可能有多条记录,count 的语义就变得复杂了。
更可靠的做法是在业务消息体里增加一个retryCount字段,每次消费失败时由消费者自行累加并重新投递到延迟队列:
{ "orderId": "A12345", "retryCount": 2 }消费逻辑变为:解析 retryCount,如果小于 3,返回延迟队列等待下次重试;如果已经等于 3,则投递到死信归档队列,不再折腾。
使用业务字段的好处是:它可以随消息走完全程,即使在复杂的转发链路上也不会丢失;而且判断逻辑是应用自己控制的,灵活度高。x-death更适合做审计排查,不太适合作为唯一的重试次数依据。
4. 归档链路设计:从死信队列到历史消息仓库
4.1 归档前需要先做的三件事
重试次数用尽之后,消息不能直接删掉。它在业务上代表一条没有被正确处理的请求,可能涉及资金、库存或者用户权益。在进入归档阶段之前,有三件事必须想清楚:
首先,确定归档的数据结构。至少需要包含:原始消息体 payload、消息的 exchange 和 routing key、首次进入队列的时间、最后一次死信的原因和时间、已重试次数、以及业务ID。这些字段在后续排查时缺一不可。
其次,规划归档的存储位置。最常见的做法是写入数据库,因为查询方便、可对接监控和告警系统。如果消息体很大,也可以只把消息体存在对象存储里,数据库里保留索引。存储介质是 MySQL、Elasticsearch 还是普通文件,取决于你后续要怎么用这些归档数据。
最后,给归档消息定义生命周期。归档不是永久保留,建议定义一个保留策略,比如保存 30 天后由定时任务清理。否则时间长了,归档库会无限膨胀,查询越来越慢,最终失去归档的价值。
4.2 消费死信队列的归档程序写法
死信归档的消费者和普通消费者没有本质区别,监听归档队列即可。需要注意的点在于:归档处理本身必须保证幂等,并且尽量不失败。
一个比较稳妥的写法是:
@Component public class DeadLetterArchiver { @RabbitListener(queues = "order.dead.archive.queue") public void archive(Message message, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException { long start = System.currentTimeMillis(); try { String body = new String(message.getBody(), StandardCharsets.UTF_8); // 根据业务ID去重,防止重复归档 boolean exists = archiveRepository.existsByOrderId(parseOrderId(body)); if (!exists) { archiveRepository.save(buildArchiveEntity(message)); } channel.basicAck(tag, false); } catch (Exception e) { // 归档失败,而且不需要再重试时,可以考虑记录后丢弃 log.error("归档失败, tag={}", tag, e); channel.basicAck(tag, false); } } }示例里我用了一个有点“激进”的做法:归档失败也直接 ack。原因是归档队列的消息本身已经没有重试价值了,如果再次 nack 让它死信循环,反而会造成队列堆积。更合理的方式是把它写到一个失败补偿表里,后续由另一个任务去重试记录。
4.3 归档消息的生命周期管理与查询
归档消息进入数据库之后,建议给它加一个status字段,记录当前状态:已归档、人工处理中、已修复。这样当业务方反馈“某个订单状态不对”时,运维人员可以直接查归档表,找到对应的消息体,看清它经历了多少次重试、因为什么原因失败、最终卡在哪里。
归档表还需要加一条独立的archived_at时间戳。基于这个字段,写一个轻量的清理任务,定期删除 30 天前的记录。我见过不少团队在归档阶段花了很多功夫,却没有考虑数据生命周期,结果半年后归档库比业务库还大,非常尴尬。
如果你的场景对实时性有要求,还可以额外配置一套告警:归档队列有消息进来时,说明有业务消息已经“救不活”了,此时触发通知给值班人员,尽快人工介入。这样一来,死信归档就不仅仅是一个被动的存储行为,而是变成了主动发现问题的信号源。
5. 实测中踩过的坑:死信循环、消息乱序与幂等
5.1 死信又死信:循环转发是怎么发生的
这是新手最容易踩的坑。假设order.business.queue的死信交换机是exchange.dead,而exchange.dead的路由又把消息恰好绑回了order.business.queue——那么一条处理失败的消息会立刻变成死信,被转发回原队列,再次被消费,再次失败,再次转发,形成一个无限循环。
这个循环可能不会像 requeue 那么频繁,但它会在后台持续占用网络资源与队列读写,而且消息的x-death里的 count 会一直膨胀,直到把整个消息头撑到很大,最后可能因为消息过大被拒绝路由。
排查思路很简单:查看业务队列的x-dead-letter-exchange指向哪个交换机,再查看该交换机绑定了哪些队列。如果绑定关系里出现了原队列本身,就说明循环链路已经形成。解决办法是把死信路由键拆分成两个方向:一个指向延迟重试队列,一个指向人工归档队列,二者都不再回到业务队列,循环自然就断了。
5.2 延迟重试引发的消息顺序漂移
在延迟重试机制下,消息的顺序性会成为一个问题。假设同一个用户连续下了两笔订单:订单 A 先进入队列,但因为下游库存服务暂时异常,被转进延迟队列等待 10 秒;订单 B 后进入队列,下游服务刚好恢复了,消费成功。于是整个系统的视角里,订单 B 先处理完成,订单 A 后处理完成,业务上如果依赖顺序,就会出现“订单 B 已经支付成功,订单 A 还在等待重试”的怪象。
这个问题本质上是重试机制与顺序性之间的矛盾。如果业务严格要求同一条业务链上的消息有序,一般有两条应对思路:一是按业务键(比如用户ID、订单ID)做分区,让同一个业务键的消息始终被同一个消费者处理,并且在该消费者内部用状态机判断,只有前一步完成才允许处理后一步;二是在消息体里带上版本号或业务时间戳,消费端发现当前处理的消息版本早于已处理版本时,直接标记为过期消息,放弃处理。
对于大多数偏重最终一致性的业务场景,来说,第二条路更实用。不要追求所有消息严格有序,而是保证同一业务键下最终结果正确即可。
5.3 归档消费的幂等与重复投递
死信队列本身和普通队列一样,也可能出现消息重复投递。比如归档消费者在处理一件事情时发生网络抖动,RabbitMQ 没有收到正确 ack,会把同一条消息再次投递出来。如果归档程序无脑 insert,数据库里就会出现重复记录。
幂等方案在 4.2 里已经提过:以业务ID作为唯一索引。归档保存前先做一次存在性检查,或者直接在表上给业务ID加唯一约束,插入时使用“存在则跳过”的写入方式。这一步看似简单,但能覆盖很多重复投递的隐蔽场景。
另外还要注意,归档操作本身不要因为单条消息失败而阻塞队列消费。否则死信队列的消息越积越多,最终归档积压,管理页面上红点一片,大家又开始加班排查。
6. 一些值得保留的设计习惯
最后分享几个我在多次实践中沉淀下来的设计习惯,谈不上标准答案,但确实帮我少踩了不少坑。
归档队列的消费者可以单独拆一个应用进程跑,不要和业务消费者混在一起。归档逻辑虽然不重,但它涉及数据库写入,如果和在线业务共用线程池,有可能在数据库抖动时互相影响。拆分以后,业务的稳定性和归档的吞吐量都能独立调优。
延迟重试的时间参数不要让每一条消息都写到配置中心,频繁改动反而容易出问题。通常的做法是固定几档 TTL,按档位选择,配置简单也容易排查问题。真正需要动态调整的场景不多,不要过早引入复杂度。
重试过程中的日志比最终成功或失败的日志更重要。建议每一步都打上业务ID、当前队列、死信原因和重试次数。线上排查问题时,往往就是靠这些日志把完整的重试链路还原出来的。
最核心的一条:死信队列不是用来兜底所有错误的“垃圾桶”,它是你可以对失败消息施加控制的机制。在使用它之前,先想清楚每条消息的失败到底意味着什么——是临时故障要重试、是数据错误要直接归档、还是需要人工介入。想清楚了再设计队列和路由,整个消息系统的健壮性会提升一个档次。