消息队列告警怎么调才不吵:换判据、拆规则,而不是抬阈值

一条队列积压告警,30 天里 721 个小时有 26 个小时超过阈值 —— 平均每天误报一次。到这个程度,值班的人已经不看它了,它等于不存在。

调这类告警时最容易走的一条路是抬阈值。这篇记录的是为什么那条路是错的,以及正确的做法是什么。

结论先放前面:告警吵,通常不是阈值定低了,是判据选错了。抬阈值会在压掉误报的同时,把真故障一起压掉 —— 因为误报和故障的量级往往是重叠的。真正该做的是找一个能把两者结构上分开的判据:换指标、拆规则、或者用持续时间。

1. 现场:一次”看起来很像故障”的误报

某天下午,unacked(已投递未确认的消息数)冲到 2405,而正常水位的中位数是 4。告警响了。

查下来不是故障:

  • 单一队列贡献了其中 99.8%
  • ready(在队列里排队等待投递的消息数)全程为 0 —— 消息一进队列就被取走,没有一条在排队;
  • 消费者数从 30 自动扩到了 150;
  • 20 分钟后自愈;
  • 三个死信队列的峰值和当前值都是 0 —— 2400 条全部处理成功。

这是一次邮件批量突发,系统的表现完全正确:来了大量消息,自动扩容,全部处理完,一条没丢。

而当时告警的 summary 写的是「处理卡死、超时、或消费者崩溃未重连」。在这个场景下,这句话是误导性的 —— 它把一次成功的弹性伸缩描述成了故障。真正的卡死长这样:unacked 消费者数不变或归零 不下降。跟这次的情况恰好相反。

2. 为什么抬阈值是错的

第一反应是把阈值从 300 抬到 3000。看看数据同不同意。

那条队列 30 天的分布:

1
P50 = 0      P90 = 0      P99 = 1800      最大 = 2821

基线是 0,尖峰上千。 这个形状说明:抬阈值到 3000,确实不会再为批量突发报警了 —— 但也意味着”几百条消息卡死”这类真实故障再也不会触发。而几百条消息卡在队列里,恰恰是最需要知道的那种故障。

问题的本质是:误报的量级和故障的量级重叠。任何单纯的阈值,都不可能把它们分开。

早先还试过另一个方向:把 for(持续时间)从 5 分钟拉长到 15 分钟,指望”熬过”突发。这个方向也是错的 —— 那是拿真实故障的发现速度去换误报率。误报应该由更精确的判据消除,而不是让所有告警都变迟钝。后来把 broker 级的 for 调回了 5 分钟,让它退化成一张粗网,精确判定交给队列级规则。

3. 办法一:用持续时间区分(尖峰签名很干净的场景)

回头看那条邮件队列的尖峰,5 分钟粒度下签名非常干净

  • 每次都落在同一个时间窗(UTC 08:10–08:20,北京时间下午 4 点过);
  • 持续 10–20 分钟后回到 0;
  • 消费者同时从 30 自动扩到 150。

这是一个每天一次的定时批量发信,不是故障。它和真故障的区别不在量级,在持续时间:批量突发 20 分钟内自愈,卡死不会。

所以这条规则单独拆出来,配置反过来调:

1
2
阈值   300 → 100      (更灵敏,几百条卡住也能发现)
for 10m → 30m (靠持续时间滤掉每天那次批量)

阈值降低、持续时间拉长。 结果是既能抓到小故障,又不会为定时批量报警 —— 因为定时批量撑不过 30 分钟。

这里的前提是尖峰签名足够干净。如果批量作业的时长本身就飘忽不定,这招不成立,得换判据。

4. 办法二:换一个能区分状态的指标

另一条队列画像完全不同,用持续时间分不开,得换判据。

30 天、5 分钟粒度、8641 个样本:

指标 P50 P90 P99 最大
unacked 2 17 47 2500
ready 0 0 0 14148

关键差别在这里:

  • unacked 有稳定水位(P50=2、P90=17),而且它在消费者忙碌时会正常升高。所以 unacked 高,可能是”在忙”,也可能是”卡住”,它分不开这两件事
  • ready 非零只占 0.76% 的时间。正常情况下消息一进队列就被取走,ready 恒为 0。它一旦持续非零,就意味着消息在排队等不到消费者 —— 这才是积压的定义。

用一次真实积压对照:ready=14148unacked=2500、持续 185 分钟。两个指标都高,但只有 ready 在平时是干净的 0,所以只有它能当判据。

选指标的判据是”平时是否安静”,不是”故障时是否升高”。故障时会升高的指标很多,平时保持安静的很少。

5. 一个 CloudWatch + Grafana 的标签坑

这个坑很具体,但会让队列级告警看不出是哪个队列

用 CloudWatch 的 SEARCH 表达式一次抓多个队列的指标时,Grafana 会把队列名放进 Series 标签。但是:

1
2
多队列 SEARCH(返回多条序列)  Series = 队列名
单队列 SEARCH(只返回一条) Series = 指标名 ← 变了

当 SEARCH 只返回一条序列时,Grafana 把 Series 设成了指标名,不是队列名。

后果有三个:

  1. 告警消息里的对象名显示成 MessageUnacknowledgedCount 之类的指标名,看不出是哪个队列;
  2. Series 做的静默会失效;
  3. 以后同一指标再加别的单队列规则,Series 值一样,会互相撞上。

修法是给单队列规则显式加一个 queue 标签,不依赖 Series。加标签不改变路由去向(复核过仍然命中原来的接收组),并且写进文档:以后新建单队列规则照做。

顺带还有一个相关的:告警模板里”对象名”的取值链如果没有覆盖 Series 这一级,队列级告警就只显示一个数字。原文是这样的:

1
[MQ] 死信队列有消息 702.00        ← 哪个队列?

补上之后:

1
2
702.00   xxx-event.dlq
22.00 yyy:persist:queue:dlq

告警里必须有对象名。 一个只有数字的告警,收到的人第一件事是去翻看板 —— 那这条告警就没有完成它的工作。

6. summary 是写给凌晨三点的人看的

顺带说一下告警文案。原来那条 summary 是从设计文档里搬过来的,写了一大段可能的原因。前面第 1 节说了,它在批量突发的场景下是误导的。

重写之后包含四样东西:

  1. 两种情况怎么区分 —— 卡死是 unacked 高 + 消费者数不变或归零 + 不下降;批量突发是消费者会扩、会自愈。
  2. 定位到具体队列要怎么做 —— broker 级指标看不出是哪个队列,得用 CloudWatch 的 Queue 维度。
  3. 怎么确认有没有真丢消息 —— 查对应的死信队列。
  4. 一次实测数据当参照 —— 上次批量突发是 2405/20 分钟/自愈。

有参照数据这一条最有用。凌晨三点看到”2405”,不知道这算多还是不算多;知道”上次 2405 是正常的批量突发”,判断就快得多。

7. 一个没有采纳的方案,和它的代价

一度考虑把 CloudWatch 那套换成 Prometheus,用 cloudwatch-exporter 统一到现有的监控栈里。算完账放弃了:

  • 加上队列维度之后,单次抓取从 45 秒涨到约 220 秒
  • scrape_timeout 是 90 秒,超时会连带丢掉这个 exporter 已有的全部指标 —— 为了加新指标把老指标搞挂,得不偿失;
  • 费用还反过来涨了(CloudWatch API 调用量),每月约 160–200 美元。

所以这套队列级告警直接用 CloudWatch 当数据源,是这个 Grafana 里的首例。统一技术栈是有价值的,但不是无限价值的;当代价是”抓取超时把现有监控一起拖垮”,就该停下。

8. 小结

  • 告警吵,先怀疑判据,再怀疑阈值。 抬阈值会同时压掉误报和小故障,因为两者量级重叠。
  • 拉长 for 来压误报要小心:那是拿故障发现速度换的。误报该由精确判据消除。
  • 尖峰签名干净(固定时段、固定时长)时,用降阈值 + 拉长持续时间,既灵敏又不吵。
  • 选指标看平时是否安静unacked 有水位且忙碌时会升,分不清”在忙”和”卡住”;ready 平时恒 0,非零就是真排队。
  • CloudWatch SEARCH 只返回一条序列时,Grafana 的 Series 会变成指标名 —— 单队列规则要显式加 queue 标签。
  • 告警里必须带对象名,只有数字的告警等于没写完。
  • summary 里放一条上次的实测数据,比写一堆可能原因有用。