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-death header,把「它在哪个队列、因为什么原因、死了几次」写得清清楚楚。这一章先把 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 < 3 且 reason == 'expired' 才重投,reason == 'rejected' 且 count 已经到顶就落库告警。死信队列从「垃圾桶」变成了「带诊断信息的待办队列」。

四、三种死信触发条件

五、延迟队列的三种做法

「延迟 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 受队头顺序影响

生产边界

动手

  1. 建 lab.dl.main 配 DLX 和队列级 TTL=1000,分别用 nack(requeue=False) 和等 TTL 到期制造两条死信,打印 x-death 并解释每个字段。
  2. 给死信消息重新投回主队列,观察再次死信后 x-death 列表是变长还是 count 增加。
  3. 建 delay.5s 队列绑 DLX 指向消费队列、TTL=5000,投一条消息,记录它从入队到进入消费队列的间隔。
  4. 给同一队列投一条 TTL=10000 和一条 TTL=1000 的 per-message TTL 消息,观察短 TTL 的那条是否被队头的长 TTL 挡住(如果有延迟消息插件也可以对比)。

自测

  1. x-death 的 count 和 reason 分别能支撑什么决策?为什么把死信按 reason 拆开更有用?
  2. 队列级 TTL 和 per-message TTL 在过期判定上的差别是什么?后者为什么会队头阻塞?
  3. rejected 和 maxlen 两种死信分别由什么动作触发?它们发生在队列的哪一侧?
  4. 用 TTL + DLX 做延迟队列,为什么「想支持任意延迟值」时它不划算?
  5. 延迟消息插件解决了问题的哪一部分?引入它的额外代价是什么?

↓ 下一步:05 章 · 集群与队列类型

进入 keel 阅读