x-death 不是重投计数器:一次"看着生效、其实没生效"的限次重试

0 阅读6分钟

x-death 不是重投计数器:一次"看着生效、其实没生效"的限次重试

主题:消息可靠性 · 终止态可观测性

先说结论

我在第二批里写过一套 MQ 毒消息治理方案:用 x-death 头计数做限次重试,重试耗尽后不再入队、转进死信队列(DLX)。那篇文章里"限次"就是靠 x-death 实现的,我当时认为它工作正常 —— 因为在低流量下它"看起来"正常。

第三批做别的排查时顺手量了一下:那条毒消息在 10 分钟里被反复投递了 114 次。

也就是说,那个计数器一直是 0,"限次"从来没有生效过。

技术原因只有一句话:在 classic 队列上,basicNack(requeue=true) 不会写 x-death 头(只有消息真正被死信时才会写 —— reason 仅 rejected / expired / maxlen / delivery_limit;quorum 队列记重投用的是 x-delivery-count / x-acquired-count,也不是 x-death)。消息被"放回原队列"不算一次死信。

但这篇想讲的不只是这个 API 细节,而是它为什么能瞒过我一个月:

一个"限次"机制失效时,它的表现和正常工作时一模一样。

一、现象:同一个缺陷的两副面孔

面孔 A:日志刷屏,永不落地。 一条注定处理失败的消息被无限重投 —— 队列的 ready 恒为 0(一出队就被取走)、unacked 恒为 1,而日志里同一条"处理失败"以毫秒级间隔反复出现:10 分钟 114 次。

面孔 B:静默挂起,队列干净。 同一个消费者的另一条异常路径(直接抛异常)在某种 ack 模式下不会 reject,消息被一直挂成 unacked:队列看起来"没有积压"、告警不响、日志只有一条 —— 比面孔 A 安静得多,也更难发现。

两副面孔的共性是:外部指标都正常。队列不堆积、服务不报错、接口可用 —— 只有"这条消息到底处理完了没有"这个问题,没有答案。

二、根因:三层"看不见"

  1. x-death 的语义不是"重试次数"。 它是"这条消息进过几次死信"的账本:只有消息离开原队列(进入 DLX、TTL 过期等)才会追加。requeue 是"放回原队列",不是死信 ⇒ 计数恒 0 ⇒ count < 3 永远成立 ⇒ 无限重投。
  2. ack 模式的语义反直觉。 手动模式(MANUAL)下,监听器抛异常并不会 reject 消息 —— 你得自己 nack,否则消息永远 unacked;自动模式(AUTO)下,抛异常才会按"是否 requeue"的策略被拒绝/重新入队。两种模式对同一个异常的处理恰好相反,这里最容易想当然。
  3. 配置文件可能是"影子配置"。 我把重试参数写在 spring.rabbitmq.listener.simple.*,重启后行为毫无变化 —— 因为这个服务自己定义了监听容器工厂,框架默认的那套容器根本没被使用 ⇒ 我改的那份 yml 从未参与运行时。(同一个项目里另一个模块没有自定义工厂,同样的 yml 就是生效的 —— 一个项目里同时存在"生效"与"不生效"的两份同名配置,光看文件分辨不出来。)

三、修复:把 ack 交给容器,并给"终止态"一个归宿

最终固化成四个动作:

  1. 监听器不碰 channel:去掉所有手工 ack/nack,让容器按 ack 模式统一处理。
  2. 用容器提供的重试 + 耗尽即拒绝:acknowledge-mode: auto + default-requeue-rejected: false + retry.max-attempts: 3(默认的 recoverer 就是"拒绝且不重新入队")⇒ 成功即确认、失败即重试、耗尽即拒绝。
  3. 改 ack 模式之前,扫一遍全模块的监听器:确认有没有模块自带容器工厂(有 ⇒ 那份 yml 是影子配置)。
  4. 追问一句:"拒绝之后,这条消息去哪了?"

第 4 条是这次最值钱的收获。我当时把"无限重投"修成了"限定 3 次后拒绝",然后才发现:这条队列上没有死信配置,被拒绝的消息直接被丢弃,只剩下日志。

"修好重投" ≠ "修好可观测性"。 我把一个"刷屏"的坑,换成了一个"安静"的坑。

四、给"已经存在"的队列补死信:参数不可变,但策略可以后加

给队列加死信的标准做法是在声明时写 x-dead-letter-exchange。但这里有个硬约束:

MQ 队列的参数在创建后不可变。 给一个已存在的队列改参数,broker 会直接拒绝(PRECONDITION_FAILED)—— 结果不是"没生效",而是消费者起不来、整条链路断流,比原来的 bug 更严重。

正确的补救是 broker 策略(policy):

# 给已存在的队列补上死信:不动队列参数、不删队列、不丢消息
<broker 管理命令> set_policy <策略名> '^<队列名>$' \
  '{"dead-letter-exchange":"<死信交换机>","dead-letter-routing-key":"<死信路由键>"}' \
  --apply-to queues

两个必须记住的配套纪律:

  • 策略是 broker 侧配置,不在代码仓库里 ⇒ 重建 broker(卷丢失、集群重搭)之后必须重新施加。所以"这个队列依赖某条策略"这件事,要同时写进代码注释、待办清单和排障文档。
  • 部署顺序:先部署"声明死信交换机 / 死信队列 / 绑定"的版本,再施加策略。反过来,因为死信交换机还不存在,被拒绝的消息会再次被静默丢弃。

💡 还有个容易踩的验证细节:x-death 只在消息真正进入死信之后才出现,所以用死信告警验证修复时,看到的 reason 是 rejected,而不是"重试了 3 次"。要确认"重试次数",得看应用日志里失败处理的次数,别去数消息头。

五、验收:用一条"注定失败"的消息把整条链走完

修复是否成立不能靠读代码 —— 造一条必然失败的消息(业务上永远无法处理,例如数量超过库存上限)。它数据安全(事务回滚、零业务写入),却能把整条链走完:

① 处理失败 → 抛出"拒绝且不重投"
② 失败留痕 × 3                     ← 恰好等于 max-attempts
③ 【死信告警】reason=rejected, queue=<原队列>, 消息体原文保留
④ 死信消费者确认 ⇒ 原队列与死信队列都回 0

其中 "留痕恰好 3 次"是决定性判据:如果限次没生效,这里会是成百上千。

六、可以抄走的 6 条自查

  • 我用什么当"重试次数"?如果取自 x-death —— 在 classic 队列上它永远是 0。
  • 选 ack 模式之前,我扫过所有模块的监听器吗?(有模块自带容器工厂 ⇒ 那份 yml 是影子配置)
  • 把"无限重试"改成"拒绝"之后,被拒绝的消息去哪了?
  • 死信交换机与死信队列真的存在吗?(不存在 ⇒ 消息被丢弃,只剩日志)
  • 死信是声明在队列参数里、还是用策略后加?要动已存在队列的参数吗?(会 PRECONDITION_FAILED,把消费弄断)
  • 我的死信配置是可重建的吗?(策略在 broker 里、不在仓库 ⇒ 重建后会不会丢)

结语

第二批那篇文章的骨架没错:限次重试 → 死信留痕 → 人工补偿。

错的是我"以为它生效了"。而它最坏的地方不是失效本身,而是:

它失效的样子,和它正常工作的样子,完全一样。

所以现在每改一处"限次 / 超时 / 重试"策略,我都会多问一句:这次生效了吗?我是从哪一侧看出来的?