3042 字
约 10 分钟
15
S16-A23 · 没人处理的那些消息:死信与积压的处置
2024-12-31
2026-11-09

某次例行巡检,有人顺手点开了死信队列的监控面板:存量五十万条,最早的一条是七个月前。没有人知道这些消息是什么,也没有人记得曾经有过相关的告警。上游业务方早就换了一版接口,这些消息对应的数据结构在代码里已经不存在了。

死信的本质不是"处理失败",而是系统承认处理失败,然后没有任何人接手。失败本身有告警、有日志、有重试;失败之后的去向,通常什么都没有。这篇讨论的是这条缺省路径:当一条消息最终无人处理时,系统默认把它放在哪里、放多久、由谁负责。


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 就告警"。它有两个结构性缺陷:

  1. 存量在阈值以下时完全静默,而五十万条的案例里,前几千条正是最容易处理的时候;
  2. 存量一旦长期高于阈值,告警进入常态,被静音或被忽略。

有效的告警语义是趋势与停滞,不是绝对值:

// 三条告警覆盖三种失效形态
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"时,先怀疑单条处理耗时,而不是先扩容。

处置顺序固定为三步:先确认不丢,再提速,最后清理。

  1. 确认不丢:检查保留期是否够长、磁盘水位是否安全、副本是否同步。保留期短于预计恢复时间的队列,第一动作是延长保留期,而不是扩容;
  2. 提速:扩消费者(受分区数限制)、提高单批拉取量、把慢依赖改为异步、必要时临时跳过非核心消息;
  3. 清理:对已过期或无业务价值的消息做归档或丢弃,并留下丢弃计数与规则说明。

批量重投是提速阶段最容易制造二次事故的动作。一次投五十万条,等于自己向下游发起一次流量高峰:

─── 分批重投:批大小与间隔都要显式给,禁止一次性倾倒
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 天",要按消息类型分别定,并且写进路由配置里,而不是留在某个人的记忆里。


自测

  1. 死信与积压的本质区别是什么?把两者混淆会导致哪个具体的错误动作?
  2. 为什么"重投"不能作为处理死信的第一步?
  3. 为什么"存量超过阈值"是一条弱告警?更强的告警语义是什么?
  4. 判断积压应看哪两个速率,为什么绝对条数没有信息量?
  5. 积压处置的三步顺序是什么,为什么"确认不丢"要排在"提速"之前?
  6. 保留期为什么不能所有队列统一设成同一个值?
S16-A23 · 没人处理的那些消息:死信与积压的处置
https://www.clxhxhhr.top/posts/6370/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。