18  传感器故障

章节 11 讲了退出码为什么要分三种。这一章给那个决定一个正式的名字, 然后用这个名字找出另外三样已经在跑、但没有被识别成同一类的东西。

18.1 控制工程里最实际的问题之一

在一个闭环控制系统里,控制器看到的不是被控对象本身, 而是传感器对被控对象的读数。这两者不是一回事, 而它们不一致的时候会发生一件很糟的事情。

如果传感器坏了、读数归零,一个朴素的控制器会认为误差极大, 于是全力施加修正 —— 为了消除一个不存在的误差,把被控对象推向毁灭。

这不是理论问题。飞机的空速管结冰、锅炉的热电偶断路、 反应堆的液位计卡死 —— 每一类都有过真实的事故, 而事故的形态几乎总是一样的:控制器工作得非常”努力”, 努力的方向完全错误,而所有人都在看着一个假的读数。

翻译到这里:

如果 CI 因为基建故障而红,而 Agent 把这个红灯当成”代码错了”, 它会去改本来正确的代码 —— 而且会改得很有信心, 因为它手里确实握着一个红灯。

小节 11.2 里那个数字,现在可以重新读一遍: 取样的五百次失败里,一百一十九次是基建故障 —— 23.8%。

在一个真实的控制系统里,如果四分之一的传感器读数不可信, 而控制器不做判别 —— 那这个控制器的表现会比开环还差。 开环至少不会主动往错的方向推。

这是一个可以量化的论断,不是修辞。它解释了一个很多团队都有、 但归因错误的现象:引入 Agent 之后,某些模块的代码质量不升反降。 通常的解释是”Agent 写得不好”。更可能的解释是: 那些模块恰好在基建故障率最高的那一层(见前面那张表:构建阶段 37%), 于是 Agent 反复在正确的代码上做无效修改。

18.2 四种传感器故障管理,全都已经在跑

有意思的地方在这里:这套系统里已经有四种独立的传感器故障管理机制, 是各自撞出来的,撞完之后正好凑成了一个完整的栈。

已有机制 控制工程里的名字 它防住什么
退出码 0/1/2 三分 对象故障与传感器故障的判别 在不可测的环境里动作
sentinel_min 哨兵下限 传感器量程 / 合理性校验 一个不可能的读数被当真
生成物对拍测试 解析冗余 单一测量通道的静默漂移
变异验证 主动注入已知故障标定传感器 一个永远不会红的测试被当成保护

四种的原理各不相同,值得逐条看,因为它们对付的是四种不同的传感器失效方式。

18.2.1 一、判别:分清是谁坏了

这是最基本的一条,也是 章节 11 讲过的。核心是那句:

判不了 = 不通过,而且要说清楚”我判不了”, 既不假装通过,也不报成对象的错。

在代码里它是一个三态枚举加一个匹配臂顺序。在控制里它叫 故障检测与隔离 —— 先检测出异常,再判断异常来自哪个部件。

18.2.2 二、量程校验:这个读数可能吗

哨兵下限做的事情是:为每一条规则声明一个”它至少应该看到多少个事实”的下界。

如果某一天匹配数掉到下界以下,更可能的解释是扫描逻辑坏了, 而不是代码一夜之间变干净了。

这在传感器工程里是最标准的一道防线:一个温度传感器读出零下三百度, 你不会认为房间变冷了,你会认为传感器断了。因为那个读数不在物理可能的范围内。

这里的实现有一个细节值得学(小节 13.4.3 讲过,这里从控制的角度再看一遍): 哨兵的类型是 NonZeroUsize,而且是必填

也就是说,你没法写一条不带量程校验的规则,也没法把校验关掉。 在传感器工程里,这相当于规定”每一个通道都必须声明它的有效量程”—— 而这恰恰是最容易在赶工期时被省掉的东西。

18.2.3 三、解析冗余:用第二个通道验第一个

当你只有一个传感器时,你没法知道它对不对。经典的解法是装两个 —— 但那很贵。控制工程里更常用的是解析冗余: 用一个模型从别的可测量算出这个量应该是多少,然后和实测值比。

小节 7.1.4 那条对拍测试就是一个纯粹的例子。 从控制的角度看它的结构是:签入仓库的生成代码是实测值, 构建系统当场重新生成的是模型算出来的值,两者必须一致。

这里的”模型”不是比喻 —— 它就是那个生成器, 一段能从源声明推出产物应该长什么样的确定性代码。 解析冗余要的正是这样一个东西:能从别的可测量推出目标量, 而且不和被验的那个通道共享实现。

单一测量通道永远发现不了那类漂移,因为签入的代码本身没有任何异常。 只有第二个独立通道能。

18.2.3.1 这条冗余的实现只有十几行

值得看一眼它的实际实现,因为解析冗余听起来很重,实际上可以很轻。 这条测试是一条构建规则,它生成的是一个 shell 脚本:

if cmp -s {generated} {checked_in}; then
  exit 0
fi
diff -u {checked_in} {generated} || true
echo "{path} is stale; regenerate it from its .aio module" >&2
exit 1

四行有效逻辑:比一下、相同就过、不同就打出 diff、 然后报一句话说明该怎么修。这条规则本身的定义也只有两个必填属性 —— generated(构建系统当场重新生成的那份)和 checked_in(签入仓库的那份)。

三处值得学。它比的是文件本身,不是某种摘要或指纹—— 所以失败时能直接把 diff 打出来,而 diff 就是修复指引, 不需要人再去想”哪里不一样”。 || true 那一处是刻意的diff 在有差异时返回非零, 而这里的判定已经由 cmp 做完了, 所以要防止 set -e 在打印之前把脚本掐掉 —— 这是一个很小的细节,但它决定了这条检查失败时有没有证据最后一行的措辞给出了修法(从它的模块重新生成), 而不是只说”不一致”—— 这正是 小节 13.4.4 那条纪律。

它证明了一件更一般的事:解析冗余的成本, 主要不在”写那条比较”,在”让第二个通道存在”。 这里第二个通道是免费的,因为生成器本来就能被构建系统独立调用; 如果生成器只能在某个人的机器上手工跑, 这条测试就写不出来 —— 所以真正的投入在 “让生成过程可被自动化调用”这一步上, 而那一步通常出于别的理由已经做过了。

18.2.4 四、机内自检:主动注入已知故障

前三条都是被动的 —— 它们等异常出现。第四条是主动的。

在航空电子里这叫 BITE(机内自检设备):系统定期给自己注入一个已知的信号, 然后检查传感器有没有按预期反应。如果注入了故障而传感器毫无反应, 那么这个传感器已经失效了,尽管它一直在输出看起来正常的读数。

这套系统里对应的是两件事:

变异验证 —— 把那个修复拿掉,回归测试必须重新变红。 如果它不变红,说明这条测试守的根本不是行为。这是给测试注入一个已知故障, 看它响不响。

规则的契约测试 —— 检查器的测试目录里,每一条规则都有一个配对的 *_tests 模块,里面故意造出违规的输入,验证检查器确实会报错。

这两件事的共同结构是:一个从来不会报错的检查器, 和一个不存在的检查器,是同一个东西。而你没法通过观察它的正常输出 来区分这两者 —— 只能主动注入。

18.3 四种机制对应四种失效方式

把它们排在一起,能看出这个栈为什么是完整的:一个传感器可以 不可用(我读不到)、可以读数不合理(我读到了,但那个数不可能)、 可以慢慢漂移(读数看起来正常,但和真相脱节), 也可以完全失灵却仍在输出(永远读同一个正常值)。 四种失效方式,四种机制,一一对应:

flowchart TB
  S["一次测量"]
  S --> F1["不可用<br/>我读不到"]
  S --> F2["读数不合理<br/>不在可能范围内"]
  S --> F3["慢慢漂移<br/>看起来正常,和真相脱节"]
  S --> F4["失灵却仍在输出<br/>永远是同一个正常值"]
  F1 --> M1["退出码 2<br/>判别"]
  F2 --> M2["哨兵下限<br/>量程校验"]
  F3 --> M3["对拍测试<br/>解析冗余"]
  F4 --> M4["变异验证<br/>机内自检"]

第四种是最危险的,因为它是唯一一种从输出上完全看不出来的。 前三种都会在某个时刻表现出异常 —— 一个错误、一个不可能的数字、 一次不一致;而第四种的输出永远是”通过”, 它和一个真正健康的传感器在数据上完全同构。 这也正是形状 A 在检查器这一层的形态。

大部分做 Agent 系统的人,这四样一个都没有 —— 他们只有一个布尔值。 这个栈的存在说明了一件事:它们不是奢侈品, 是任何一个认真做闭环的人迟早会重新发明的东西。

18.4 fail closed 与 fail open

一个系统在传感器失效时的默认行为,暴露了它真正的设计意图。 小节 11.5 讲过为什么这里必须 fail closed: 绝大多数系统默认 fail open,而那个默认值只在有人会记得回来补的世界里成立。

放到传感器这个视角下,还能多说一层:这个选择有真实的代价 —— 基建抖一下,所有人的流水线一起停,而这个代价是当场可见的。 它之所以仍然对,是因为另一侧的代价不可见: fail open 的成本不会出现在任何一次事故报告里, 它只会表现为”某天我们发现这个检查已经三个月没有真正跑过了”。

两侧的可见性不对称,是所有传感器故障管理的共同处境 —— 后面四种机制(小节 18.3)付的都是可见的成本, 挡的都是不可见的损失。

18.5 哨兵的局限

这一节是对源系统提出的第二条改进建议。

哨兵下限是一个手写的绝对常量。类型系统保证了它必填、非零, 这堵死了最省事的绕过路径,但没有解决一个更慢性的问题: 仓库在长,扫描面在变。 一条规则今天扫 9,046 处, 一年后可能扫 15,000 处 —— 那个写死的下界什么时候调?谁调?根据什么调?

只要它需要被手工调整,就存在一条路径:某天一条规则因为哨兵失败 而挡住了人,最快的解法是把下界调低, 而这个动作和”修复一个过时的阈值”在 diff 上长得一模一样。 这不是说会有人恶意绕过,是说这条路径上没有任何摩擦, 而没有摩擦的路径最终一定会被走。

更稳的形态是相对变化率而不是绝对值:这次的扫描数 相对上一个版本掉了超过某个比例,就判传感器故障。 因为真正的信号是突变,不是”低于某个数”—— 一个逐渐增长的仓库里,扫描数应该是缓慢单调上升的, 任何一次断崖式下跌都值得怀疑,不管它跌到了多少。 一条从 9,000 掉到 3,000 的规则,在绝对下界 50 面前毫无反应, 而它显然已经瞎了三分之二。

这个改法还有一个额外的好处:它不需要任何人去维护阈值—— 基线由上一次运行自己提供。这正是这套系统在别处反复用的那条原则 (证据绑定版本),只是还没有用在哨兵上。

18.6 为什么这四种机制会被独立地重新发明

这套系统里的四种传感器故障管理,没有一种是从控制工程里学来的, 它们各自是从一次具体的失败里长出来的:退出码三分来自 “大量红灯其实是基建故障,而 Agent 在改正确的代码”; 哨兵下限来自”一条规则的扫描范围因为配置变化而缩小,静默失效”; 生成物对拍来自”有人手改了生成物,下次重新生成时被静默抹掉”; 变异验证来自”测试通过,但拿掉修复它照样通过”。

四条不同的路径,走到了同一个地方。 这不是巧合 —— 它说明一旦你开始认真依赖某个测量, 你就必然会遇到”测量本身不可信”这个问题, 而这个问题的解法空间不大,所以不同的人会收敛到相似的答案。

这个观察有一个实际用途:如果你的系统里一个这样的机制都没有, 那不是因为你不需要,是因为你还没有认真依赖过任何一个测量。 “认真依赖”的标志很具体 —— 你会不会因为某个检查是绿的,就跳过人工确认? 如果会,那你已经在依赖它了,而它的自检机制现在还是空的。

18.7 传感器故障管理的成本

这四种机制不是免费的。退出码三分的成本是每接一种新工具 都要判断它的失败该归哪一类;哨兵下限的成本是每条规则 多一个必须维护的常量;解析冗余的成本是一条额外的测试, 以及生成器要能被独立调用;机内自检的成本是每次修 bug 多一步、 每条规则多一个配对的测试。

其中第二条的成本是持续的,而 小节 18.5 讲过它还没有被很好地解决 —— 那条建议之所以值得优先做: 它不只是提高了灵敏度,还消掉了一笔长期的人工维护成本。

但这些成本有一个共同特点:它们都在建设时付,不在故障时付。 它们避免的那类失败,代价是在故障时付的, 而且是在你不知道自己在故障的那段时间里持续地付。 这个成本结构决定了它们值不值:如果你的系统会活很久,值; 如果它三个月后就没了,不值。

18.8 传感器故障和形状 A 是同一件事

值得把这两个概念对齐一次,因为它们在书里是分开出现的。 形状 A(小节 4.2)说的是探针和被测对象之间有一条未被验证的因果假设; 传感器故障说的是测量本身不可信。 这是同一件事的两种表述 —— 一个从现象说,一个从机制说。

合起来之后,它们给出了一个更完整的图景:

未被验证的假设 它对应哪种传感器失效 对应的机制
“能读 ⇒ 能写” 测的是另一个量 需要换测量对象,四种机制都救不了
“工具跑完 ⇒ 检查执行了” 测量没有发生 量程校验(执行数不为零)
“签入的 = 生成的” 测量慢慢漂移 解析冗余
“测试在 ⇒ 行为被守住” 测量失灵但仍在输出 机内自检

第一行是最难的,因为它不是”传感器坏了”, 是”传感器测的从来就不是那个量”。一个测得非常准的错误的量, 四种机制全都会报告一切正常 —— 它们验证的是测量的健康度, 不是测量的相关性。

这类问题没有自动化的解法,它需要一次人的检查: 坐下来,写清楚这个探针实际在测什么,再写清楚你想知道什么, 然后看这两句话一不一样。这个练习看起来幼稚, 但它是唯一能发现这类问题的方法, 而且做完之后通常会有一两处让人不安的发现。

18.9 这四种机制之间也有顺序依赖

小节 18.15 按性价比排了顺序, 但四种机制之间还有一层更硬的依赖关系, 它决定了某些顺序不只是不划算,是做不了

机内自检依赖判别。 你往一条检查里注入一个已知故障, 期待它变红 —— 而如果这条检查的失败语义是两态的, 你没法区分”它红了因为抓到了注入的故障”和 “它红了因为注入的过程本身把环境搞坏了”。 没有三态,机内自检的结论是不可信的。

解析冗余依赖量程校验。 两个通道给出不同的数, 说明其中一个错了 —— 但如果你不知道每个通道的合理量程, 你连”哪一个更可能是错的”都判断不了, 更糟的是两个通道同时坏掉给出同一个错误的数这种情况 完全无法被发现。量程校验是每个通道各自的下界, 而冗余是通道之间的比较,两者是正交的、缺一不可。

量程校验依赖一件更基础的事:那个”工作量”的数得存在。 一条不打印扫描面的检查,你连量程校验都无从加起 —— “从第一天就打印它”这条建议之所以 被我重复了三次(小节 16.12.4)。

把这三条依赖串起来:打印工作量 → 量程校验 → 判别 → 机内自检 / 解析冗余。这条链和 小节 18.15 那个性价比排序基本一致,但它给出的是一个更强的理由: 不是”先做便宜的”,是后面的做不了,除非前面的先做完

18.10 跨领域的对照

这四种机制在别的工程领域里都有成熟的对应物,值得列出来, 因为知道它们有名字,意味着可以去查那个领域的经验:

这里的机制 航空 过程控制 分布式系统
退出码三分 故障检测与隔离 传感器有效性判别 熔断的半开状态
哨兵下限 量程与合理性检查 报警死区与量程 异常检测的下界
解析冗余 解析余度 软测量交叉校验 读修复 / 校验和
机内自检 机内自检设备 定期标定 混沌工程

最后一列的最后一行值得注意:混沌工程和机内自检是同一个思想—— 主动注入已知故障,验证系统的响应符合预期。 它们在这套系统里的形态(变异验证、故意造违规变更) 比通常意义上的混沌工程便宜得多,因为注入的对象是测试和检查, 不是生产系统。

这是一条被低估的路径:在验证层做混沌工程, 成本低一个数量级,而它验证的正是你最依赖的那个东西。 一次生产环境的混沌实验需要窗口、需要预案、需要人守着; 而一次”把修复拿掉看测试红不红”只需要三十秒, 并且它问的是同一个问题 —— 这个防线是真的还是假的。

18.11 什么样的传感器最不该被信任

给一个排序,按”它坏了却不被发现”的概率从高到低。

第一名是那些从来不失败的。 一个跑了半年、 一次都没有红过的检查,有两种可能:它守的东西从来没被违反过, 或者它已经不工作了 —— 而这两种在输出上完全无法区分。 这就是机内自检(小节 18.2.4)存在的全部理由, 也是为什么这套系统里每条规则都有配对的”故意造违规”测试。

第二名是那些依赖外部服务的。 一个需要调用外部 API 才能完成的检查,在那个 API 变慢、返回格式变化、 或者悄悄降级时会失效,而这些变化通常不会导致明显的错误, 只会导致结果不准。

第三名是那些配置复杂的。 配置越多, “配错了但没报错”的可能性越大 —— 而这正是形状 D(小节 4.5)。

第四名是那些最近改过的。 任何一次对检查器自身的修改, 都可能引入静默失效,而检查器的修改通常不会被同等严格地测试 —— 因为”测试检查器”这件事本身就不在大部分人的心智模型里。

还有一名之外的:那些只回答了半个问题的。 这一类不在上面的排序里,因为它不是”坏了”, 是它从一开始就没覆盖你以为它覆盖的范围 —— 而它的伪装比前四种都好,因为它每天都在正常工作。

写这些东西的过程里出过一个干净的实例。这套稿子有一条交叉引用检查, 它保证每一个 @sec-xxx 都指向一个存在的锚点, 半年里一直是绿的。它漏掉的是同一个锚点被定义了两次—— 第八章有两节挂着同一个 id。这条检查当时依然是绿的, 因为它问的是”引用指向的锚点存在吗”, 而两个同名锚点里只要有一个存在,这个问题的答案就是”是”。

漏掉的后果不是报错,是渲染层静默选一个: 读者点一次交叉引用,跳到的可能是另一节,而没有任何东西会报警。 这是形状 A 的变体一 —— 探针测的是另一个量, 而它被抓到的方式和 小节 15.2.1 那次一模一样: 不是有人怀疑那条检查,是有人在做别的事时撞见了那两个同名的小节。

补它只花了十几行:在收集锚点时多记一个”这个 id 出现在哪几处”, 出现超过一次就报。补完之后它立刻又抓到了第二处 —— 一个在第十二章、我完全不知道存在的重复。 一条新加的检查在上线当天就抓到存量, 这件事本身就是它该被加的证据。

这四条合起来有一个实用的读法:它们描述的是同一批检查。 一个跑了半年没红过、依赖外部服务、配置复杂、不久前刚改过的检查, 是你系统里最不该被信任的那一个 —— 而它多半正是你最依赖的那一个, 因为它一直很安静。

18.12 跨越三层的例子

用一个具体的场景展示四种机制怎么配合:一条检查 “所有对外接口都有权限校验”的规则。

判别这一层做的是:当规则依赖的接口清单读不出来时, 报”判不了”,而不是在一份不完整的清单上给出通过。 量程这一层做的是:断言扫描到的接口数不少于某个下界, 防的是清单解析出了问题、只扫到三个接口。 冗余这一层做的是:从源码扫描和路由注册表两个来源各算一遍接口数并对比, 防的是某一个来源开始漏。 自检这一层做的是:测试里故意加一个无权限校验的接口, 验证规则会报错,防的是规则的匹配逻辑本身失效。

四层里第三层最容易被省掉,而它防的是最隐蔽的一种失效。 因为源码扫描和路由注册表理论上应该给出同一个数, 而当它们不一致时,说明其中一个的世界观已经过时了 —— 而你不知道是哪一个。

“不知道是哪一个”本身就是一个足够的告警: 它意味着你对这个系统的理解有一处错误, 而这个错误在别的地方也可能造成影响。 这也是解析冗余相比”再加一道检查”的本质区别 —— 它不告诉你哪里错了,它告诉你你的模型和现实之间有一道裂缝

18.13 传感器故障管理的一条元原则

把四种机制抽象一层,它们遵循同一条原则: 任何一个你依赖的判断,都必须有一个独立于它的方式来验证它还活着。

“独立于它”是关键。判别用退出码的语义来验证, 而不是用它的输出内容;量程用工作量来验证,而不是用它的结论; 冗余用第二个通道来验证;自检用已知的输入来验证。 四种都在绕开被验证对象自己的说法。

这条元原则可以直接推广出去:任何一个”它说没问题”的东西 —— 一个监控、一个健康检查、一个自动化任务、一份报表 —— 都需要一个不依赖它自己说法的验证方式。 这条推广的代价很低,因为它不要求你建第二套系统, 只要求你在设计每一个”绿灯”的时候多问一句: 这个绿灯的反面,是”没问题”还是”我没看”?

18.14 给读者的自检

对你系统里的每一道检查,问一个问题: 它自己坏了的时候,会表现成通过还是失败? 如果答案是”通过”,你有一个静默的传感器故障 —— 它会在你最需要它的那天让你以为一切正常。

把你的检查列出来,逐个填这张表:

检查 它坏了会怎样 有没有量程校验 有没有第二通道 有没有主动自检

大部分人第一次填完之后会发现,绝大多数检查在坏掉的时候 会表现成”通过”,而且没有任何一列有勾。 这不是什么灾难—— 这只是说明这些检查此前 从来没有被当成传感器看待过,而只要开始这么看, 前三列都能在一天之内补上第一个勾。

18.15 传感器故障管理的投入顺序

四种机制不必一次全建,按”成本除以收益”排有一个清楚的顺序。

第一是量程校验(哨兵):成本最低(打印一个已有的数 加一个下界比较),覆盖面最广 —— 它对任何一种”扫描面异常缩小”都有效,不管原因是什么。

第二是判别(退出码三分):成本半天, 而它防的是最贵的一类错误 —— Agent 在不可测的环境里改正确的代码。

第三是机内自检(变异验证):手工版本成本为零 (修 bug 时先让测试红一次),自动化版本很贵(小节 12.13), 所以只在关键路径上做。

第四是解析冗余:成本最高,需要第二个独立通道, 但它是唯一能发现”慢慢漂移”的机制(小节 18.3)。

18.15.1 该不该建第四种

解析冗余不是每个系统都需要,判据是一个问题: 这个测量的结果,如果慢慢地偏离真相,会有别的东西告诉你吗? 会,就不需要冗余;不会,就需要。

“生成物和它的源声明”正好是”不会”的典型: 手改一个生成物,代码能编译、测试能过、行为可能也对, 唯一坏掉的是那条不变量,而没有别的东西看着它。 反过来,一个算错了的价格会被用户投诉,一个变慢的接口会被监控发现 —— 那些地方不需要再加一层冗余,因为现实本身就是第二个通道。

18.16 和第三部的关系

最后把这一章的位置说清楚。第三部讲的三层判定全都是传感器, 而这一章讲的是:传感器自己会坏,而且坏的时候通常不报错。 所以这一章不是第三部的补充,是它的前提。

一个没有传感器故障管理的判定系统,它的所有结论都带着 一个未被验证的假设 —— “我的测量还在工作”。 这个假设在 小节 4.2 那张表里已经被证伪了五次, 在五个不同的层上。

这一章之所以值得单独存在、而且值得放在讲完三层判定之后: 你得先有测量,才谈得上测量的可信度。 一个还没有任何自动判定的团队读这一章,会觉得它是过度设计; 而一个已经在依赖十几道检查的团队读它, 会发现自己一直站在一个没有验过的地基上。