Skip to content

04 - 死信队列与延迟队列

面试高频度:⭐⭐⭐⭐ 考查方式:方案设计题、场景应用题


一、什么是死信(Dead Letter)?

1.1 死信的本质

"死信"是一个结果,不是一个属性。消息不会天生就是死信——它是被处理过程中某些条件触发后,才变成死信的。

可以类比现实中的"退回件":一封信投递失败(地址不存在、收件人拒收、超时未取),邮局就会把它退回。死信队列就是 RabbitMQ 的"退回件收发室"。

1.2 消息变成死信的三种场景

场景触发条件典型原因
被拒绝Consumer 调用 basicRejectbasicNack,且 requeue=false业务处理失败,不想重试(如数据格式错误)
TTL 过期消息在 Queue 中的存活时间超过设定的 TTL延迟队列场景,或者消息因积压超时
队列满Queue 达到 x-max-lengthx-max-length-bytes 上限,新消息到来时旧消息被挤出队列积压保护,防止 Broker 内存溢出

关键理解:死信不是"错误",而是一种预设的消息流转机制。你可以在声明 Queue 时就告诉 Broker:"如果有消息变成死信,请把它转到指定的交换机去。"

1.3 为什么需要死信队列?

没有死信队列时,被拒绝或过期的消息直接消失——你甚至不知道有过这条消息。死信队列提供了:

  • 兜底保障:消费失败的消息不会丢,可以后续人工处理或重试
  • 可追溯性:死信消息会携带 x-death Header,记录它"怎么死的"、从哪来的
  • 延迟队列的基础:TTL + DLX 组合是 RabbitMQ 实现延迟消息的经典方案

二、死信队列的工作原理

2.1 消息流转路径

Producer → Normal Exchange → Normal Queue → Consumer(处理失败,NACK)

                                    │ 消息变成死信

                              DLX Exchange → DLX Queue → Dead Letter Consumer
                                                          (人工处理 / 告警 / 补偿)

核心配置点:在声明 Normal Queue 时,通过 x-dead-letter-exchange 参数指定死信交换机。这样当 Normal Queue 中的消息变成死信时,Broker 会自动把它发布到指定的 DLX Exchange,再由 DLX Exchange 路由到对应的 DLQ。

注意:死信消息会携带额外的 x-death Header,包含来源队列、死信原因、死信次数等信息。Dead Letter Consumer 可以根据这些信息决定如何处理(重试、告警、记录)。

2.2 死信消息的 Header 信息

死信消息不是原封不动地转发——Broker 会自动附加 x-death 数组,记录死信的历史:

字段含义用途
queue来源队列名定位消息来自哪个队列
reason死信原因:rejected / expired / maxlen判断是消费失败还是超时还是队列满
count死信次数防止死循环(消息在多个死信队列间流转)
time死信时间审计和排查

三、延迟队列——死信的最重要应用

3.1 为什么需要延迟队列?

很多业务场景需要"一段时间后执行某个动作":

场景延迟时间动作
订单超时关闭30 分钟检查是否支付,未支付则关闭
支付超时提醒15 分钟提醒用户尽快支付
会议开始提醒10 分钟通知参会人
红包 24h 退回24 小时未领取红包自动退回

传统方案是定时任务轮询数据库——每分钟扫描一次"创建超过 30 分钟且未支付的订单"。但这有三个问题:

  1. 精度差:最多有 1 分钟的误差(29 分钟付的可能已被关,31 分钟付的可能还没被扫到)
  2. 数据库压力大:高峰期每分钟扫一次大表,拖慢主库
  3. 扩展性差:订单量增大后扫描时间变长,1 分钟扫不完就超时了

延迟队列的优势:精确到消息级别、不依赖数据库扫描、天然支持高并发

3.2 方案一:TTL + DLX(原生方案)

原理:把消息发到一个设置了 TTL 的队列,消息在队列中等待直到 TTL 过期,过期后变成死信,自动路由到 DLX Exchange → DLQ → 消费者处理。

TTL 有两种设置方式,行为差异很大:

方式配置位置行为问题
队列级 TTL声明 Queue 时设置 x-message-ttl队列中所有消息统一过期时间,到期后直接过期不灵活,一个队列只能有一种延迟时间
消息级 TTL发送消息时设置 expiration 属性每条消息可以有不同的过期时间有排队问题(见下文)

消息级 TTL 的排队问题——这是一个极其重要的坑:

队列头部:[msg-A: TTL=60s]  →  [msg-B: TTL=10s]  →  [msg-C: TTL=5s]

RabbitMQ 只在消息到达队列头部时才检查是否过期。
所以:
- msg-C(TTL=5s):要等 msg-A 和 msg-B 都过期后才被检查,实际等待远超 5 秒
- msg-B(TTL=10s):要等 msg-A 过期后才被检查
- msg-A(TTL=60s):60 秒后过期 → 触发检查 → 发现 msg-B 也过期了 → 发现 msg-C 也过期了

结果:msg-C 本该 5 秒后过期,实际上可能要等 60 秒。这导致延迟时间不精确。

为什么消息级 TTL 有排队问题而队列级 TTL 没有? 因为队列级 TTL 的检查逻辑不同——Broker 会为队列中每条消息记录"到期时间戳",到期后直接标记过期,不依赖队列头部的位置。而消息级 TTL 只在消息被"处理"(到达头部)时才检查。

3.3 方案二:延迟队列插件(推荐)

原理rabbitmq_delayed_message_exchange 插件在 Broker 端实现了一个特殊的 Exchange 类型(x-delayed-message)。消息发送到这个 Exchange 时,Broker 不会立即路由,而是在 Broker 端存储并计时,到期后才投递到绑定的 Queue。

为什么插件方案没有排队问题? 因为每条消息独立计时——Broker 为每条延迟消息维护一个定时器(内部使用 Erlang 的 timer wheel),到期后独立触发,不受其他消息影响。

3.4 两种方案对比

维度TTL + DLX延迟插件
延迟精度消息级 TTL 不精确(排队问题),队列级 TTL 精确但不灵活精确,每条消息独立计时
多延迟值消息级 TTL 可以不同但不精确;队列级 TTL 精确但只能一种时间同一队列可以有不同的延迟时间
实现复杂度中等(需要配 DLX + 延迟队列 + 超时处理队列)简单(一个特殊 Exchange)
可靠性依赖 TTL 计时机制插件内部存储,重启后恢复
生产推荐延迟时间固定且精度要求不高时可用推荐

四、重试机制与死信的配合

4.1 为什么不能无限制 requeue?

消费失败后直接 requeue=true 最简单,但如果失败原因是代码 Bug(如 NullPointerException),消息会反复失败、反复入队——形成死循环,浪费资源,且其他正常消息也被堵在后面。

4.2 重试 N 次后进死信的实现思路

核心思路:在消息的 Header 中维护一个重试计数器,每次消费失败时计数 +1,达到阈值后发到死信队列:

第 1 次消费失败 → x-retry-count=0 → requeue=true,计数 +1
第 2 次消费失败 → x-retry-count=1 → requeue=true,计数 +1
第 3 次消费失败 → x-retry-count=2 → requeue=false → 进入 DLX

注意:这种方式需要在 requeue 前"重新发送"一条带新计数的消息,然后 ACK 原消息,因为 RabbitMQ 不允许修改已投递消息的 Header。


五、面试常见追问

Q1:延迟队列选型建议?

延迟时间固定(如 30min),精度要求不高 → TTL + DLX
延迟时间灵活(如 1s~24h 不等),精度要求高 → 延迟插件
不能安装插件 → Redis ZSET + 时间轮询,或 RocketMQ 延迟消息

Q2:死信队列的消息能重试吗?

可以。Dead Letter Consumer 可以把消息重新发送到 Normal Queue。但必须限制重试次数(通过 Header 中的 x-death.count 判断),否则会在两个队列间无限循环。

Q3:TTL 过期消息为什么不精确?

因为消息级 TTL 只在消息到达队列头部时才检查。如果前面的消息 TTL 更长,后面的消息不会被及时检查。解决方案:使用队列级 TTL 或延迟插件。


六、关联笔记

  • **消息可靠性保障** - ACK 机制与持久化
  • **消息幂等性与重复消费** - 死信重试中的幂等设计
  • **消息积压与消费能力** - 消费失败发死信的限流策略

Knowledge4J — Java 知识库