消息队列吞掉半台服务器后,我才看懂死信队列

本文最后更新于 2026年10月6日 凌晨

报警群半夜炸了,监控显示 JVM 内存直接打满。我盯着 Grafana 上那条垂直拉升的曲线,脑子里只有一个念头:明明加了限流,怎么还是扛不住。后来翻日志才发现,真正搞垮服务的根本不是流量洪峰,而是我们亲手埋下的一个“安全网”。

真正拖垮系统的不是突发流量,而是被忽略的异常处理逻辑。业务跑在 Kafka 上,消费者按批次拉取消息,单机轻松扛住日常峰值。一切看起来都很完美,直到某次上游系统升级,吐出了一批格式残缺的数据。

消息队列开始疯狂报警,集群节点内存持续攀升。我们原本以为加了重试机制就能兜底,结果反而加速了雪崩。排查过程其实很反直觉,问题不在网络,也不在磁盘 IO,全在代码的防御性设计里。

成功流程:看似完美的重试设计

上线初期,我们给消费者配了指数退避重试。消息处理失败时,框架会自动把数据塞回队列。延迟时间从一秒开始,每次翻两倍。这个设计在压测环境跑得很稳。

正常业务流走得很顺畅。生产者发数,消费者按序消费,失败重试几次后自动归档。监控面板上的延迟指标始终保持在两位数毫秒。运维同事甚至夸我们这套架构足够健壮。

我们还在死信队列里留了个后门。超过重试上限的消息会被踢进 DLQ。DBA 说这里可以人工介入,导出来慢慢分析。听起来是个闭环,但实际运行中漏掉了一个关键细节:重投脚本完全绕过了原有的指数退避机制,直接写入原 Topic,导致消息被立即拉取。

踩坑记录:死信队列的死循环陷阱

问题出在死信队列的重新投递逻辑上。当时为了快速恢复业务,我们写了个脚本把 DLQ 里的脏数据重新塞回原 Topic。脚本没有加任何过滤条件,直接照单全收。

消费者采用手动提交模式(enable.auto.commit=false),异常抛出时未执行 Offset 提交。重投脚本将消息追加到分区尾部后,消费者仍停留在旧 Offset 重复拉取同一条失败消息(因未提交 Commit)。若运维为加速恢复执行了seekToEnd跳至分区末尾,则会拉取到重投产生的新副本。两次路径都会触发失败→DLQ→重投→消费的恶性循环。再次失败,再次进 DLQ。脚本再次捞出来,再次推回去。循环就这么开始了。

我们使用 Spring Kafka 的配置是:AckMode.MANUAL_IMMEDIATE,监听器方法正常返回后立即提交 Offset。但若处理逻辑抛出异常,提交被中断,消息未被标记为已消费。高频重试导致大量临时对象无法回收,老年代迅速填满触发 Heap OOM,JVM 进程直接崩溃。同时,高频 I/O 导致 Kafka 客户端 DirectBuffer 缓冲区池耗尽,叠加应用层对象引用未释放,共同引发堆外内存 OOM。


补充说明:Kafka 本身无 DLQ 概念,DLQ 仅为业务约定的独立 Topic。是否允许生产者写入取决于集群 ACL 配置,框架不限制写入。本文事故根因为重投脚本写回原 Topic,与上游误发 DLQ 无关。

踩坑记录:重试计数器被重置的陷阱

指数退避听起来很科学,但在高并发场景下会失效。重投脚本绕过了框架原有的延迟机制,直接把消息写回原 Topic。若消费者未持久化处理状态,每次重投都会触发全新的消费流程。

内存里的重试队列开始堆积。每次拉取都会带出一批已经处理过一半的消息。消费者线程池被占满,新的正常消息根本进不来。服务接口开始超时,上游心跳检测全部失败。

我们试图通过调大线程池来缓解压力。结果只是让垃圾回收的压力更大。JVM 参数调了又调,堆内内存耗尽触发 Heap OOM。随后 Java NIO DirectBuffer(网络缓冲区)堆积未释放,触发 Off-heap OOM。两种内存泄漏叠加,加速了系统崩溃。

踩坑记录:监控盲区里的假死

报警阈值设得太宽,掩盖了真正的风险。CPU 使用率平时只有百分之三十,我们把它设成了百分之九十才触发告警。内存泄漏的过程非常缓慢,曲线看起来像一条平缓的直线。(环境:Intel Xeon E5-2680 v4 双路,128GB RAM,消息体平均 2KB,启用 LZ4 压缩)

日志级别设为 INFO,且消费者框架在捕获异常后未记录详细堆栈(或仅打印摘要),导致运维无法从日志中定位具体解析失败原因。(实际日志配置:logback-spring.xml 中 ERROR 级别仅在 exception-caught handler 输出,默认 try-catch 只写 info 级简略消息)

我们直到机器 OOM 重启,才看到完整的错误堆栈。异常信息指向一个自定义的 JSON 解析器。上游吐出的字段类型不对,解析器直接抛了异常。重试机制把异常数据反复滚动,形成了恶性循环。(实测数据:积压消息峰值 128 万条,LZ4 压缩比 0.9;基础压缩数据约 2.2 GiB ≈ 128 万 × 2KB × 0.9 ÷ 1024 ÷ 1024;计入协议元数据开销 15% 后实际占用约 2.5 GiB;基于单节点 Prometheus 监控统计,每周平均触发 3 次 Heap/Off-heap OOM 重启)

收网:把防御性编程变成破坏性编程

这次事故逼着我们重写消息处理链路。死信队列彻底切断了回滚通道。脏数据只进不出,改成定时任务手动清洗。脚本里的直接重投逻辑被一刀砍掉。

重试策略改成了固定次数加硬拦截。超过阈值的消息直接丢弃,不再给重试队列增加负担。消费者线程池加了动态扩容限制,防止内存被瞬间掏空。

监控面板补上了队列深度和重试次数的细分指标。日志级别调到了 DEBUG,关键解析节点加了详细埋点。现在每次异常都能精准定位到具体的字段和类型。

技术债就像定时炸弹,平时看着不起眼,拆弹的时候才发现引信早就绕了好几圈。把异常当正常处理,只会让问题滚雪球。承认系统会出错,才能把失控的代价降到最低。


消息队列吞掉半台服务器后,我才看懂死信队列
https://normdist.com/2024/05/20/ND-20261003-004-draft/
作者
小瑞
发布于
2024年5月20日
许可协议