工程治理
收录于 工程治理体系
死信队列的工程治理:为什么进入死信,不等于问题结束
很多团队配了死信队列,却没有真正建立异常治理能力。问题不是消息失败了,而是某个业务结果没有兑现。
前段时间我排查一个 MQ 系统,看到一组很典型的现象:
系统里挂着多个死信队列,其中一个 backlog 已经堆了很久,消息量不小,但业务侧几乎没人关注它。
这件事让我一下子警觉起来。
如果这些消息真的重要,为什么可以长期没人处理?
如果这些消息不重要,当初为什么又要专门设计死信队列?
后来我越来越确信,很多团队的问题不是“没有 DLQ”,而是对 DLQ 的理解停在了“失败消息存储位置”这一层。
他们知道消费失败可以重试,重试多次以后可以进死信;但再往后,谁来发现、谁来判断、谁来修复、谁来 replay,往往没有形成一条明确的工程链路。
所以这篇不讲 DLQ 怎么配置,也不讲某个中间件的 API。
我只想讲一件事:
进入死信,不代表问题结束,而代表某个业务结果还没有被兑现。
如果这句话不成立,死信队列最后大概率会变成数据黑洞。
如果这句话成立,那我们真正要设计的就不只是一个队列,而是一条异常治理路径。
再把话说得更直一点:
如果一个死信队列长期堆积、长期没人处理,但业务又没有明显报警,通常只剩两种解释:
- 这些消息其实早就不承载关键业务结果。
- 系统已经在不知不觉中接受了部分业务不一致。
无论是哪一种,都不是“队列问题”,而是工程治理问题。
一、死信不是“处理完成”,而是业务结果没有兑现
很多系统讨论异常处理时,默认都是从技术链路开始的:
消费失败 -> Retry -> Dead Letter Queue
从 MQ 的角度看,这条链路当然成立。
Retry 负责自动恢复,DLQ 负责把失败消息隔离出去,避免主链路被坏消息拖死。
但如果理解只停在这里,DLQ 就很容易被写成一个“失败缓存区”。
团队盯的是消费错误率、重试次数、backlog 曲线,却没有继续追问一个更本质的问题:
这条消息在业务上到底代表什么?
在事件驱动系统里,一条消息通常不是一个抽象对象,它往往代表一个已经发生的业务事实。
比如:
- 用户已经完成支付。
- 订单已经创建成功。
- 库存已经扣减。
- 用户应该获得积分。
如果“支付成功”消息在积分系统消费失败,技术上看只是一次消费异常。
但业务上真正发生的是:
用户已经付款,但他应得的积分没有到账。
也就是说,技术上的失败消息,对应的是业务上的结果未完成。
这也是我现在看 DLQ 时最在意的地方。进入死信,不应该被理解成“系统处理完了”,而应该被理解成“系统把一个暂时无法自动恢复的问题隔离出来了,等待后续治理”。
如果连这层含义都没有,DLQ 迟早会沦为一种心理安慰:
消息没丢,但业务也没真正闭环。
二、为什么很多团队会把死信队列用成黑洞
这件事往往不是因为团队懒,也不一定是中间件能力不够。
更常见的原因是,问题在一开始就被定义小了。
系统上线时,团队通常会认真讨论这些事:
- topic 怎么建。
- consumer 怎么扩。
- 重试次数配多少。
- 死信 topic 叫什么。
但很少有人在同一个设计会议里继续往下问:
- 哪些异常适合延迟重试,哪些异常应该直接进入死信?
- 一条死信到底对应哪个业务结果没有兑现?
- 谁来处理这些消息,处理时限是什么?
- replay 之前要先修代码、修数据,还是先补偿业务?
- 这次处理完以后,后面怎么避免再次发生?
这些问题一旦缺席,DLQ 很容易进入一种看起来“系统没坏”、实际上治理已经缺位的状态:
- 队列是有的。
- 监控是粗糙的,甚至没有。
- 死信数据在持续积累。
- 平台方知道“有失败”,但不知道业务后果是什么。
- 业务方知道“有问题”,但不知道该去哪里接这个问题。
Producer 和 Consumer 在事件系统里本来就是解耦的。
消息一旦发出去,Producer 往往就把自己的职责视为完成。
如果 Consumer 失败又没有一条明确的异常路径,这个失败就很容易停留在系统内部,既没有自然回流到业务链路,也没有被稳定接住。
所以很多团队不是没有死信队列,而是没有把异常当成一个完整生命周期来治理。
三、不是所有异常都应该进死信,先看业务上能不能恢复
一旦把视角从“处理失败消息”切到“恢复业务结果”,很多看起来理所当然的设计都会变。
最典型的一条就是:
不是所有异常都应该沿着 失败 -> Retry -> DLQ 这条路径走到底。
因为不同异常,对应的是完全不同的业务状态。
1. 临时依赖失败
例如数据库瞬时不可用、RPC 超时、网络抖动。
这类问题往往不是业务本身错了,而是当前执行环境不稳定。
这种时候最合理的动作通常不是直接进死信,而是:
- 延迟重试。
- 指数退避。
- 控制重试预算。
因为很多问题在几分钟内就会自动恢复。
如果这种异常都快速打进死信,本质上是在把“本来可以自动恢复的问题”升级成“必须人工治理的问题”。
2. 条件暂未满足
还有一类情况,严格来说甚至不能叫错误。
比如依赖数据还没同步、关联资源还没 ready、上游状态尚未推进到可处理阶段。
这类消息的关键不在“失败”,而在“现在还不具备处理条件”。
它们更适合走等待条件成熟的路径,而不是直接送进死信。
3. 数据异常
例如字段缺失、schema 不兼容、关键业务字段非法。
这类问题通常无法靠重试解决,因为重试不会改变消息内容本身。
这时候进入死信通常是合理的,但它的含义不是“归档失败消息”,而是:
- 把问题从主链路隔离出去。
- 保护正常消息继续处理。
- 给后续的数据修复、人工分析和 replay 留出入口。
4. 代码缺陷
如果消息消费失败是因为 Consumer 本身有 bug,那 DLQ 也不应该被理解成终点。
它更像一个临时停车场:
- 先修代码。
- 再 replay 历史失败消息。
- 确认业务结果被补回。
所以真正该先问的不是“要不要配 DLQ”,而是:
这类异常在业务上能不能自动恢复;如果不能,系统准备怎么把结果补回来。
四、DLQ 的职责只有三件事,不要把它讲成完整方案
如果把死信队列讲得太重,很容易把它误写成“异常处理方案”。
这会直接把设计带偏。
DLQ 真正擅长做的事情,只有三件:
- 隔离 Poison Message,避免坏消息反复阻塞主链路。
- 保护系统吞吐,不让少量异常拖垮大盘。
- 为后续治理提供一个明确入口。
它不擅长做的事情也很明确:
- 它不会自动告诉你业务后果是什么。
- 它不会替你完成异常分类。
- 它不会替你做数据修复、补偿或 replay。
- 它不会自然长出责任归属和处理时限。
这里其实有一个更具体的起点,就是 Poison Message。
这类消息的典型特征是:无论重试多少次都很难成功,还会持续阻塞消费者。
如果系统没有隔离机制,一条坏消息就可能把后面的正常消息一起拖住。DLQ 在这里最核心的价值,就是把这种消息从主链路里摘出来。
所以我更愿意把 DLQ 理解成异常路径里的一个隔离点,而不是整套方案。
很多系统不是没有 DLQ,而是把“有 DLQ”误当成“异常治理已经完整”。结果就是主链路吞吐保护住了,业务闭环却没有真正建立。
五、事件驱动系统不能只设计正常路径,还必须把异常路径单独设计出来
正常路径大家都很熟:
Producer -> MQ -> Consumer -> 业务处理成功
这条链路很好讲,也很好画架构图。
团队会讨论 topic 怎么拆、吞吐怎么扩、消费模型怎么选、数据怎么落库。
但真正让系统在长期运行中变重的,往往不是正常路径,而是异常路径。
Consumer 失败
-> Retry
-> Delay Retry
-> Dead Letter Queue
-> 分析
-> Replay / 补偿
-> 复盘
如果这条路径没有在设计阶段被显式定义,异常就会在系统内部沉积:
- 有些消息不断重试,消耗算力。
- 有些消息进入死信后长期没人处理。
- 有些业务结果一直没有兑现,但没有人立刻感知。
- 有些系统在整体可用的表象下,默默接受了一部分不一致。
这也是为什么我越来越觉得,事件驱动系统不应该只追求“消息发出去了、链路能跑通”。
更关键的是:
当消息处理失败时,系统有没有一条明确的异常路径,把这个失败从技术事件变成可治理的业务对象。
这条路径一旦缺失,DLQ 再多也只是把问题换个地方放。
六、真正要建设的,是一条异常治理链
如果死信队列只是异常路径里的一个节点,那真正需要建设的能力就会变得更清楚。
在我看来,一个能长期运转的异常治理链,至少要有五步:
1. 发现
系统必须先能看见异常,而不是靠人碰巧发现。
最基础的可观测项至少包括:
- consumer failure rate
- retry rate
- DLQ backlog
- 消费延迟
- replay 成功率
但只盯技术指标还不够。
更有价值的是把它们和业务对象挂起来:哪个租户、哪条订单链路、哪类权益发放、哪种补偿动作正在失败。
2. 分类
异常不能只按“失败了”来处理。
至少要回答:
- 这是临时故障,还是确定性失败?
- 这是依赖抖动,还是数据问题?
- 这是可以自动恢复的,还是必须人工介入的?
- 这条消息到底对应哪个业务结果?
没有分类,后面的处置一定会混乱。
3. 分析
真正的分析不是看一眼日志就结束。
它要回答的是根因在哪里,以及业务影响是什么。
例如:
- 是消息数据本身坏了。
- 是消费逻辑有 bug。
- 是外部依赖不可用。
- 是系统设计把不该重试的问题反复重试了。
4. 处置
这一步才是异常治理真正的价值所在。
当原因确认之后,系统要明确采取哪种恢复动作:
- replay 历史消息。
- 数据修复后再 replay。
- 直接走业务补偿。
- 明确放弃,并留下审计痕迹。
注意,处置目标不是“把死信清空”,而是“把业务结果补回来,或者把无法补回的边界说清楚”。
5. 复盘
如果异常处理到 replay 为止,系统只是在不停擦地。
真正的治理闭环,一定还要回到系统本身:
- 重试策略是不是太粗。
- 数据校验是不是太晚。
- 是否缺少租户级监控。
- 是否应该增加 replay 工具或数据修复工具。
- 责任边界是不是本来就不清楚。
异常复盘做得多了,系统才会逐渐从“会出错”走向“出错后还能被治理”。
七、从工程落地看,至少要补齐三类能力
如果要把这篇文章压成几条可执行的建议,我会优先补这三类能力。
1. 让死信和业务对象挂上钩
不要让 DLQ 只是 broker 上的一个名字。
至少要能回答:
- 这条死信对应哪个业务动作。
- 影响的业务对象是谁。
- 当前责任归属在哪个系统、哪个团队。
如果死信消息和业务对象之间没有映射关系,后续所有治理动作都会很慢。
2. 给死信建立明确的处理流程和时限
很多团队的问题不是不会 replay,而是没人知道什么时候该处理、谁来处理。
所以流程至少要明确:
- 发现后谁接单。
- 谁做分类。
- 谁决定是补偿、修数据还是 replay。
- 什么级别的异常需要升级。
- 多久不处理就算超时。
没有这些定义,死信队列里的数据只会越来越旧。
3. 提前准备 replay 和修复工具
如果每次处理死信都要临时写脚本,治理成本会非常高。
系统规模一上来,这种方式一定撑不住。
更现实的做法是把这些能力前置成工具:
- 查看死信详情。
- 按条件筛选同类异常。
- 修复数据后重新投递。
- 保留 replay 审计记录。
你不一定一开始就做成一套“异常处理平台”,但至少要让异常治理不再完全依赖人工体力。
系统规模再往上走,这三类能力通常还会继续演进成平台能力。
因为当 Topic、Consumer 和死信队列的数量同时增长以后,如果每个系统都各自处理异常,治理成本很快就会失控。
到那一步,统一的监控、分析、回放和审计,往往就不是锦上添花,而是必要基础设施。
八、写在最后
死信队列当然有价值,而且在成熟系统里通常是必要的。
但它真正的价值,不是“系统终于有地方放失败消息了”,而是:
- 它隔离了坏消息,保护主链路吞吐。
- 它把无法自动恢复的问题显式暴露出来。
- 它迫使团队正视一件事:系统里有一些业务结果,并没有像你想的那样自然完成。
所以我不太愿意把 DLQ 写成一个“消息中间件功能点”。
它更像一面镜子。
一个团队怎么对待死信队列,往往就怎么对待异常治理。
如果死信长期堆积却没人关心,说明系统并没有真正建立异常路径;
如果死信能够被及时发现、分类、修复、replay 和复盘,说明团队已经开始把异常当成一种系统能力来治理。
这类问题本质上也和我在 《复杂业务治理:思考》 里写的是一回事:
系统真正难的,往往不是把正常路径跑通,而是把长期例外纳入治理。
这篇文章最后只想落一句话:
死信队列的工程价值,不在于存储失败消息,而在于把“业务未完成”纳入一条可治理的异常路径。
只有把这条路径设计出来,进入死信才不是终点,而只是治理的开始。