某次例行巡检,有人顺手点开了死信队列的监控面板:存量五十万条,最早的一条是七个月前。没有人知道这些消息是什么,也没有人记得曾经有过相关的告警。上游业务方早就换了一版接口,这些消息对应的数据结构在代码里已经不存在了。
死信的本质不是"处理失败",而是系统承认处理失败,然后没有任何人接手。失败本身有告警、有日志、有重试;失败之后的去向,通常什么都没有。这篇讨论的是这条缺省路径:当一条消息最终无人处理时,系统默认把它放在哪里、放多久、由谁负责。
1. 死信与积压是两种问题,处置方式完全不同
运维语境里这两个词经常被混用,但它们的成因、判据、处置动作几乎没有交集。
| 维度 | 死信 | 积压 |
|---|---|---|
| 本质 | 单条消息处理不了 | 处理速度跟不上生产速度 |
| 表现 | 条数增长缓慢,但永不下降 | 条数快速增长,速率差持续为正 |
| 判据 | 重试次数耗尽 | 消费速率 < 生产速率 |
| 处置动作 | 修因、重投、归档 | 扩容、降级、丢弃或延后 |
| 危险动作 | 无脑重投(会再失败一次) | 无脑扩容(可能压垮下游) |
| 复发性 | 高,同一个缺陷会持续产死信 | 低,通常是容量或依赖问题 |
| 谁该处理 | 消息生产方或消费逻辑负责人 | 消费方容量负责人 |
把积压当死信处理,会逐条看消息内容;把死信当积压处理,会扩容消费者,而扩容后的消费者以同样速度继续把消息推进死信队列——加速失败。
2. 死信的三个来源,与各自的处置成本
死信不是一类问题,是三类问题共用同一个收容所。
| 来源 | 典型形态 | 修因难度 | 重投前必须做什么 |
|---|---|---|---|
| 数据异常 | 字段缺失、金额为负、外键指向不存在的记录 | 中,取决于上游是否配合 | 清洗或补数据,否则必再失败 |
| 代码缺陷 | 空指针、反序列化不兼容、状态机卡死 | 低到中,自己改 | 发版修复,重投才有意义 |
| 依赖不可用 | 下游超时、第三方接口变更、鉴权过期 | 高,常不受自己控制 | 确认依赖已恢复,并限速重投 |
这三类的共同点是:重投本身不解决任何问题。重投是把消息再送一次,如果失败原因还在,它只是把消息从死信队列搬运到死信队列,顺带消耗一次下游容量。
所以处置顺序永远是:先归因,再修因,最后重投。跳过前两步的重投,在监控上表现为"重投成功率极低",在业务上表现为"消息转了一圈又回来了"。
// 重投前的前置条件检查,缺一不可
public boolean canRedeliver(DeadMessage msg) {
// 条件一:根因已标记修复,工单号非空
if (msg.getFixTicket() == null) return false;
// 条件二:该批次的历史重投成功率高于阈值
if (redeliverStats.successRate(msg.getBatchId()) < 0.5) return false;
// 条件三:重投次数未超上限,超限转归档而不是继续投
return msg.getAttempts() < MAX_ATTEMPTS;
}
重投必须有次数上限和最终归档。没有上限的重投系统,等于把死信队列变成了一个会自我增殖的队列;没有归档的系统,等于永远不敢删任何一条消息。
3. 告警要盯"条数不下降",而不是"超过阈值"
死信告警最常见的写法是"存量 > 1000 就告警"。它有两个结构性缺陷:
- 存量在阈值以下时完全静默,而五十万条的案例里,前几千条正是最容易处理的时候;
- 存量一旦长期高于阈值,告警进入常态,被静音或被忽略。
有效的告警语义是趋势与停滞,不是绝对值:
// 三条告警覆盖三种失效形态
groups:
- name: dlq
rules:
// 一:持续增长,说明有稳定产死信的源头
- alert: DLQGrowing
expr: increase(dlq_size[1h]) > 0 and dlq_size > 0
for: 30m
// 二:存量不下降,说明有人在处理但处理不动,或根本没人处理
- alert: DLQNotDraining
expr: delta(dlq_size[6h]) == 0 and dlq_size > 0
for: 6h
// 三:重投成功率过低,说明在重投未修因的消息
- alert: DLQRedeliverIneffective
expr: rate(dlq_redeliver_failed[1h]) / rate(dlq_redeliver_total[1h]) > 0.8
for: 2h
第二条是关键,也是最难被写出来的:"六小时内一条都没有减少"比"超过一千条"更能说明问题。它捕捉的是"没人管",而不是"量很大"。量很大但每天清空两次的队列,是健康的;量很小但三个月纹丝不动的队列,是没人管的。
与告警同等重要的是归属人。"我们会看的"是最不可靠的承诺——它没有主语,也没有时间。死信必须挂在具体的人或团队上,并且这个归属要能被查出来:
// 每条死信路由都要有归属,没有归属的路由等于垃圾桶
dlq_routes:
- queue: order.pay.dlq
owner: PAY_TEAM
ticket_project: PAY
retention_days: 30 // 保留期,到期自动归档
redeliver_mode: manual // 必须人工确认才重投
- queue: notify.push.dlq
owner: GROWTH_TEAM
retention_days: 7 // 推送类消息时效短,过期无价值
redeliver_mode: auto_limited
4. 积压:判据是速率差,处置顺序是先确认不丢
积压的判断依据不是绝对条数。"积压十万条"这句话本身没有信息量——一条每秒百万级的流里,十万条是几秒钟;一条每天几百条的流里,十万条是几个月。
| 判据 | 计算方式 | 含义 |
|---|---|---|
| 净增长速率 | 生产速率 − 消费速率 | 正且持续 = 真积压 |
| 预计清空时间 | 存量 / (消费速率 − 生产速率) | 负值或极大 = 不会自愈 |
| 消费延迟 | 最新消息时间戳 − 当前时间 | 更贴近业务感受 |
| 分区倾斜度 | 最大分区存量 / 平均存量 | 高倾斜 = 单分区热点而非总量问题 |
第四项常被漏掉:总量看起来正常,但某个分区堆了几十万条,原因是分区键设计导致热点。这时候扩容消费者无效,要改分区策略。
─── 两个速率必须同时间窗、同口径,否则算出来的差值没有意义
prod=$(rate kafka_topic_messages_in_total[10m])
cons=$(rate kafka_consumer_messages_out_total[10m])
echo "net=$(echo "$prod - $cons" | bc) msg/s" // 持续为正且清空时间为负 = 不会自愈
积压时的次生故障往往比积压本身更早被感知:会话超时(处理时间超心跳间隔被踢出组,再均衡期暂停消费,积压加剧)、心跳失效导致的重复消费、位点提交滞后带来的重复处理。看到"消费者频繁 rebalance"时,先怀疑单条处理耗时,而不是先扩容。
处置顺序固定为三步:先确认不丢,再提速,最后清理。
- 确认不丢:检查保留期是否够长、磁盘水位是否安全、副本是否同步。保留期短于预计恢复时间的队列,第一动作是延长保留期,而不是扩容;
- 提速:扩消费者(受分区数限制)、提高单批拉取量、把慢依赖改为异步、必要时临时跳过非核心消息;
- 清理:对已过期或无业务价值的消息做归档或丢弃,并留下丢弃计数与规则说明。
批量重投是提速阶段最容易制造二次事故的动作。一次投五十万条,等于自己向下游发起一次流量高峰:
─── 分批重投:批大小与间隔都要显式给,禁止一次性倾倒
BATCH="${BATCH:-500}" // 单批条数,按下游容量定
SLEEP="${SLEEP:-2}" // 批间隔秒数
while true; do
n=$(dlq_tool redeliver --queue "$QUEUE" --limit "$BATCH" --dry-run | jq '.count')
[ "$n" -eq 0 ] && break
dlq_tool redeliver --queue "$QUEUE" --limit "$BATCH" --tag "TICKET_ID"
sleep "$SLEEP"
done
度量这四个数就够定位问题:死信存量与增长率、重投成功率、积压峰值与恢复时长、单条消息平均处理耗时。最后一项是区分"容量不足"与"消费逻辑变慢"的唯一依据,而这两者的处置动作相反。
5. 排错:五种常见的"消息不见了/消息不动了"
| 现象 | 常见原因 | 处理方向 |
|---|---|---|
| 死信存量不降但没人报错 | 无归属人或无告警 | 先补归属与"不下降"告警 |
| 重投后消息又回到死信 | 未修因就重投 | 停止重投,回到归因 |
| 积压但消费者 CPU 很低 | 单条处理阻塞在下游 IO | 看单条耗时,不是看 CPU |
| 消费者频繁 rebalance | 处理超时触发心跳失效 | 提高心跳容忍或缩批 |
| 消息在生产端就消失了 | 生产确认机制与重试策略不匹配 | 核对 acks 与生产端重试配置 |
排错顺序:先确认消息在哪(生产端/队列/消费端/死信),再确认"处理不了"还是"处理不完",最后才动容量。跳过第一步直接扩容是最常见的错误。
6. 边界:这些场景不需要正式的死信治理
| 场景 | 简化做法 | 理由 |
|---|---|---|
| 消息量极小(每天几十条) | 日志里检索失败记录 + 周度人工查看 | 治理体系的维护成本高于消息本身价值 |
| 错误可容忍丢失(埋点、日志上报) | 丢弃 + 计数告警 | 丢失的业务代价接近零 |
| 下游天然可重复消费且幂等 | 有限重试后丢弃 | 重复消费无副作用,归档价值低 |
| 一次性数据迁移任务 | 失败清单落文件,人工核对 | 生命周期短 |
| 受合规约束的消息(支付、合同、通知留痕) | 必须正式治理,且保留期按规约 | 丢失或超期删除有举证风险 |
最后一行值得单独强调:保留期不只是成本问题。有些消息必须留够(举证、对账、监管回溯),有些消息必须按期删除(个人信息最小化)。这两类要求会同时出现,所以保留期不能"统一设成 30 天",要按消息类型分别定,并且写进路由配置里,而不是留在某个人的记忆里。
自测
- 死信与积压的本质区别是什么?把两者混淆会导致哪个具体的错误动作?
- 为什么"重投"不能作为处理死信的第一步?
- 为什么"存量超过阈值"是一条弱告警?更强的告警语义是什么?
- 判断积压应看哪两个速率,为什么绝对条数没有信息量?
- 积压处置的三步顺序是什么,为什么"确认不丢"要排在"提速"之前?
- 保留期为什么不能所有队列统一设成同一个值?