当一个诊断标记对所有对象都亮起:比功能不工作更难发现的一类 bug

给告警平台写了一个”规则审计”功能:扫描所有告警规则,给出诊断标记 —— 哪些规则只吵不产出pure_noise:一直触发,但从来没有真正转发出去过)、哪些在反复抖动flapping)。

它上线之后一直在工作,界面上有数据、有标记、有颜色。直到我为了补测试覆盖率随手写了一个用例,才发现:

这些标记对生产环境里的每一条规则都亮着。

结论先放前面:一个恒真的诊断标记,比一个不工作的功能更难发现 —— 因为界面是活的。它不会报错、不会空白、不会有人提工单,它只是不再排序任何东西。防它的办法不是加测试覆盖率,是给”命中率”本身设一个自检:任何一个用来筛选的标记,如果命中率是 100% 或 0%,它就已经坏了。

一、第一个:一个字符的列索引,把诊断反了过来

补覆盖率时写的第一个针对性测试就挂了 —— 挂在一条转发率 100% 的规则上:它也被标成了 pure_noise

根因是聚合查询选出来的是 (source, rule_name, ...),而下游收集名字去做关联的代码读的是 r[0] —— 拿到的是数据源名,却拿去和”规则名”列表比对

于是没有任何一条能匹配上,forwarded 对每条规则都停在 0,而这个标记的职责恰恰是”这条规则一直触发但从不转发”。

一个字符的索引,把一个诊断指标彻底反了过来。

值得记的是它为什么活了这么久:这个模块此前 68% 的覆盖率全是别处的测试顺带覆盖的,它自己一个测试都没有。而它存在的全部理由 —— 噪音图和去重链的遍历 —— 恰好在那没被覆盖的部分里。

“覆盖率还行”和”这个模块被测过”是两回事。 顺带覆盖到的往往是数据结构的构造和最外层的调用,真正承载业务判断的分支一行没跑。

二、第二个:修了列索引还是不对 —— 两个命名空间根本连不上

改完列索引,测试绿了,看起来结束了。

然后拿生产数据核了一遍:8 条被审计的规则仍然全部报 forwarded=0、仍然全部带着 pure_noise —— 其中一条在同一时间窗内有 127 条转发记录。

列索引只是表面。真正的原因是:

  • 决策轨迹里 matched_rules 存的是转发规则的名字(”所有告警通知”、”新高深度分析”);
  • 而这个审计是按告警规则的名字分组的(”充值金额单次超 500 报警”)。

这是两个不相交的命名空间。 无论读哪一列,这个关联都不可能命中。

改法是换一个维度做键 —— 决策轨迹里本来就有 alert_name 字段,正是这个维度,而且建过索引,于是关联从行扫描变成了一次分组聚合。顺带加了大小写不敏感匹配,因为上游不同系统送来的名字大小写并不一致(DatasourceNoDatadatasourcenodata 都有)。

这一段的教训值得单独写出来:

测试全绿 + 一个听起来合理的根因,不等于修好了。

第一次修复完全符合”合理”的标准:找到了一个真实存在的缺陷,改掉,测试通过。但它只是把一个 bug 换成了另一个 bug。这个标记只有在拿”答案已知的数据”验证之后才算被证明 —— 我知道那条规则有 127 条转发,所以我知道 forwarded=0 是错的。

自己写的测试用的是自己造的数据,而造数据的时候用的是和写代码时同一套(错误的)心智模型。它测不出命名空间这种问题。

三、第三个:阈值是相对的,等于没有阈值

同一个功能里的另一个标记 flapping,用同样的方式发现:8 条规则全带着它,所以它也没在排序任何东西。

它的判定是:

1
total >= days_active * 0.7

翻译过来就是:平均每个活跃日触发超过 0.7 次

对一个业务告警每天响好几次的环境来说,这是每一条规则、永远

这条判定的问题不在数值选得不对,在于它是相对的、而且没有陈述过。没有任何地方写着”我们认为每天 0.7 次算抖动”。它藏在一个表达式里,看起来像个算法。

更糟的是,它旁边就有一个真正在工作的检测器:一个基于 Redis 状态机的实现,检测真实的 firing↔resolved 振荡,有可配置的翻转阈值,而且已经在另一个界面上实时展示。要在历史数据上复现同样的判定,需要一个”每事件恢复标记”,而这个表数据模型里根本没存。

一个二流的代理指标,摆在一个能用的检测器旁边,比没有这个标记更糟。 因为它会被当成同一件事的另一个视角,而它其实只是噪声。

最后的处理是让审计停止猜测

  • events_per_active_day 直接报成一个数字,不做判定;
  • 只在超过一个明确写出来的阈值(每天 10 次,一条规则开始主导通知流的量级)时才标 high_volume
  • 汇总栏显示整个环境的中位数,而不是一个永远显示”全部”的计数器。

从”给你一个判定”退回到”给你一个数字 + 一条写明的线”,是这一类功能最该做的退让。判定是要负责任的,而这个数据模型支撑不起那个责任。

四、第四个:这次是我的度量错了

同一周我还报告过一处”缓存命中率有两套口径、差 8 行”的问题。复核之后要更正:那个缺陷不存在,是我量错了。

后端本来就把所有不走大模型的路径都加了起来(两条路径 128 + 8 = 136 行,正好等于标记为命中的行数),前端用的也是后端算好的比率。我的探针把 SQL 里的标记只和其中一条路径比,还去找了一个叫 cache 的字段,而实际返回的字段叫 cache_statistics

数字一直是对的,错的是我的测量方法。

不过这次核对确实暴露了一个真实的风险:那份”不走大模型的路径”清单,被手写在两个地方 —— 聚合查询里一份、看板渲染里一份 —— 而且没有任何东西把它们、以及数据库里那个标记绑在一起。往其中一处加一条新路径而忘了另一处,命中率就会悄悄少算,不会有任何测试失败。

所以最后改的不是算法,是把这份清单提成一个常量,放在它所属的枚举旁边,聚合从它求和,渲染直接用后端的总数不再自己列举,再加一个契约测试把三者钉在一起。

写下来是因为这件事本身也是个教训:“我发现了一个 bug”和”我的探针和被测系统对不齐”,看起来完全一样。 报缺陷之前,先确认自己量的是不是同一个东西。

五、这类 bug 的共同形状

四件事凑在一周里,回头看有清晰的共性。

它们都不报错。 没有异常、没有空白页、没有 500。功能”在工作”,只是输出恒定。这比崩溃难发现得多 —— 崩溃有人提工单,恒真没有。

成因集中在少数几类:

形状 例子
关联键取错列 读了 r[0] 数据源名,比对规则名
关联键跨了两个命名空间 转发规则名 vs 告警规则名,永远不相交
分桶键把不同的东西塌成一个 缺规则名的载荷全落进 {source}::unknown,三个互不相关的监控各抖一次就凑够了翻转阈值
阈值是相对的、且没被陈述 total >= days_active * 0.7
探针和系统对不齐 只比了一条路径、字段名找错

前三种在类型系统里都合法:都是字符串比字符串。编译器帮不上忙,测试如果用的是同一套错误心智模型造的数据,也帮不上忙。

六、怎么防

给命中率设自检。 这是成本最低、收益最大的一条。任何一个用于筛选或排序的标记,都应该有一条断言:

1
2
命中率 == 100%  →  它没有在排序任何东西
命中率 == 0% → 它可能根本没接上

这不需要知道正确答案是什么,只需要知道”全中”和”全不中”都是可疑的。可以做成一个日常巡检,也可以做成上线前的一次性检查。

关联之前先确认两边是同一个命名空间。 不是同一种类型(都是字符串),是同一种东西。这一步在写代码时问一句就够了:这一列的值,和那一列的值,会不会有一个是另一个的子集?如果答不上来,先去数据库里各取 5 行看看。

用答案已知的数据验证。 不是”造一批测试数据看看跑不跑得通”,是”我知道这条规则有 127 条转发,看它报多少”。前者验证代码自洽,后者才验证代码正确。

报数字,别报判定;非要报判定就把线画出来。 events_per_active_day = 3.2 永远不会恒真。flapping = true 会。

别在一个能用的检测器旁边放一个二流的代理指标。 如果已经有一个真正在做这件事的组件,审计功能应该去引用它的结论,而不是用历史数据近似重算一遍。

报缺陷之前先自证探针。 拿一个答案已知的样本喂给你的探针,确认它能给出正确答案,再拿它去测未知的。