16 小规模怎么做(检查层)
和 章节 10 配对,同样的约束: 不许出现任何需要重基建的建议。 这一章里的每一条, 五个人的团队都能在现有的 CI 上直接加,不需要新工具、不需要新语言、 不需要一个专门维护它的人。
16.1 按投入产出比排序的三道检查
三道检查,投入分别是半小时、半天、一天。它们的共同点是 都不在增加覆盖面,都在建立可信度 —— 这个区别是这一章的主线, 小节 16.14.4 会说明为什么顺序不能反。
16.1.1 一、断言测试执行数不为零
投入半小时,而这是全书投入产出比最高的一条改动。 它挡住的是 小节 12.3 里那三个形态中最常见的一种, 而它的实现就是在 CI 脚本里加一行 grep:
# 跑完之后,确认真的跑了
output=$(<你的测试命令> 2>&1)
echo "$output" | grep -qE "Executed [1-9][0-9]* test" || {
echo "guard: 测试执行数为 0"; exit 1
}不同的测试框架输出格式不同,但每个框架都会打印执行数。 找到那一行,断言它不为零 —— 半小时里有二十五分钟花在找那一行上。
为什么这条排第一:因为它是上游污染的防线。 一旦”跑了零个用例”没被发现,后面所有依赖”跑一次看结果”的动作 —— 变异验证、flake 复现、二分定位 —— 得出的结论全是空话。 这不是说它们会给出错的结论,是说它们会给出看起来对的结论, 而那更糟:一个明显错误的结论会被复查,一个看起来对的不会。
16.1.2 二、退出码三分
投入半天,详见 小节 10.2.3。这一条同时属于环境层和检查层的 入门清单,因为它便宜到不做没道理。
一个容易被漏掉的补充实践:在给 Agent 的提示里, 把”看到退出码 2 不要改代码”写成一条常驻规则。 否则你做了这个区分,而消费它的一方不知道 —— 这个区分的全部价值在于它改变了 Agent 收到失败之后的动作, 而如果 Agent 不知道有这个区分,那你只是给自己看了一个更精确的数字。
16.1.3 三、一条报数模式的结构规则
投入一天。选你团队在评审里吵得最多的那条约定,写成检查,先只报数不拦。 然后跑两周,看它每次报多少。
这个数字会告诉你两件事。第一,它到底是共识还是一厢情愿—— 如果存量违规有几百处,那这条”约定”从来没有被真正执行过, 而你以为它是共识。第二,该先清理哪部分存量, 前提是报数输出带上了具体位置(小节 16.12.3)。
两周之后再决定要不要切成拦截,而且要按 小节 15.3 那四步走: 先把存量清到接近零,再切。这两周不是缓冲期,是数据采集期 —— 你在这两周里得到的那个数字,是你决定这条规则命运的唯一依据。
16.2 变异验证的最小形态
这一条不是”一道检查”,是一个习惯,但它比上面三条加起来还重要: 每次修 bug 时,先让新测试红一次,再让它绿。
成本是零 —— 你本来就要跑一次测试,区别只是跑的顺序: 先写测试、跑一次(应该红)、再改代码、再跑(应该绿)。 如果第一次它没红,你刚刚发现了一条假绿,而发现的成本是零。
这条习惯的价值在 Agent 场景下被放大了,因为 小节 12.2.1 讲的那个结构性理由:让一个断言通过,永远比让一个行为正确要容易。 一个被要求”加测试”的 Agent 面对的正是这个优化问题 —— 它不是在作弊,它是在解一个你给错了的题。
16.3 不要做的三件事
不要一上来就拦人。 存量违规会让所有人当场停摆,然后规则被关掉 —— 而被关掉的规则比不存在的规则更糟,因为它建立了 “规则挡路时可以关掉它”这个先例,而这个先例会被应用到你以后的每一条规则上。
不要抄别人的规则表。 抄来的规则边界没有被你的代码校准过, 误报率会很高,而误报的规则会被绕过、绕过之后还继续消耗信任。 我那套规则里的二十三条,真正可移植的只有三到四条 (小节 A.8),剩下的对你来说是噪音。
不要为”以后可能有用”写规则。 和目录层级一样(小节 6.2): 规则也必须被一次真实的失败挣得。一条没有实例的规则, 它的边界是想象出来的,而想象出来的边界在真实代码上的误报率不可预测。
16.4 什么时候该升级
三个信号,出现任意一个就该往上走一档。
同一类问题在两个月内出现三次,这是 小节 6.6 那条演进纪律的检查层版本 —— 三次是一个经验值, 它的作用是把”这好像是个模式”变成一个可以行动的判据。
有人开始用”本地是好的”当理由,这说明本地和 CI 跑的不是同一件事 (小节 12.4),而这个裂缝会越来越宽。 一旦这个理由被接受一次,整个检查层的权威就开始漏气 —— 它的危险不在于这一次的判断是错的,在于它教会了所有人 “CI 的结论是可以被质疑的”。
你发现自己在评审里反复说同一句话,这句话就是你的下一条规则, 而且它已经被”挣得”了 —— 你为它付过的代价,就是你重复说它的那些次。
16.5 从一道检查到一套系统的三个台阶
上面三道检查建好之后,接下来会自然遇到三个问题, 而它们出现的顺序几乎是固定的。
16.5.1 台阶一:规则开始互相冲突
出现时机是规则超过五条左右。症状是一条规则要求 A,另一条隐含地要求非 A, 或者更常见的 —— 一条规则在另一条规则要求的写法上误报。
修法是找出规则背后的原则。两条冲突的规则, 通常是同一条原则的两个不完整的投影,而找出那条原则、 用它替换两条规则,会同时消掉冲突和一部分误报。 这个动作在规则少的时候做很便宜,在规则多的时候几乎做不动 —— 所以五条左右这个时机值得记住。
16.5.2 台阶二:本地和 CI 开始不一致
出现时机是检查开始有配置、有依赖、有版本。症状是有人说”本地是好的”。
修法是 小节 13.2 那一条:让本地和 CI 跑同一条命令、 同一份配置。 这通常意味着把检查从 CI 配置里挪进一个可以本地执行的脚本, 而 CI 只是调用它。这一步的收益被严重低估 —— 它同时消除了一类争论和一类误报,而且它是一次性的。
16.5.3 台阶三:失败开始分不清是谁的错
出现时机是检查依赖了外部资源,比如下载、容器、真实设备。 症状是红灯里开始混进”不是代码的错”的那些。
修法是退出码三分 —— 如果你还没做的话。如果你已经做了, 这一步的问题会升级成:怎么判断一个新工具的失败该归哪一类? 这个判断没法自动做,只能一条一条接, 小节 13.8.1 讲过这个成本以及为什么它必须付。
16.6 三道检查各自的最小实现
给到可以直接抄的程度。
16.6.1 断言测试执行数不为零
关键是找到你的测试框架打印执行数的那一行。xUnit 系通常是 Executed N tests 或 N tests, N assertions,各语言的内置测试 通常是 ok N 或 N passed,端到端框架通常是 N passing。
拿到之后断言它不为零。注意要断言”不为零”,不是断言”等于某个数”—— 后者会在每次加测试时失败,然后被人调大,然后失去意义 (小节 16.12.1 会说明这个失效路径)。
16.6.2 退出码三分
不需要重写 CI,在你现有的检查脚本外面包一层就够:
run_check() {
output=$("$@" 2>&1); code=$?
[ $code -eq 0 ] && return 0
# 基建故障的特征:识别它们,返回 2
case "$output" in
*"command not found"*|*"connection refused"*|\
*"no space left"*|*"timeout"*|*"unable to fetch"*)
echo "$output"; echo "guard: 基建故障"; return 2 ;;
esac
echo "$output"; return 1 # 其余归为内容违规
}这个特征列表一开始不会全,而这没关系 —— 每次遇到一个新的基建故障, 往里加一条。它是从失败里长出来的,和规则一样。 重要的是默认值的方向:不认识的失败归为内容违规(返回 1), 而不是归为基建故障 —— 因为把真的违规误判成基建故障会放行, 反过来只会多一次无用的修复尝试。
16.6.3 第一条报数模式的规则
hits=$(grep -rn "<你的模式>" <你的范围> | wc -l)
files=$(grep -rl "" <你的范围> | wc -l)
echo "[YOUR-RULE] (report-only) — $hits 处 (扫描 $files 个文件)"
exit 0 # 报数不拦注意那个”扫描了多少个文件”—— 那就是哨兵的读数(小节 13.4.3)。 从第一天就打印它,因为等你想加的时候,你已经没有历史数据可以对照了。 这是这一章里唯一一条”现在不做以后补不上”的建议。
16.7 这三道检查合起来防住了什么
对照七个形状盘点一次,结论是七个里只覆盖了两个的一部分: 形状 A(探针测错)被第一道防住了最常见的一种, 形状 G(本地≠远端)被第二道让归因变清楚了,其余五个都没有覆盖。
这个盘点很重要,因为它防止一种误解 —— 建完这三道不等于”判定建好了”。 但这三道是有顺序意义的:它们建立的是判定本身的可信度, 而其余五个形状的检查,全都建立在”我的检查是可信的”这个前提上。
先有可信的判定,再有更多的判定。 顺序反了的话, 你会在一个不可信的基础上叠加,而叠加的每一层都继承了底层的不可信 —— 更糟的是,你不会知道自己继承了它。
16.8 一个人的检查层
前面讲的是五人团队,一个人的情况不一样,值得单独说。 一个人的系统里,最反直觉的一点是:判定层的价值不是”防别人”, 是”防未来的自己”。
具体地说它防三件事。防止你自己在赶时间的时候放水—— 一条自动检查不会因为今天很急就网开一面,而你会。 防止上下文丢失—— 三个月后回到一段代码, 你已经忘了当时为什么那样写,而一条带 incident 字段的规则会告诉你。 防止 Agent 的产出在你不注意时漂移—— 这是最实际的一条, 因为一个人不可能逐行看几十个改动,而 Agent 会持续产出。
16.8.1 一个人该建哪两条
如果只建两条,第一条仍然是测试执行数不为零,理由同前,成本半小时。
第二条是一条”我最容易犯的错”的检查,而这一条要靠翻自己的提交历史 找出来:看你过去半年里,有哪一类问题你修过三次以上。 一个人的系统里这个数据特别可靠,因为所有的提交都是你自己的 —— git log 就是你的故障记录,而且它比任何事后回忆都准确。
16.9 三道检查在不同技术栈上的形态
三道检查的描述是技术栈中立的,但落地时的第一步不是 —— 所以给几个常见栈的具体入口,省掉那半小时的摸索。
测试执行数那一条,关键是找到你的框架打印执行数的那行。 Node 生态的常见形态是 N passing 或 Tests: N passed; Python 是 N passed;Go 是 ok <pkg> 一行一个包, 所以断言的是 ok 出现的次数而不是一个数字; JVM 系通常在报告文件里而不在标准输出里, 这时候更省事的做法是断言报告文件的用例数节点存在且非零。
退出码三分那一条,第一步是找出你现在的失败里 哪些其实不是代码的错。做法是翻最近二十次红灯的日志, 把它们分成两堆 —— 这个动作本身就是 小节 10.6.0.3 那个练习, 而它的产出直接就是那份基建故障的特征列表。 不要凭想象写那个列表,凭日志写。
报数规则那一条在任何栈上都一样, 因为它的最小形态就是 grep 加 wc -l (小节 16.6.3)。唯一需要注意的是 把扫描的文件数一起打印出来 —— 那是哨兵的读数, 而它是三条里唯一一个”现在不做以后补不上”的东西。
三条的共同点是没有一条需要引入新工具。 如果你在做这三件事的过程中发现自己在选型, 那说明走偏了 —— 它们的正确形态是加在你现有 CI 脚本里的几行。
16.10 判定层和你的时间的关系
一个视角,它解释了这一层为什么值得投入: 判定层做的事情,本质上是把你的注意力从”检查”移到”判断”。
没有判定层的时候,你要看每一个改动,你要判断”这样写对不对”, 而你的注意力随产出量线性消耗。有判定层的时候,你只看被拦下来的, 机器判断”这样写对不对”、你判断”该不该这么做”, 而你的注意力随”意外”消耗。
第三点是关键。一个健康的判定层,会让你的注意力消耗和”产出量”脱钩, 转而和”意外的数量”挂钩 —— 而意外的数量是有上限的 (一个系统里真正新鲜的问题不多),产出量没有上限。
这一层的投入回报之所以是超线性的:它不是让你快了一点, 它改变了你的注意力消耗曲线的形状。 而这也解释了 小节 19.5 那句话为什么重要 —— 当注意力不再和产出量挂钩之后,它就变成了这个系统里最稀缺、 也最值得保护的资源。
16.11 什么时候可以停
这一章给的东西可以一直建下去,所以得说清楚什么时候该停。 一个可用的停止条件:当你连续两个月没有遇到 “合并之后才发现的问题”时,停。
这不是说你的系统完美了,是说当前的瓶颈已经不在判定这一侧了, 而继续加固判定是在优化一个不是瓶颈的环节。
那时候瓶颈通常在两个地方,而我对它们都帮不上忙。一个是参考输入—— 也就是”该做什么”这个按定义在回路之外的问题 (小节 20.2 · 章节 20),做的事情本身不对, 判定再准也没用。另一个是工具链(章节 9)—— Agent 够不着某些东西,只能猜。
第二个在小团队里出现得比想象中早。一个五人团队在建了三道检查之后, 下一笔最值钱的投入通常不是第四道检查, 是让 Agent 能看见它改的东西。
16.12 三道检查的常见实现错误
按遇到的频率排,四个。
16.12.1 错误一:断言执行数等于某个具体的数
grep -q "Executed 42 tests" # ✗
grep -qE "Executed [1-9][0-9]* tests" # ✓前者每次加测试都会失败,然后被人改成新的数, 改几次之后就会被改成 Executed .* tests —— 于是它什么都不检查了。 一条会因为正常工作而失败的检查,最终会被改成不检查任何东西, 而中间那几次修改每一次都是局部合理的。
16.12.2 错误二:把基建故障的特征写死
第一次遇到 “connection refused”,加进去;第二次遇到 “i/o timeout”, 加进去;第三次遇到一个新的,检查把它归成了内容违规。 这不是错误,这是必然 —— 特征列表永远不完整。
正确的心态是把它当成一个持续维护的列表,而不是一次写完的配置。 这意味着它需要一个 owner(小节 10.15)—— 一个没有 owner 的”持续维护的列表”,实际上是一个停止维护的列表。
16.12.3 错误三:报数模式的输出没有位置信息
[MY-RULE] 47 处违规 ✗ 你不知道该从哪开始清
[MY-RULE] 47 处违规 ✓
src/a.ts:12 ...
src/b.ts:88 ...
报数模式的全部价值就在那份清单里。 只报一个数字, 你得到的是焦虑,不是行动项。那条挂着 966 处违规的规则 仍然有价值 —— 它每一次都把这 966 处的位置和修法摆出来 (小节 13.7)。
16.12.4 错误四:忘了打印扫描面
[MY-RULE] 0 处违规 ✗
[MY-RULE] 0 处违规(扫描 1,204 个文件) ✓
前者在规则坏掉时和规则通过时长得一模一样。 这个数字是免费的(你本来就遍历了那些文件), 且它是后面所有自检机制的基础(小节 13.4.3)。 从第一天就打印它 —— 这是这一章里唯一一条错过就补不上的建议, 因为它的价值在时间序列里,而时间序列没法追溯生成。
16.13 三道检查之后的第四道该是什么
三道建完之后,最常见的问题是”接下来加什么”。 答案不在我这里,在你的评审记录里 —— 小节 10.5.1 那个练习的产出, 也就是你重复说得最多的那句话,就是第四道检查的内容。
这条原则值得被明确表述:第四道及之后的每一道检查, 都应该来自你自己的失败,而不是来自任何一份现成的清单。 理由在 小节 A.8:我讲到的那二十三条规则里, 真正可移植的只有三到四条,剩下的十九条对你来说是噪音 —— 而噪音在规则集里的代价不是零,它稀释了每一条规则的可信度。
16.14 判定层的三个成熟度阶段
给一个可以定位自己的框架。
16.14.1 阶段一:有检查,但不知道检查可不可信
特征是 CI 是绿的,但你不完全放心,有时候会手工再验一遍。 这个阶段的瓶颈是可信度,不是覆盖面 —— 加更多检查不会让你更放心,让现有的检查可信才会。 三道检查(小节 16.1)针对的正是这个阶段。
16.14.2 阶段二:检查可信,但覆盖不全
特征是 CI 绿了你就敢合,但仍然有问题漏过去。 这个阶段可以开始加规则了,而且加的每一条都应该来自 一次真实的逃逸(小节 15.11)—— “逃逸”这个词是准确的:它指的是一个已经过了你所有检查、 然后在生产里出了问题的东西,而它精确地标出了你覆盖面的一个洞。
16.14.3 阶段三:覆盖够了,但维护成本上来了
特征是规则超过二十条,开始有误报,开始有人抱怨。 这个阶段需要的是 小节 15.13 那张健康检查清单, 和一个有名字的 owner(小节 10.15)。 注意这个阶段的动作和前两个阶段是反的:前两个阶段在加, 这个阶段的主要工作是减和修。
16.14.4 三个阶段的顺序不能跳
最常见的错误是从阶段二开始 —— 直接加一批规则, 而底下那三道保证可信度的检查还没有。 结果是你有二十条规则,但你不知道它们是不是还在工作。
这个状态比只有三条可信的检查更糟,因为它给了你一种虚假的安全感, 而虚假的安全感会让你停止手工验证。也就是说, 你不仅没有得到二十条规则的保护,还失去了你原本有的那层人工兜底。
16.15 自检表
把 小节 18.14 那张表放在这里,因为它是这一层最好的入门练习。 对你现有的每一道检查,填这一行:
| 检查 | 它坏了会表现成通过还是失败? | 有没有量程校验? | 有没有第二通道? |
|---|---|---|---|
大部分人第一次填完会发现:绝大多数检查在坏掉的时候会表现成”通过”, 而且后两列一个勾都没有。这不是灾难 —— 这只是说明这些检查此前从来没有被当成传感器看待过。 只要开始这么看,后两列都能在一天之内补上第一个勾: 量程校验就是打印扫描面,第二通道可以简单到”在另一个环境里再跑一次”。
16.16 这三道检查为什么不包括”写测试”
一个合理的疑问:为什么入门清单里没有”要求写测试”? 因为”要求写测试”不是一道检查,是一个期望。
它和这三道的差别在三处。能不能自动验证:能,用覆盖率, 但那验证的不是同一件事。失败时的动作明不明确:不明确—— “写什么样的测试”这个问题,覆盖率不回答。它坏了会怎样:无信号。
第二处是关键。“覆盖率不达标”这个失败给出的指导是”去覆盖那些行”, 而满足它最快的方式是写没有断言的测试(小节 12.12)—— 所以这条要求会引导出一个不是你想要的行为。
三道检查里那条”断言测试执行数不为零”不同: 它失败时的指导是明确的(你的过滤器写错了,或者宿主缺了), 而且没有一个”作弊”的满足方式 —— 要让执行数非零,你必须真的跑一些用例。
这给出一个判断一道检查好不好的通用标准: 满足它最省事的方式,是不是你想要的那个行为? 如果不是,这道检查会产生它自己的技术债 —— 而那笔债的利息,是所有人对这套检查的信任。
16.17 判定层建设的一条时间线
给一个可以照着走的六个月节奏:
| 时间 | 做什么 | 产出 |
|---|---|---|
| 第 1 周 | 测试执行数断言 + 退出码三分 | 判定开始可信 |
| 第 2–4 周 | 只观察,记评审语句和红灯分类 | 两份数据 |
| 第 2 月 | 第一条规则,报数模式 | 存量的数字 |
| 第 3 月 | 清存量,同时观察第二条规则的候选 | ——— |
| 第 4 月 | 第一条切拦截,第二条上报数 | 第一道真正的门 |
| 第 5–6 月 | 重复,同时开始记录扫描数的时间序列 | 可观察的健康度 |
注意第 2–4 周那一格:什么都不加,只观察。 这三周通常会被跳过,而”只观察”之所以难,是因为它没有可见的产出 —— 在那三周里,你手上没有任何新东西可以展示。
但那三周的产出是后面所有规则的依据。 跳过它的代价不是”晚了三周”, 是你的第一条规则来自猜测而不是数据(小节 15.11), 而一条来自猜测的规则误报率高、会被绕过, 然后连带损害你对整套东西的信任。
16.18 压成三句话
一、先建可信度,再建覆盖面。 三道检查 (执行数、退出码三分、一条报数规则)针对的全是”我的检查可不可信”, 而不是”我的检查够不够全”(小节 16.14.4)。
二、第四道及之后的每一道,都必须来自你自己的失败。 因为抄来的规则边界没有被你的代码校准过, 而误报的规则会被绕过、绕过之后还继续消耗信任。
三、一道好的检查,满足它最省事的方式应该就是你想要的那个行为。 小节 16.16 那个判据是这一章唯一的通用测试 —— 它能提前发现一道检查会不会制造它自己的技术债。
16.19 最后一件事:不要一次做完
这一章给的东西加起来大概一周半的工作量, 而正确的做法是把它摊到两三个月。 理由已经在 小节 16.17 那张表的第二行里了。
这条建议有一个具体的出处:这套系统的第一条自动检查, 比 Agent 规模化晚了整整两个月(小节 15.5)。 那不是拖延,是因为在那之前根本不知道该拦什么 —— 而这是我付过的最贵、也最值得的一个教训。