KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
04 · 死信与延迟:x-death 结构与延迟队列的代价 — keel 龙骨
消费失败的消息去哪了?RabbitMQ 的答案是「看你怎么配」。配了 x-dead-letter-exchange 就进死信队列,没配就真的丢。死信最有价值的地方不是「消息没丢」,而是那条消息上会带一个 x-death header,把「它在哪个队列、因为什么原因、死了几次」写得清清楚楚。这一章先把 x-death 的每个字段对着真实输出拆开,再讲延迟队列的几种做法各自的代价。实验出自本机 RabbitMQ 4.3.6,节点名已脱敏。
消费失败的消息去哪了?RabbitMQ 的答案是「看你怎么配」。配了
x-dead-letter-exchange就进死信队列,没配就真的丢。死信最有价值的地方不是「消息没丢」,而是那条消息上会带一个x-deathheader,把「它在哪个队列、因为什么原因、死了几次」写得清清楚楚。这一章先把x-death的每个字段对着真实输出拆开,再讲延迟队列的几种做法各自的代价。实验出自本机 RabbitMQ 4.3.6,节点名已脱敏。
一、现场:死信队列越堆越多,但不知道是哪种失败
运维给 lab.dlq 配了告警:深度超过阈值就报警。某天开始报警,但排查发现死信消息分两类——一类是消费逻辑一直抛异常被 nack(requeue=False) 拒掉的,另一类是「本来想在 30 秒后重试、结果再也没被处理」的。放在同一个队列里混着,看消息体也分不清。
问题在于没利用 x-death。每一条被死信的消息都会带上这个 header,reason 字段直接告诉你是 rejected 还是 expired。把它们分开统计,才能判断是消费逻辑的问题还是延迟投递的问题。
二、一条消息变成死信的完整链路
flowchart TD
PUB["publish 到 lab.ex.dl<br/>routing_key=job"] --> Q["queue lab.dl.main<br/>x-dead-letter-exchange=lab.ex.dlx<br/>x-message-ttl=1000"]
Q --> D{"消息为什么离开主队列?"}
D -->|"消费者 nack(requeue=False)"| R1["reason=rejected"]
D -->|"队列 TTL 到期"| R2["reason=expired"]
D -->|"队列满且 x-overflow=drop-head"| R3["reason=maxlen"]
R1 --> DLX["exchange lab.ex.dlx (fanout)"]
R2 --> DLX
R3 --> DLX
DLX --> DLQ["queue lab.dlq"]
DLQ --> XD["消息头带上 x-death:<br/>count / reason / queue / exchange / routing-keys / time"]
style PUB fill:#e3f2fd,color:#0d3b66
style Q fill:#e8f5e9,color:#1b5e20
style D fill:#fff3e0,color:#8a4b00
style R1 fill:#ffebee,color:#b71c1c
style R2 fill:#ffebee,color:#b71c1c
style R3 fill:#ffebee,color:#b71c1c
style DLX fill:#ffe0b2,color:#8a4b00
style DLQ fill:#e8f5e9,color:#1b5e20
style XD fill:#f1f8e9,color:#33691e
三、x-death 的六个字段
主队列 lab.dl.main 配了 x-dead-letter-exchange=lab.ex.dlx 和 x-message-ttl=1000。让它通过两条路径各产生一条死信:一条被 nack(requeue=False) 拒绝,一条等 TTL 自然过期。从 lab.dlq 取出来打印 x-death(05-manual-ack-dlx.txt):
第 1 条死信 body=被拒绝的消息
x-death=[{'count': 1L, 'reason': 'rejected', 'queue': 'lab.dl.main',
'time': datetime(2026, 10, 5, 6, 8, 57, tzinfo=utc),
'exchange': 'lab.ex.dl', 'routing-keys': ['job']}]
第 2 条死信 body=TTL到期
x-death=[{'count': 1L, 'reason': 'expired', 'queue': 'lab.dl.main',
'time': datetime(2026, 10, 5, 6, 9, 2, tzinfo=utc),
'exchange': 'lab.ex.dl', 'routing-keys': ['job']}]
x-death 是一个列表,每一项是一次死亡记录,字段含义:
count:这条消息在这个队列上死亡了几次。值为1L,说明这是第一次。用它做重试次数上限最直接:消费前检查count,超过就进「最终失败」队列。reason:死亡原因。实测两条分别是rejected(被 nack / reject 拒掉)和expired(TTL 到期)。第三种常见值是maxlen(队列满、被drop-head挤掉)。queue:消息死在哪个队列上,这里是lab.dl.main。经过多次死信的消息,列表里会有多段,能还原出完整路径。exchange:消息最初是从哪个 exchange 发出来的,这里是lab.ex.dl。routing-keys:死亡时用的 routing key,是个列表(消息可能同时匹配多个 key)。time:死亡时间。注意它是消息离开原队列的时间,不是进死信队列的时间。
有了这些字段,消费端就能做「有依据的重试决策」:count < 3 且 reason == 'expired' 才重投,reason == 'rejected' 且 count 已经到顶就落库告警。死信队列从「垃圾桶」变成了「带诊断信息的待办队列」。
四、三种死信触发条件
- rejected:消费者
basic_nack(requeue=False)或basic_reject(requeue=False)。这是主动的,由消费逻辑决定。 - expired:消息或队列的 TTL 到期。本实验用的是队列级
x-message-ttl,队列里所有消息共享同一个过期时间。官方文档还支持每条消息单独设 TTL(expiration属性),两者混用时,取较小的那个生效。 - maxlen:队列长度超过
x-max-length,且x-overflow是默认的drop-head,被挤出去的队头消息成为死信。注意和第 02 章的reject-publish不同——那种是拒收新消息、不产生死信。
五、延迟队列的三种做法
「延迟 30 秒再重试」最常见的做法是利用「TTL 到期 → 死信」这条链路:
做法一:每级一个队列 + TTL + DLX。 建 delay.30s / delay.5m / delay.30m 三级队列,消息先投到对应延迟级别的队列,设队列级 TTL,到期后死信到真正的消费队列。优点是只用 broker 原生能力、不需要插件;缺点是延迟粒度是队列级的,要有几档延迟就得建几个队列,且不同延迟的消息不能走同一个队列。
做法二:单条消息设 TTL。 给消息设 expiration 属性,所有消息进同一个队列,到期各自死信。这里有个必须知道的坑:RabbitMQ 官方文档(Time-to-Live and Expiration,检索于 2026-10-05)规定,per-message TTL 的消息只有在到达队列头部时才会被判定过期。如果队头是一条 TTL 很长的消息,它后面的短 TTL 消息即使早已过期也要排队等着。本机实验没有复现这一条(实验用的是队列级 TTL,不存在队头阻塞),这里按官方文档规定陈述,实际使用前建议在自己的拓扑上验证。
做法三:延迟消息插件。 官方维护的 rabbitmq_delayed_message_exchange 插件提供一个 x-delayed-message 类型的 exchange,消息带一个延迟头,由插件负责定时投递,粒度可以到毫秒级、不需要建多档队列。代价是:插件不是核心协议的一部分,本机 status 里 Enabled plugins: (none),也就是默认没装;引入它意味着多一个需要随 RabbitMQ 版本一起升级、验证的组件。延迟量极大或量极其密集时,插件内部要维护定时结构,对内存有额外要求。
选哪个取决于约束:延迟档位固定、量不大 → 做法一,零依赖;延迟值多样、要毫秒级 → 做法三;只在个别场景要延迟、量又小 → 也可以在应用侧用一张延迟表轮询,把复杂度留在应用里。
六、常见误判
| 常见误解 | 对着哪条输出核对 | 结论 |
|---|---|---|
| 死信消息和新消息一样,需要自己记上下文 | x-death 里带着 queue / exchange / routing-keys / time |
死信自带诊断信息,不必再加额外字段 |
| TTL 到期时间是「进死信队列的时间」 | time 字段是消息离开原队列的时刻 |
time 是死亡时刻,进 DLQ 可能有延迟 |
| requeue 和死信是一回事 | nack(requeue=False) 才进 DLX,requeue=True 消息回主队列 |
requeue 是回原队列,死信是转到 DLX,两条路径 |
| 队列 TTL 和消息 TTL 效果一样 | 本实验用队列级 TTL;官方文档对 per-message TTL 有队头阻塞规定 | 队列级 TTL 所有消息同寿命;per-message TTL 受队头顺序影响 |
生产边界
- 教学替身 vs 真实依赖:实验里的 DLX 是一个 fanout exchange 加一个队列。生产里通常按「死信原因」拆多个 DLQ(rejected 一个、expired 一个、maxlen 一个),或至少用 routing key 区分,才能各自设不同的告警阈值与重放策略。
- 上线要盯的指标:死信队列深度与其增长速度、按
reason分类的占比、count(重试次数)的分布——大量消息count到顶说明下游长期故障,继续重试没意义。 - 失败策略:死信队列必须有消费者或明确的人工重放流程,否则它会变成第二个积压源;给死信消息设一个「最终失败」队列,
count超限的消息进那里并告警,避免无限重试;延迟队列的每一级都要监控深度,防止过期没触发导致的静默堆积。
动手
- 建
lab.dl.main配 DLX 和队列级 TTL=1000,分别用nack(requeue=False)和等 TTL 到期制造两条死信,打印x-death并解释每个字段。 - 给死信消息重新投回主队列,观察再次死信后
x-death列表是变长还是count增加。 - 建
delay.5s队列绑 DLX 指向消费队列、TTL=5000,投一条消息,记录它从入队到进入消费队列的间隔。 - 给同一队列投一条 TTL=10000 和一条 TTL=1000 的 per-message TTL 消息,观察短 TTL 的那条是否被队头的长 TTL 挡住(如果有延迟消息插件也可以对比)。
自测
x-death的count和reason分别能支撑什么决策?为什么把死信按 reason 拆开更有用?- 队列级 TTL 和 per-message TTL 在过期判定上的差别是什么?后者为什么会队头阻塞?
rejected和maxlen两种死信分别由什么动作触发?它们发生在队列的哪一侧?- 用 TTL + DLX 做延迟队列,为什么「想支持任意延迟值」时它不划算?
- 延迟消息插件解决了问题的哪一部分?引入它的额外代价是什么?
↓ 下一步:05 章 · 集群与队列类型