15  规则是怎么长出来的

前面几章讲的都是规则长成之后的样子:一条禁止模式的四个字段、 一份路径清单的六个字段、报数和拦截两个档位。这一章讲它是怎么长出来的。

这一章的材料有一个别处没有的性质:它可以从版本历史里直接读出来。 规则的诞生时间、诞生的原因、以及它从报数到拦人的那一刻, 全都是提交记录里的事实,不是事后的叙述 —— 这一点很重要,因为关于”我们的规则是怎么来的”这类问题, 事后的叙述通常比事实整齐得多。

15.1 规则的完整时间线

这是从提交历史里还原的一条真实时间线。日期换成了相对天数 —— 有意义的是它们之间的间隔,不是它落在哪个月:

时间 发生了什么 性质
第 1 天 一次提交修了构建缓存的隔离问题,顺手把不变量写进常驻文件(+2 行) 人读的规则
第 2 天 新平台上第一条真实流水线,三个任务在分析阶段同时崩 事故
第 6 天 提交:“加入路径不变量闸门” —— 整个机制诞生 机制诞生
第 17 天 提交:“扩到 8 条:……构建产物归属……” —— 这条规则落地 机器执行的规则

15.1.1 第 2 天发生了什么

根因不在代码里。新的执行环境把缓存目录挂成了节点级共享, 于是同一个节点上并发的进程各自启动构建服务、全部指向同一个输出根、互踩锁。 崩溃时的日志有明确的识别特征:多个并发任务的日志里出现相同的输出根哈希, 同时伴随”服务异常终止”。

这是形状 B —— 同一份状态有两个写者,只不过这次的”状态”是构建输出目录, 而”写者”是几个互相不知道对方存在的进程。值得注意的是这次的两个写者 不在同一份代码里:它们是同一个程序的两个实例, 被一个外部的挂载配置放到了同一个位置上。

15.1.2 这条时间线里最值得说的一点

不变量在事故的前一天就已经写进常驻文件了。第二天照样发生了。

原因不是没人看那份文档,而是事故出在刚迁过去的新平台的挂载配置上 —— 写在文档里的规则不会跟着你迁移到新平台,它只对读过它的人和会话有效。 那次迁移是一次基础设施变更,做变更的人(或 Agent)读的是基础设施的配置, 不是仓库的常驻文件,而那条规则和那次变更之间没有任何机械的连接。

这就是全书那句话的代价版本:规则没写进结构,它就只是一段文字。 写进常驻文件的那两行是声明,写进禁止模式的才是结构, 中间隔着的是一次线上事故。具体隔了多久有个数字: 从写下声明到落成结构,十六天,其中前五天是”以为已经解决了”, 后十一天是在建机制。

15.2 规则不是一次性设计出来的

有一个数字很能说明这套系统的性格:架构规则那个文件, 从建立到现在总共只有 20 次提交。二十次,二十三条规则 —— 也就是说规则极少被批量添加,基本是一次一条、 跟着一次具体的改动一起进来的。

每一次提交的标题都写着这条规则是被什么逼出来的:

提交标题(节选) 它揭示的
“本地化表按 owner 就近拆分 X/Y,并新增表归属守卫” 规则和它的第一次应用同时进来
“Riff 两端目录按依赖方向重排,并补上多端产品逃过的界面粒度闸” 一条已有规则漏了一类情况,被一个具体产品钻过去了
“产品声明解耦 + 例外归位,发布走账本转为拦截 一条规则从报数转成拦人
“界面测试粒度收敛到包装器:9 个目标合成 2 个 规则带来的具体收益

第二条尤其值得说。它不是”我们想到还该加一条规则”, 而是”某个产品的形态钻过了现有规则的空子,所以补一条”—— 规则的边界是被真实的越界行为定义的,而不是被想象中的越界行为定义的。 这两者的差别在误报率上,而误报率决定了规则的存活。

第三条是下一节那个流程在版本历史里的痕迹:规则先以报数模式跑了一段, 等存量清理完(提交标题里的”例外归位”),才在同一次提交里转成拦人。

15.2.1 这个数字自己就是一次形状 A

这一节写完之后发生了一件值得记下来的事:上面那个数字第一次写错了。

第一次统计用的是最直接的命令 —— git log -- guardrails/architecture/rules.toml 数一下有多少行,得到 9。正确的数字是 20, 差别在于那个文件曾经从仓库根目录的 guardrails.toml 搬到过现在的位置,而不带 --followgit log 在重命名处就停住了

$ git log --oneline -- guardrails/architecture/rules.toml | wc -l
9
$ git log --oneline --follow -- guardrails/architecture/rules.toml | wc -l
20
$ git log --follow --name-status --oneline -- ... | grep '^R'
R088  guardrails.toml  →  guardrails/architecture/rules.toml

这正是形状 A:探针测的不是你以为的东西。 命令没报错,输出是一个合理的数字, 而”9 次提交、23 条规则”这个组合甚至更有说服力 —— 它让”规则很少被批量添加”这个结论显得更强。 一个错误的测量给出了一个符合预期的答案, 于是它不会被复查。

它被抓到的原因也值得说:不是因为有人怀疑那个数字, 是因为写这一节时要列出那些提交的标题, 列到第十条时发现还有内容 —— 是”用它做别的事”暴露了它, 不是”检查它”暴露了它。

这条经验可以直接抄走:一个只被用来产生结论、 从来没有被用来产生别的东西的数字,是最容易错的那种。 因为它没有第二个消费者去和它对账 —— 而 小节 18.2.3 讲的解析冗余, 本质上就是人为地造一个第二消费者。

15.3 规则上线是四步,不是三步

完整的四步是:先写进文档;变成可执行的检查;以报数模式跑一段时间, 把存量清单摆在每一次输出里;等它收敛了才切成拦截。

大部分人会跳过第三步 —— 既然规则是对的,为什么不直接执行? 跳过第三步的结果是所有人当场停摆,然后这条规则被关掉, 那还不如从来没有过。

“那还不如从来没有过”不是修辞。一条被关掉的规则比一条不存在的规则更糟, 因为它的文件还在,于是下一个人会以为这条约定还在生效; 因为关掉它的那次操作建立了一个先例 —— 规则挡路的时候可以关掉它; 而这个先例会被应用到下一条规则上。第三条是最贵的, 它的成本不落在这条规则身上,落在你之后所有的规则身上。

15.3.1 这一步在控制里有个名字

报数模式在控制工程里叫死区:误差被测量、被报告, 但在误差降到某个范围内之前不施加控制作用。

死区存在的理由和这里完全一样 —— 一个在初始误差很大时就全力动作的控制器, 会饱和、会剧烈震荡,然后被操作员关掉。而”被操作员关掉” 是控制工程里一个真实且常见的失败模式:一个总是在叫的告警最终会被静音, 一个总是把阀门开到底的调节器最终会被切到手动。

我是独立撞出来的,而撞出来的形态和教科书上的一模一样。 这类重合出现了很多次,而它们的意义不在于”我们做对了”, 在于这些形状是被问题本身逼出来的,所以你也会撞上同一批。

15.4 报数模式还是一件测量仪器

这一节讲一件源系统还没用起来的事,也是书里对它的第三条建议。

报数模式通常被当成上线的过渡档,但它同时是一个正在运行的对照组 —— 它报数但不拦,所以你能看到”如果不拦,会发生什么”的完整样本。

现在有一条规则挂着 966 处报数模式的违规,已经跑了一段时间。 于是有一个可以直接问、而且数据全在手边的问题: 这段时间里,这 966 处违规实际引发了几次故障?

如果答案是零,那么这条规则可能根本不该切成拦截 —— 它守的东西也许并不重要,或者重要性远低于它将要制造的摩擦。 如果答案不是零,那么这几次故障就是这条规则最有力的辩护词, 而且它们还能告诉你应该先清理哪一部分存量。 报数模式产生的数据,比报数模式本身有价值,而这个数据现在没有被读。

15.5 第一条规则比 Agent 规模化晚了两个月

这是全书对”该从哪开始”这个问题的答案,而且它是反直觉的: 这不是拖延,是因为在那之前根本不知道该拦什么。

正确的顺序是先遇到一个行为问题,写一个测试; 同一种结构问题出现几次,才沉淀成检查; 某条路径总是需要额外的上下文和风险提醒,才为它写一份清单。 别从治理系统开始。

这里有一个可以自查的推论:如果你现在就能列出二十条该拦的规则, 说明这些规则大概率是从别人那里抄来的,不是从你的失败里长出来的。 抄来的规则有一个共同问题 —— 它们的边界没有被你的代码校准过, 而 小节 14.4 已经说明了,边界没校准的规则误报率会很高, 误报的规则会被绕过。

15.6 规则会被绕过,只要它误报

Agent 会不会绕过这些规则?会 —— 只要规则误报。 所以那些禁止模式是拿真实数据调出来的(小节 14.4): 不禁掉表退役,因为禁了会在正确的工作上误报,然后被绕过去。 另外每条规则都有哨兵下限,匹配数掉下去说明规则自己失效了(小节 13.4.3)。

15.6.1 但有一个数字没有被测量

那个数字是绕过率:规则跑了多少次,其中多少次的失败 最终被判定为”规则错了”—— 也就是说,那次改动的结果是改规则,而不是改代码。

这个比例是规则集腐化速度的先行指标。它接近零,说明规则边界校准得好; 它开始上升,说明规则和代码的实际形态在脱节, 而这通常发生在一次大重构之后。

这个数据是可以从版本历史里算出来的:每一次改规则的提交, 去看它前面那次失败是什么。这不需要新建任何采集, 需要的只是把这个问题问出来 —— 而它现在没有被问。

15.7 规则怎么退休

这一节在原始材料里完全没有,但它是这套系统长期风险最大的一块。 二十三条架构规则、二十份路径清单、六条检查通道、各语言的债务台账 —— 前面讲了规则怎么长出来,几乎没讲规则怎么退休。

三个没有答案的问题。第一个是规则本身有没有 owner: 一条规则加进去之后,谁负责在它误报时修它、在它过时时删它? 如果答案是”谁碰到谁修”,那么它的实际 owner 是零。

第二个是重构时有多少规则的路径范围要跟着改。 这个问题有一个现成的实例:历史里有一次提交是 “把某产品未被挣得的 App 层摘掉,按获取方式拆进组装根与库”—— 一次纯粹的目录重排。那次重排之后, 所有以路径通配符定义范围的规则,它们的匹配范围都变了: 有的变宽了(扫到了不该扫的),有的变窄了(漏掉了该扫的), 而后者是静默的。没有任何机制会告诉你”这次重构让某条规则的 覆盖面掉了一半”—— 除了哨兵下限,而哨兵只在掉到绝对下界以下时才响。

第三个是一条挂着 966 处违规的报数规则,收敛的动力从哪来。 文档里写着”等存量收敛了,它就可以切成拦截”,但谁负责收敛?什么时候? 一条永远处于报数模式的规则会变成背景噪音 —— 每次 CI 输出里都有它, 每次都没人看。那时候它已经从”一个正在过渡的规则” 变成了”一条被遗忘的规则”,而这两者在输出里长得一模一样。

15.7.1 这套系统的长期风险

三个问题合起来指向同一件事:这套系统的长期风险不是不够严, 是规则集本身腐化成一堆没人敢动的历史遗留。

这个风险有一个明确的先行指标,就是上一节那个绕过率。 它现在没有被测量,所以这个风险目前是不可观测的 —— 而 小节 19.1 会说明,一个不可观测的状态是不可控的。 这不是一个理论上的顾虑:一个团队通常不是在某一天决定放弃规则集的, 而是在连续几个月里每次都选择绕过,直到有一天发现已经没人知道 这些规则里哪些还是活的。

15.8 从反馈到前馈:规则的终点

最后一节回到 小节 5.5 埋下的那条线。 一条规则的完整生命周期不止于”从报数变成拦截”,它还有一段:

flowchart LR
  I["一次事故"] --> D["写进文档<br/>(人读的声明)"]
  D --> R["可执行的检查<br/>(报数)"]
  R --> E["可执行的检查<br/>(拦截)"]
  E --> S["结构性的不可能<br/>(前馈)"]
  S --> X["从规则集里消失"]
  R -.->|"误报<br/>被绕过"| K["规则死亡"]
  E -.->|"范围漂移<br/>静默失效"| K

图里那条主线是三个阶段:评审时被人发现是反馈,而且最贵; 变成一条自动检查还是反馈,但便宜了;变成一个结构性的不可能才是前馈, 到这一步问题消失了。第三步才是终点,而这套系统里有四条走完了:

规则 它最终变成了什么
界面测试的 bundle 命名 名字由包装器推出,两个冲突的目标在加载期就报重复,拆不出来
配置的合法性 已校验的类型在模块外无法构造
哨兵不能被关掉 类型是非零整数,且必填
生成物的一致性 对拍测试,签入值与重算值必须相等

这四条已经不是”规则”了,它们是结构 —— 没有人需要遵守它们, 因为违反它们做不到。这正是 章节 5 那句话的具体含义: 检查的最高成就,是把自己变成不再需要检查的东西。

值得注意的是这四条的终点形态各不相同:构建图的重复目标、 类型的可见性、非零整数、对拍测试。四种不同的机制,同一个效果。 这说明”把规则变成结构”没有统一的做法, 它取决于你的技术栈提供了什么表达能力 —— 一个类型系统很弱的语言里, 第三步能走通的规则会少得多,而那不是纪律问题,是能力边界。

15.9 规则的三种死法

一条规则不会一直有效。它有三种死法,而三种的表现完全不同 —— 上面那张图里的两条虚线,画的就是其中两种。

15.9.1 死法一:被关掉

最明显的一种:有人在某次紧急情况下把它关了, 然后没有开回来。它的好处是可见的,配置里有一个明确的痕迹, 只要有人定期看一眼那份配置就能发现。

15.9.2 死法二:被绕过

规则还开着,但大家学会了怎么写才不会触发它, 而那种写法并不比原来的更好,只是不触发。这种死法在配置里没有任何痕迹 —— 规则每次都是绿的,它的统计数据看起来很健康。 发现它的唯一办法是 小节 15.6.1 那个指标。

15.9.3 死法三:范围漂移

最阴的一种:规则还开着,没有人绕过它, 但它扫的东西变了。一次目录重构、一次模块拆分、一次路径通配符的调整, 规则的匹配范围悄悄缩小了,于是它守着一个越来越小的角落, 而输出仍然是绿的。哨兵下限是专门防这一种的, 但它只在扫描数掉到绝对下界以下时才响 —— 一条本来扫 9,000 处的规则掉到 3,000 处,哨兵(下限 50)不会响。 这就是 小节 18.5 建议改成相对变化率的最强理由。

15.9.4 三种死法的检测成本

三种死法的检测成本差别很大:被关掉靠定期看配置,成本低; 被绕过要统计”改规则 vs 改代码”的比例,成本中等,需要一次性建立统计; 而范围漂移只要把扫描数存成时间序列加一个变化率告警, 成本几乎为零,因为数据已经在输出里了。

每一次运行的输出里都有每条规则的扫描数,那就是哨兵的读数。 把它们按时间存下来画成曲线,范围漂移会一眼看出来 —— 一条正常的规则,扫描数应该随仓库增长而缓慢单调上升, 任何一次断崖式下跌都值得怀疑。这是我对这套系统的第四条建议, 而它和前三条一样:输出里已经有这个数了,缺的只是把它存下来。

15.10 规则的数量应该收敛,不是单调增长

一个观察,它和大部分人的直觉相反:一套健康的规则集, 规则数量应该收敛到一个稳态,而不是持续增长。

理由是上一节那条路径 —— 每一条走完全程的规则, 最终会变成结构,然后从规则集里消失。界面测试的命名规则变成了 “两个冲突的目标在加载期就报重复”,配置合法性规则变成了 “已校验的类型在模块外无法构造”,这两条已经不需要作为规则存在了。

所以规则集的动力学应该是新规则从事故里进来, 旧规则通过”变成结构”离开。如果只有进没有出, 那说明”把规则变成结构”这条路径没有在走 —— 而那才是真正该担心的事,不是规则太多。

这给出一个可以直接用的诊断:过去一年里, 有几条规则因为”问题已经在结构上消失了”而被删除? 零意味着转化路径没有在走,规则集在单调增长;一到两条是健康的; 很多条则要么说明规则本来就加得太随意,要么说明你正在做一次大重构。 “零”是最常见的答案 —— 因为删除一条规则需要有人主动去做, 而没有任何机制会提醒他(小节 15.7)。

15.11 规则的来源分布

一条规则可以从三个地方来,而三个来源的规则,质量差别很大

来源 特征 误报率
一次真实的事故 边界被那次事故校准过
一次评审中的重复讨论 边界模糊,但至少是真需求
从别处抄来 / 凭空想出 边界完全没有校准

小节 15.2 那二十次提交里,绝大多数属于第一类 —— 每一次提交的标题都写着规则被什么逼出来。这个分布本身是一个 可以自查的指标:数一下你的规则,有多少条你能说出它是因为 哪次具体的失败而存在的?说不出的那些就是第三类, 而第三类是绕过率最高的(小节 15.6),也是最该被优先审视的。

需要说清楚的是,第三类不是”坏规则”的同义词 —— 一条抄来的规则完全可能是对的。问题在于它的边界没有被你的语料校准过, 所以它在你的仓库里的误报率是一个未知数。未知的误报率 意味着你不知道它会不会被绕过,也就意味着你不知道它是不是还活着。

15.12 规则的”半衰期”

规则会过时,而过时的方式有两种,它们的处理完全不同。

方式一是问题消失了小节 15.12.1)—— 小节 15.8 讲的那条路径,问题在结构上被消除了。 处理办法是删掉规则,而且应该有一次明确的删除提交, 提交信息里写清楚”这个问题现在由 X 结构性地保证了”。 这是最好的一种过时,也是唯一一种值得庆祝的过时。

方式二是问题还在,但规则不再匹配它小节 15.12.2)—— 代码演化了,规则的模式匹配不到新的形态,但那个不变量仍然需要被守。 处理办法是改规则,不是删规则。这一种的危险在于 它看起来像第一种:规则不再报违规了,看起来”问题解决了”。

区分两者的唯一方法是看扫描数(小节 15.9.3)。 问题真的消失了,扫描数不变而违规数归零;规则不再匹配了, 扫描数会下降。这两个数必须一起看 —— 而这就是为什么 输出格式里每条规则后面都跟着扫描数(小节 13.16)。 一个只报违规数的检查器,没法区分这两种情况, 而它们需要完全相反的处理。

15.12.1 方式一:问题消失了

值得再说一句为什么这一种值得庆祝:它意味着有人做了那件 没有任何机制会提醒他去做的事。删掉一条规则不产生任何即时收益 —— 没有 bug 被修复,没有性能变好,没有人会注意到。 它唯一的收益是规则集少了一条需要被理解和维护的东西, 而这个收益要等到半年后有人读这份规则表时才兑现。

15.12.2 方式二:问题还在,但规则不再匹配它

这一种更常见,而且它通常发生在一次重构之后。 重构的人改的是代码结构,他没有理由去想”这次改动让哪几条规则失灵了”—— 而规则的绿色输出会主动配合这个误解。 扫描数的时间序列(小节 15.9.4)之所以 是这一章里性价比最高的一条建议:它把一个需要主动想起的检查, 变成了一条会自己响的曲线。

15.13 规则集的健康检查清单

给一份可以每季度跑一遍的清单:

检查 健康的样子
规则总数的变化 有进有出,不是单调增长
每条规则能否说出它的诞生原因 全部能
报数模式的规则数 少数,且每条有明确的收敛计划
零违规的报数规则 应该被切成拦截,或者被删除
每条规则的扫描数趋势 随仓库缓慢上升,无断崖
台账的总量趋势 单调下降
绕过率 接近零

第四行是最容易被遗漏的。一条零违规的报数规则处于一个无意义的中间状态: 它不拦人所以不产生保护,也不报数因为没有违规, 它只是每次运行多跑了一次扫描。这套系统里现在就有两条这样的规则。

七项里有五项的数据在每次运行的输出里就有, 但没有任何一项被自动化小节 13.12.1)。 这份清单现在的实际状态是”一份需要有人记得去跑的清单”—— 而我从头到尾在说的正是这种东西不可靠。 把它写在这里,是因为承认一个缺口比假装它不存在有用; 真正的解法是让这七项里的五项变成一条会失败的检查。

15.14 事故到规则之间该隔多久

那条时间线(小节 15.1)里,从事故到规则落地隔了十五天。 这个延迟是合理的,甚至是必要的,理由有三条。

第一,事故当天你还不知道这类问题的完整形态,你只知道这一次它长什么样, 而规则要覆盖的是这一类不是这一个 —— 而”这一类”需要时间才能看清。 第二,事故当天做的决定倾向于过度反应:刚被烧过的人会想加一条很严的规则, 而过严的规则误报率高,误报的规则会被绕过。 第三,规则需要拿真实数据调边界(小节 14.4),而收集数据需要时间。

但延迟也有上限。 超过某个时间,那次事故的细节就模糊了, 而细节正是校准规则边界所需要的。一个可用的节奏是这样的: 事故当天修好,并写下故障记录,因为细节在这时候最完整; 一周内把不变量写进常驻文件或说明,这是人读的版本; 两到四周里观察同类问题有没有再出现,同时收集边界数据; 一个月内,如果确实是一类问题,写成可执行的规则,以报数模式上线。

第一行是关键。那份故障记录不是给别人看的, 是给一个月后要写规则的你自己看的 —— 而它的价值取决于它记了多少”当时相信的是什么”, 因为一个月后你会记得结论,但不会记得当时为什么会相信另一个结论。

15.15 为什么”顺手加一条规则”通常是错的

在一次改动里”顺手”加一条规则是一个很自然的冲动,但它有三个问题。

它没有经过报数期,一上来就是拦截的,而你不知道存量有多少 —— 小节 15.3 的第三步被跳过了。它混在一次业务改动里, 评审时不会被单独审视,而规则是一个应该被单独审视的东西, 因为它会影响所有人。它的诞生原因不会被记录, 提交信息讲的是那次业务改动、规则只是”顺手”, 而一个月后没有人能说出它为什么存在小节 15.11)。

值得对照的是这套系统的实际做法:那二十次提交里, 规则总是和它的第一次应用一起进来的, 而提交标题里明确写着这条规则是什么、为什么。 “和它的第一次应用一起”和”顺手加一条”看起来相似,实质完全不同 —— 前者是”我刚做完这件事,发现它该被规则化”,后者是”我路过,加一条”。 区别在于前者手里有一个具体的实例,而规则的边界正是被实例定义的。

15.16 作用域会自己漂

小节 15.9.3 讲了范围漂移这种死法,这一节讲它是怎么发生的, 因为知道机制才能防。

漂移有三个入口,而三个都不是”有人改了规则”。

第一个入口是通配符的语义。 一条 scope = ["Modules/**/*.swift"] 的规则,它的覆盖面完全由 Modules/ 下面有什么决定。 新加一个产品,它自动被覆盖 —— 这是好事; 而把一批代码从 Modules/ 挪到别处,它自动不被覆盖了 —— 而这一次没有任何东西会响。 同一个机制, 在一个方向上是优点,在另一个方向上是漏洞。

第二个入口是豁免清单的累积。 每一条豁免加进去的时候 都有具体的理由,而理由是局部的(“这个文件是代码生成器”)。 但十条豁免累积起来会构成一个非局部的效果: 规则实际守着的那片区域,和它名字宣称的那片区域, 已经不是一回事了。而没有任何一次单独的添加是错的。

第三个入口是语言或框架的演进。 一条匹配某种写法的规则, 在语言引入了一种新的等价写法之后就自动漏了一半 —— 而这个漏是从新写法被第一次使用那天开始的, 没有任何提交能被指认为”引入漏洞的那一次”。

三个入口的共同点是它们都不产生一次可以被评审的改动。 规则文件没变、检查还在跑、输出还是绿的。 所以防它的唯一办法不是评审,是看那个数—— 每条规则每次运行的扫描面,存成时间序列 (小节 15.9.4)。三个入口里的前两个会让那条曲线掉, 第三个会让它平掉而违规数掉,两种形态都能被一眼认出来。

15.17 规则和文档的分工

一条约定,什么时候该是规则,什么时候该是文档? 小节 8.3.1 从”载体”的角度回答过, 这里从”生命周期”的角度再答一次 —— 因为两个角度给出的答案在一处不一致,而那一处很有意思。

载体角度的答案是:能写进结构就写进结构,不能的写进常驻文件。 生命周期角度的答案是:一条约定在它还可能改变的时候, 不该被固化成规则。而不一致的地方是, 有些约定重要到必须无条件生效,但它还没有稳定。

这时候正确的做法是报数模式(小节 15.3), 因为它同时满足两边:规则已经存在,所以它可以被讨论、被数据支撑; 而它还不拦人,所以改它的成本还很低。 报数模式的本质是一个”还没决定”的状态被显式表达了出来 —— 而这比另外两个选项都好,因为写成文档是不可执行、会被忽略的, 直接拦人是把一个还没稳定的判断固化成了机制。

15.18 规则的最坏形态

最后说一个应该被主动避免的形态,因为它同时具备了这一章讲的所有问题: 一条抄来的、直接拦人的、没有诞生原因的、没有哨兵的、没有 owner 的规则。

它的失效轨迹是可预测的。上线,大量存量违规,所有人停摆; 有人加一批豁免,或者干脆关掉;如果是加豁免, 豁免列表持续增长而没人知道为什么;如果是关掉, 文件还在但它不再生效;半年后,没有人能说出它该不该存在。 每一步都是局部合理的,这正是它危险的地方 —— 没有任何一步值得在评审里被拦下来, 但五步走完之后你得到的是一条既不保护也删不掉的规则。

15.18.1 对照:一条规则的最好形态

属性 最好的样子
来源 一次真实的失败
上线 先报数,存量清零后再拦
边界 拿真实数据调过,零基线
自检 有哨兵,且哨兵不能被关掉
失败信息 带诞生原因和修法
owner 有名字
终点 有一条通往”变成结构”的路

七项里最容易被漏掉的是最后一项,而漏掉它的后果是 小节 15.10 那个问题:规则集单调增长,而增长是不可持续的。 值得注意的是前六项都是关于”这条规则怎么开始”, 只有最后一项是关于”这条规则怎么结束”—— 而一个只考虑开始不考虑结束的机制,它的成本会一直累积。

15.19 规则集也有一个”第一条”的问题

小节 15.5 说第一条规则来得晚是对的, 但没说第一条规则本身有一个特殊的地位: 它定下了这个规则集的形状。

具体是三处。第一条规则的严格程度会成为默认值 —— 如果它一上来就拦人,后面每一条都会被期待照做, 而报数模式这个档位从此需要一个解释才能被使用。 第一条规则的粒度会成为默认值 —— 如果它是一条覆盖全仓库的通用规则, 那么”某个模块专有的约定”就没有一个自然的位置。 第一条规则的记录方式会成为默认值 —— 如果它没有写下诞生原因,后面的也不会写, 因为没有一个格式在提醒。

所以第一条规则值得多花半天,不是花在实现上, 是花在把这三样定对上:先报数、粒度对准一个具体的失败、 带上一行诞生原因。这半天定的是一个模板, 而后面二十条会照着它长。

这也解释了 小节 15.18 那个最坏形态为什么那么常见 —— 它通常不是第二十条规则,它是第一条。 一个团队决定”我们该有一些规则了”, 从别处抄一条最有名的,直接开启拦截,不写理由 —— 三个默认值一次全定错,而后面的十九条会忠实地继承它们。

15.20 压成一句话

规则不是被设计出来的,是被事故长出来的; 而一条规则的终点不是”永远执行”,是”变成不再需要执行的东西”。

前半句决定了你该从哪开始(小节 15.5), 后半句决定了你该往哪走(小节 15.8)。 大部分团队两头都错:从一份抄来的规则清单开始, 然后让它单调增长,直到没人敢动。 正确的形态是一个有进有出的流 —— 事故进来,结构出去, 而中间那段才是规则。