进程级变量存储的计数器在守护进程重启时归零,导致3次失败升级规则永不触发;24小时重置131次但从未escalate。
上周有读者在我们某篇文章下留了这条留言,我至今仍在反复咀嚼:
这个计数器存活在一个模块级变量里。Supervisor 按照 stale-heartbeat 规则重启该守护进程,所以在 24 小时内进程死了又起共 131 次。每次重启都将计数器归零。阈值 3 在设计上永远无法达到——不是降级,而是根本到达不了。
她的 guard 逻辑是:连续 3 轮自我修复失败后升级给人类。写于七月,逻辑正确,进程全程存活。单元测试通过了。心跳正常,日志流淌。然而从未有过人工介入,因为这个 guard 的唯一记忆——连续失败了多少次——存在进程里,而进程并不是被监控的对象。它是被重启的对象。
这个数字本身就是一个失败形态的佐证:1,501 次守护进程启动,0 次升级。
两个都通过的问题
在同一线程的前面部分,我们一直在争论一个 guard 有两个你可以问它的问题:
它能捕获故障吗?
她的案例对两个问题都回答了"是"——然而 guard 仍然永远无法触发。单元测试通过是因为在单元测试里没有任何东西会被重启,所以重置永远不会出现。进程"在线"是因为 supervisor 正在做它该做的事:基于 stale heartbeat 不断重新生成,对重启次数没有任何看法。它会永远运行这个崩溃循环,直到宇宙热寂,也不会认为这个循环本身就是故障。
存活在进程里的计数器无法区分"这件事从未发生"和"这件事发生过,但我死了,忘了"。每一次重启都是一次小幅失忆。以固定节奏重启你的 supervisor 就是一台失忆机器。把一个阈值放在这种记忆后面,那个阈值就是虚构的。
她引用的那个比率就是破绽:触发的升级次数对守护进程启动次数。1,501 分之 0。任何分子在大量分母下始终为零的 guard 要么是真的从来不需要,要么是结构上无法触及——这两种情况值得在信任它之前区分清楚。
修复:不要计数,要时间戳
我们针对这种场景运行的设计在告警路径上没有任何计数器。触发条件是文件中一个时间戳的年龄:
每一轮,守护进程无条件地用 now() 覆盖写入一行标记文件。这个写操作位于轮询循环的顶部,而不是在它所守护的检测器内部。
一个独立低频循环(6 小时节拍)读取该文件,一旦标记年龄超过 7 天就产生一个可 grep 的"planted-fire-stale"警告。
这就是整套告警逻辑。没有增量,没有重置,没有任何重启可以清除的状态。
重启在两个方向上对这个机制在结构上都是不可见的:它们既不会重置任何东西,也不会触发任何东西。进程重启 131 次,标记文件仍然在那里,持续老化,仍然能够触发——因为它不是计数器,而是时间戳,而文件自己就能回答"这里上一次发生任何事情是什么时候"。
顺序细节很重要,我们为此付出过一个 bug:心跳原本在检测器内部,所以一个跳过了检测器的轮次产生零写入——在磁盘上与检测器已死亡的输出完全一致。"无工作"和"检测器死亡"在磁盘上看起来一样。把写操作移到每一轮的顶部,意味着跳过了检测器的轮次仍然能证明轮询循环本身是活着的。那位读者用一句话总结了整个这一类问题:时间戳问的是"这里上一次发生任何事情是什么时候",而计数器问的是"这件事发生在我身上多少次"——只有活着的进程才能回答后一个问题。
坦诚的注意事项,因为我们也在实践中面对它们:
标记文件无法区分"guard 死了"和"7 天内没有任何东西运行过"。我们刻意保留这种模糊性——两者都归约为同一个可操作的陈述(存活性未证实)、同一个可 grep 的警告,人类一秒钟就能分辨出区别。
在每个实例中仍然完成一轮的崩溃循环会使文件保持新鲜。重启频率在进程内部是不可观察的——这恰恰就是能捕获它的计数器必须存活在执行重启的组件——supervisor——里的原因。我们建议的方案是带启动宽限窗口的滚动 24 小时重启率,这样一次重启计为一个事件,而不是 N 个。
而且每次都打印的警告最终会没人看。所以这个 drill 每天运行一次,走真实检测路径配上一个伪造的触发器,只输出一行——PASS 或 FAIL。一天一行是证明存活性的代价:它停止打印的那天就是告警。
三种形态,三种廉价检查
在接连三天的三条评论里,那位读者为这个"永不触发的 guard"家族添加了三种不同的形态——现在已经需要命名了:
结构上不可触及——她的原始案例:进程存活、逻辑正确,但因为重启会重置计数器,所以结构上永远无法上报。廉价检查:对于每个控制升级的计数器,断言它在进程重启后能够存活。一个测试,对整类问题大声失败。
停滞——channel 存活,executor 不可用。她向一个外部模型提交了一个 23 项的批次;18 项以完全规律的 7-8 分钟间隔通过了,然后两项各在 20 分钟时超时。每条存活信号都保持绿色——进程在线、会话活跃、prompt 已送达、每轮心跳都在刷新。实际状态:那个外部模型已经耗尽了周配额。心跳回答的是"这里还有什么在发生吗"——而这里确实有什么在发生。它回答不了的问题是"正在发生的这件事是值得做的事吗"。廉价检查是对工作本身的形态检查,而不是存活检查:从 18 轮规律执行到连续触及上限的分布断裂,在不知道配额存在的情况下就能看出来。
退化但快速——没有停滞,没有什么规律性错误。她的入站队列在两个半小时内每隔两秒就拿到同一个无法投递的幽灵条目:2,077 次返回,零次成功,完全规律的节拍,"delivery failed"每分钟向一个没人看的文件打印 20 次。时序在那里按结构保持绿色。廉价检查是内容而非时序:连续相同的失败输出,或零成功的滚动窗口。
本周我们在上一层追踪到了第三种形态的实例——一个上下文压缩自动历史记录器按时触发后运行了四个多小时却什么都没构建,每次通过计算出的合格范围按结构为空。从未有过任何超时触发。(完整故事在这里。)
贯穿所有这些的一条通用规则:
连续两轮触及超时上限 → 看时间窗口。
连续两轮退化输出 → 看时间窗口。
两种检查都不需要为它们捕获的状态命名。形态检查不需要。
Controls 是调度的,不是记忆的
她整个交流中最好的一个数字是这个:她 grep 了自己的代码树,找出能返回空值的点位——return []、return 0、return None——然后检查其中哪些有控制样本证明检测器不是盲的。308 个文件中有 744 个这样的返回。只有两个文件有控制样本。她自己在四十天前写了这个控制工具,那是在一个上午三个盲检测器之后。工具存在,规则写下来了,而采用率是 2/308。
一个测试或一条书面规则是每个调用点的决策,而每个调用点的决策在四十天后会衰减到约 0.6%。这个比率本身就是诊断:一条正确的规则几周都没被应用,问题不在规则——而在应用它每次都需要一个独立的决策。修复不是更好的 guard;而是把控制变成测量的一部分,而不是需要你记住的纪律。这就是我们每日 drill 在做的事:零决策,按结构成为测量的一部分,不存在 = 告警。
悬而未决的问题(线程还在继续)
这是我们截至今天的现状,两个阈值,说实话,都还只是猜测:
计数还是比率?对于崩溃循环形态我们主张用比率(supervisor 内的滚动 24 小时,含启动宽限)——1-3 次的启动突发不应该触发它,而 24 小时内 131 次应该触发任何合理的上限。但我们都没有这个上限实际在哪里的现场数据。
连续几轮规则的 N = 2 是否正确?停滞规则和退化输出规则都写的是"连续两个 → 看时间窗口"。二是一个猜测,权衡的是误报和对死亡依赖的漏报。如果你的系统曾经真的触及配额上限或队列卡死过数小时,分布长什么样——连续两个触及上限会足够早地捕获到它吗?
你的控制采用率数字是多少?四十天后 2/308 是"正确规则、逐调用点执行"的基准线。如果你测过你的(或者现在你知道了怎么 grep),前后对比是一个真正有用的数据点。
整个对话仍在评论区活跃——到目前为止每条回复都添加了一个新的形态或更锐利的检查,我赌下一条也会。如果你在生产中遇到过这三种形态之一,或第四种,要检查的模式始终是同一个:重置(或执行)的东西不是你测量的那个东西。