返回博客

2026.09.12

十二次掷骰成功,零人通过:怎么揪出那些装好了却从没运行过的机制?

一个跑了三十七个模拟年的系统里,六处机制代码完整、单元测试全绿、从来没有真的生效过。判据只是一个除法——数它在生产数据里发生过几次。

engineeringdebuggingtesting

三十七个模拟年里,角色为了跨过一道等级门槛总共掷了 68 次骰,成功 12 次。真正跨过去的人数是 0。

骰子没坏。那 12 次成功被记录得清清楚楚,写进了日志、进了历史档案。跨境函数也在,单元测试是绿的。它只是从来没有被执行过一次。

这是我在同一个引擎里同一天挖出的六处里最狠的一处。另外五处长得一模一样:一套四档位的伤势系统,三十四年触发率 0.79%;一个缓存层写好两个月,整个五千多行的读取层里只有四个地方调用它;一个模块写了完整的增量索引,注释明写"绝不在请求路径里扫 2.7GB",而索引目录是空的;一个方法名压根不存在,被兜底函数静默吞掉了整整一年的调用;一道阈值高到三十四年的真实数据永远够不着。

六处的共同签名是全部静默、全部无报错、测试全绿

判据在第 2 章,它只是一个除法。我自己第一次用这个判据就用错了,那段在第 3 章。

1. 一行判断的顺序,吃掉了十二次成功

这个世界里角色要跨过大等级门槛,走的是掷骰判定:裁决者提案、骰子决定成败。

查下来是这样:有个角色第十年就站到了门槛前,之后第十八、二十二、二十三、二十七、三十、三十三年,六次掷出成功,一次没跨过去。另一个角色五次成功,同样零次。全场三十多人的等级字段三十七年恒定不变——这个世界从来没有人见过第二个层级长什么样。

根因是一行判断的顺序。进境函数的第一道闸是"如果提议值不高于当前值就原样返回",而这道闸排在骰子结果之前。跨大门槛这条路完全依赖裁决者主动提案,而裁决者的判据全是主观文字描述,并且骰子结果从来没进过它的视野。它根本不知道这个人刚刚掷出了成功。

更讽刺的是同级内部的小台阶早就有引擎强制兜底,跨大门槛这条边被显式排除了,原注释还写着"跨大门槛仍完全沿用裁决者 + 掷骰逻辑"。设计是有意的,实践中等于禁止。

修法是一行:把判断依据从"小台阶那颗骰"换成"按边类型选好的那颗骰"。回放全史,十二次成功 → 十二比十二全部跨境。

一行代码,十二次被吞掉的成功,三十七年。

2. 判据:数它在生产数据里发生过几次

这一章是全文最有用的,而且它只是一个除法。

触发率 = 这个机制的产物条数 ÷ 它号称已经运行的周期数

配一句话点破它的性质:这不是什么经验规律,就是一个除法。分子去查落盘产物的行数,分母是这套机制上线以来跑了多少个周期。

拿这六处代进去对账:

机制 分子(产物) 分母 触发率
跨境判定 0 次真跨 12 次成功 0
遗产结算 3 条账本 37 年 0.08 次/年
死亡裁决 1 次真产出 38 次调用 2.6%
伤势系统 576 个快照 73,372 个状态快照 0.79%
付费疗伤 0 次 7,806 个「人-旬」 0.0%
缓存层 4 个调用点 五千多行读取层

死亡裁决那行特别值得看:三十八次调用,三十七次返回"无人死亡"。它跑得很勤,日志很漂亮,产出接近于零。

产物条数 ≈ 0 而机制号称已上线数十个周期 = 这条路径实际上是未测试代码。 不管它的单元测试有多绿。

第二个除法:阈值落在分布的哪个分位

上面那个除法告诉你"它没触发",这个告诉你"为什么永远不会触发"。

那套伤势系统的设计完全正确:四个档位、各自独立的恢复曲线、作用于四类判定。实测三十七人里只有九人受过伤,峰值 30(满值 100),最高两档三十四年来无人达到

原因在输入分布,跟代码一点关系都没有:

单次掉血  中位 4 / p90 9
单次回血  中位 1 / p90 2
但回血次数是掉血的 3.30 倍   ← 高频小额把伤害磨平了
总回血 / 总掉血 = 1.02

三十四年两万九千点伤害,百分之百走了只碰生命值的那条路径;唯一会写伤势的通道三十四年只触发六十一次,而单次跌幅够得上"重伤"的事件零次。全是一两点的日常磨损。

于是判据长这样:把触发阈值拿去跟真实输入分布的分位对一下。阈值超过实测最大值,触发概率就恒等于零——这时候代码写得再对也没用,它是一扇开在墙上三米高处的门。

修法也是照着分布来的:单次跌幅超过实测 p75 才沉淀为持久伤,日常磨损完全不受影响。

这个世界只是琐碎,从来不危险。

3. 我第一次用这个判据就用错了

这段是全文我最不想写的一段,但它决定你要不要信前面两章。

查那个跨境 bug 的时候,我先断言"三十五次判定成功、零次升级"。错的。

我只查了"大层级"字段,没查"小阶段"字段——这两个是不同的落点,实际上三十五次里有二十六次真的推进过。当场被数据打脸。真正为零的只有跨大门槛这一条边。

所以教训得钉死:断言"某机制没生效"之前,把它的全部落点字段查全。少查一个,就会把"部分生效"误报成"完全失效"。

这个错和我在第 2 章鼓吹的那个除法是同一个形状的病——分子取错了。除法本身没有保护你不犯错的能力。

4. 测试全绿救不了你,因为它量的不是这个

这一段是可以直接拿去用的武器。

看任何一份"这个功能已经上线了 / 已经验证过了"的声明,别看测试覆盖率,问三个数:

  1. 这个机制的产物在生产数据里有几条?
  2. 它被调用了多少次,其中多少次真的产出了东西?
  3. 触发它的阈值,落在真实输入分布的哪个分位?

三样东西各有各的量纲,谁都不回答上面那三个问题:

  • 单元测试量的是"这段代码在我构造的输入下是正确的"。构造的输入。
  • 变异测试量的是"我的断言有鉴别力"。断言。
  • 设计评审量的是"这个设计讲得通"。那套伤势系统四个档位、独立恢复曲线、设计完全正确,实测触发率 0.79%,任何评审都会放它过。

我修完跨境那个 bug,配了二十四条断言 + 十六个变异体零逃逸。这套验证很扎实,但它证明的是修复是对的,它一个字都没有证明这条路径此前跑过。三十七年零次生效,恰恰是在这套验证之前发生的。

顺带一个更阴的形状:那个系统的默认数据集是按"目录字节数最大"选的,而某个可以直接删掉的原始输出转储占了目录的 99.27%。于是排序永远锁死在转储最肥的那个旧数据集上。HTTP 200、界面正常渲染、零报错,展示的是错误的世界。清一次转储,它就会静默切到一个停在很早年份的死档,页面照常渲染。

"没有报错"从来不是证据。 判据得是"这条路径在真实历史里被走过几次"。

5. 它第一次真的触发的那一刻,下游四个消费方全不知道

修完重启,等年末结算。全场成功率最低、身家最薄、一件加成道具都没有的那个角色掷出了成功——三十七年来第一个跨过去的人。她在那道门口站了十三年,踩着线过去的。

然后四条分叉炸了出来,一条比一条离谱:

  1. 裁决者以为自己没放行。 它的提示词还写着"若且仅若条件齐备,你可以放行",而引擎已经改成无条件强制。它判定"条件不齐"、写下"这一年没有突破",引擎在它背后把人推过去了。
  2. 世界不知道。 公共事件预算年年吃满,全史一百一十五条里人物成就类零条。她破境那一旬前后的二十条公共事件是擦古籍、分拣矿渣、抄账目。
  3. 她自己不知道。 渲染给她自己看的档案读的是一个角色创建时写下的影子副本,全引擎没有任何代码更新过它。她看到的自己是"第一层初期",连阶段名都是错的。这个 bug 休眠了三十七年,因为从来没有人跨过去。她是第一个触发的人。
  4. 跨境是净惩罚。 固定周费从 40 涨到 100,年结余从 620 掉到 20,而周俸和职位逐字未变。

第三条是这整篇文章的缩影:一个跑了三十七年很稳的系统,它的稳定有可能只是因为大半个状态空间从没被访问过。

同一晚引擎真的挂了,连撞三关,全是三十七年第一次跑到的代码:

  • 账本链式对账——逐笔拿账户当前余额比这一笔的结果值,两笔遗产链式入同一账户后第一笔就对不上了。数据全对,判据形状错了。
  • 新人单行检查——初始化要求状态文件只有一行,但年末进的新人早已自己存过状态。
  • 配置死结(这条压根不是代码 bug)——年死亡上限 5 人、年招新上限 2 人,死超过两个就永远补不回来,而目标人口每年加一。

第一关里有个额外教训,贵得离谱:这个校验在仓库里有四处拷贝,而且执行路径和审计路径是两套独立的校验。我第一版只改了审计侧,重启时先撞执行侧的预检,根本走不到审计。新人那条判据有两处拷贝。

改任何校验之前,先把它在仓库里的所有兄弟 grep 出来。 不然你会修一次、炸一次、以为自己疯了。

6. 这份数据的毛病

诚实起见列一下,这几处补完之前我不会把结论说死。

  • 第 3 章那个错我已经写了,但值得再说一次:那句"零次生效"最初有一半是我自己造出来的假象,分子取错了字段。
  • 0.79% 这个数的分母是状态快照数,不是"受伤机会数"。 换个分母数字会变。结论方向站得住(最高两档三十四年无人达到是直接观测),但那个具体百分比不要引用。
  • 12/12 的修复验证是在同一份历史数据上回放的,不是重新跑出来的世界。它证明判断顺序修对了,不证明新世界里的跨境频率是合理的。
  • 付费疗伤 0/7,806 只统计了有记录的「人-旬」。 如果某类恢复压根不写记录,它会被误计成"没发生"。这条我没排除掉。
  • 这是一个封闭模拟系统,输入分布由引擎自己生成。 有外部真实输入的系统,阈值够不着的概率形状会完全不同——"p75"这个具体分位别照搬,重新量你自己的分布。
  • 那组"逐年净变化全在 ±100 以内"只覆盖了十一年,不是全史三十七年。我没查清为什么口径只到十一年。

7. 照着做的清单

下次接手一个"已经上线很久"的机制,按这个顺序问:

  1. 它的产物在生产数据里有几条? 零条就当它没写。
  2. 它被调用了几次,其中几次真的产出了东西? 返回"无事发生"的那些不算。三十八次调用、三十七次空转,这机制是个摆设。
  3. 触发阈值落在真实输入分布的哪个分位? 超过实测最大值就是永远触发不了,跟代码质量无关。
  4. 它的落点字段有几个? 全查。少查一个就会把"部分生效"报成"完全失效"。
  5. 这个校验在仓库里有几份拷贝? 执行路径和审计路径通常各有一套,只改一处重启照炸。
  6. 它第一次真的触发时,下游有几个消费方? 那几个全是零覆盖代码,一个都别信。

那个第一个跨过门槛的角色,拿到的第一份奖励是一张周费从 40 涨到 100 的账单,年结余从 620 掉到 20,存款直接低于保护线。这个世界给"变强"的回礼是破产。