首页 游戏 软件 排行 专题

死信队列不是垃圾桶:3个参数没配,重试把磁盘写满

来源: 本站整理 日期:2026/11/4 11:17:31

凌晨两点半,磁盘告警把我从床上叫醒。登录机器一看,根分区 92%,顺着 du 挖下去,一个平时没人看过的目录占了 37G那是我们自建消息队列的死信队列目录,二十多万条消费失败的消息躺在里面,最早的一条已经躺了 9 天。

如果你也在自建消息队列,或者正在给一套消费系统挑机器、扩磁盘,可以先看看这两家的配置和技术说明:terabix(AMD EPYC Genoa + DDR5 + RAID-10 NVMe Gen4 云主机)、liteserver(低延迟网络 + NVMe SSD 云服务器)

更让我后背发凉的是另一件事:那台机器的 CPU 当时是 100% 满载,跑了两天。也就是说这些消息不是躺在那儿不动,而是一条接一条地被重新拉出来、重新消费、重新失败、重新入队,周而复始。磁盘是被写满的,CPU 是被空转烧掉的,业务侧的正常消息其实早就在后面排队等着真正让我加班到天亮的不是那 37G 死信,是那 20 多万次毫无意义的重试。

消息队列积压文件堆积在磁盘上的场景

一、消息为什么会一直重试

先说清楚机制。主流消息队列(Kafka、RabbitMQ、RocketMQ)在消费失败时的默认动作大致是:把消息重新放回队列(RabbitMQ 是 nack 重新投递,Kafka 是 offset 不提交),然后等消费者下一次拉取。这个"失败"如果永远不消失,就会形成一条稳定的死循环。

我们踩到的第一个坑是毒丸消息:一条消息体里有下游不认识的字段,序列化后抛异常。这条消息不管重试多少次都会失败,但它前面挡着整条队列,consumer 一直卡在同一条上。后来的运维加了 try-catch 把异常吞掉,异常不抛了,但 offset 依然不提交,于是又变成无限重投。

第二个坑是依赖抖动。下游服务白天好好的,凌晨发版重启,调用超时从 2 毫秒涨到 30 秒。消费端的超时阈值设的是 5 秒,超过就判失败重试于是在下游重启的那 20 分钟里,同一批消息被反复打了几万次。

第三个坑最隐蔽:重试没有退避。很多人在重试里直接 for 循环或者不设间隔地重新入队,失败的消息在同一秒内被重投几十次。20 万条消息 × 每条重投 30 次 = 600 万次写盘和解析,CPU 直接跑满,磁盘 IO 也被写满。

运维人员在深夜机房排查消息队列消费异常

二、三个参数,配错一个就是灾难

复盘下来,真正能止血的就是三个参数。它们在 RabbitMQ 里叫 x-death 计数和 DLX,在 RocketMQ 里是重试队列的层级,在 Kafka 这类基于日志的队列里则要靠消费端自己实现。

参数一:最大重试次数。它决定了"一条消息最多折腾几回"。设 3 次和设 100 次,前者 20 万条消息最多产生 60 万次尝试,后者是 2000 万次量级完全不同。我们的经验值是:下游是内部微服务、网络稳定,设 3~5 次;下游依赖外部接口、抖动概率高,设 8~10 次。超过这个数还在失败,基本可以判定是数据本身有问题(毒丸、格式变更、权限变更),继续重试没有任何意义。

参数二:重试间隔(退避策略)。这个参数被忽略的概率是最高的。固定间隔 1 秒在下游抖动时等于火上浇油,正确做法是指数退避加随机抖动:第 1 次等 1 秒,第 2 次等 2 秒,第 3 次等 4 秒、8 秒,每次再叠加几秒随机偏移,避免一批消息在同一时刻集体复活造成二次风暴。Kafka 消费端最简单可行的版本就是本地按失败次数 sleep,或者引入一个专门的重试 topic 配合延迟消费。

参数三:死信队列的容量上限和保留策略。死信队列最常见的误用是把它当成垃圾桶重试失败就往里一扔,不设上限、不设过期、不做监控。消息在里面躺几周甚至几个月,磁盘就是这么被撑爆的。正确姿势是:死信队列必须有条数上限或容量上限,超过就触发告警;必须有保留期限(比如 7 天),过期自动清理;必须有独立监控,深度超过阈值直接报警到值班群。

死信队列把失败消息隔离到独立收集箱的示意图

三、死信队列不是垃圾桶,得有人管

死信队列的语义是"这条消息我处理不了,但我不丢,留给人来看"。可实际上大部分团队没人看。我们那 37G 里躺着 20 万条,其中真正有业务价值、需要人工介入的可能只有 200 条,剩下的 99.9% 是同一个下游接口超时导致的重复副本。

所以死信队列必须配三件事:

1. 入库即打标:入队时带上原始 topic、错误码、失败时间、重试次数、消费组这几项,否则你三个月后回头看,只看到一坨没有上下文的 JSON。

2. 入库即告警:死信队列的入队速率(条/分钟)比深度更能反映问题。我更愿意看"每分钟新增 300 条死信"这种告警,因为它直接指向正在发生的故障。

3. 定期演练回放:从死信里挑 100 条,用修好的消费端重新跑一遍,确认这批消息处理完没有新副作用,然后才允许批量清理。没有回放验证就删,等于丢数据。

服务器节点之间数据方块排队与死信隔离的等距插画

四、给 broker 本身的机器留点余量

这次事故让我重新算了一遍 broker 的机器规格。消息队列对机器的要求其实很挑:

磁盘:队列目录和死信目录的写入是持续的小块随机写,对延迟极其敏感。普通机械盘或者共享存储的云盘在这个场景下很容易把 p99 拉高几个数量级。RAID-10 的 NVMe 是这个场景的稳妥选择,写放大和故障恢复都友好。

内存与 CPU:broker 要做页缓存管理,消费者要在同一台机器上做序列化。当重试风暴来的时候,CPU 是最先见顶的指标,EPYC 这类多核高频 CPU 加 DDR5 内存能把重试风暴的余量拉开一截。

网络:消息同步、消费者拉取、监控上报三条流量都走网络。大带宽、低延迟的线路能明显缩短一次拉取往返,间接降低 consumer 端的确认超时。

如果你的消息队列是自建在自己机器上的,建议至少把 broker 放到独立的数据盘上,并给队列目录和死信目录设磁盘配额。这样就算死信队列真的失控,最坏情况也只是这个目录写满,根分区和日志目录还活着,监控和告警链路不会跟着一起挂掉。我们后来就按这个思路重做了:死信单独一个分区,单独设 20G 配额,触顶直接告警,写满也只影响清理流程,不会影响正常消息的消费。

五、我现在的排查清单

下次再遇到"磁盘涨得莫名其妙",我会按这个顺序走一遍,不用再熬一整夜:

1. du -sh 逐级找大目录,确认是不是队列目录;df -h 看是哪个分区;iostat -x 1 看当前 IO 和 %util。

2. 看队列深度:RabbitMQ 用管理 API 或 rabbitmqctl list_queues,Kafka 看 consumer lag,先确认"积压"到底发生在正常队列还是死信队列。

3. 看死信队列的增长速率:find /var/lib/rabbitmq/mqdlx -type f -mmin -10 | wc -l,十分钟内新增多少条,这个数字直接决定要不要立刻动手。

4. 抓一条死信样本出来看错误码,区分是毒丸(必然失败)、依赖抖动(会自愈)、还是配置错误(改了就好)。三类的处置完全不同。

5. 应急动作:先停掉重试风暴(把消费组的重试关掉或降速),再导出一份死信样本留存,最后才是清理。不能反着来先删文件,重试还在跑,磁盘马上又满。

回头看,那 37G 和两天的满 CPU,根源只是三个参数没配对:重试次数没有上限、重试没有退避、死信队列没有容量上限和告警。消息队列的坑从来不在代码里,而在运维参数里。

玩家留言 跟帖评论
查看更多评论
相关文章
猜你喜欢
同类下载