把不确定性收进流水线:一个人的系统如何变得可检查
一个能跑的小系统,什么时候开始让人害怕?
大概是从“改一个函数要先想十分钟”开始的。你有几百到几千行代码,一两个服务,跑在自己租的机器或家里的旧电脑上。它每天替你干活,你也习惯了它。然后某一天你发现,自己不太敢动它了:发布要挑没人用的时段,出了问题先怀疑网络再怀疑自己。
这种怕不是能力不足,是缺少可核对的东西。没有测试,就只能靠回忆判断“改坏了没有”;没有发布流程,每次上线都像一次手工手术;没有观测,事故只能靠用户抱怨来发现。怕的来源不是复杂度,是不可验证。
下面是我在自己项目上试出来的六个方法,以及它们各自在什么情况下会失效。
一、验证要分层,并且说清每层测不到什么
多数人谈测试只谈“覆盖率”,但覆盖率回答不了“我改坏了会不会被发现”。更实用的分法是按“每块钱买到多少信心”排序:
契约层最便宜。真起一次服务,用一个假的下游代替真实依赖,断言返回码、必填字段、错误格式。它挡的是最常见的回归:重构时少一个字段、改名时漏一处引用。成本大约半天。
纯逻辑层次便宜。把不依赖 IO 的函数抠出来,用输入输出表覆盖。边界条件、空输入、异常分支都能量化验证。前提是这些函数真的纯——一旦它读全局变量或写文件,这层就退化成集成测试。
形态层几乎免费。对源码做静态断言:某段逻辑必须长成某个形状。它挡不住行为回归,但能防“某个决定被悄悄改回去”。写这类测试要诚实:它不是验证,是绊线。我曾在里面误报过一次——断言“事件只能绑定一次”,但代码里两个绑定点分别在 if 和 else 分支里,语义正确、形状不符。
运行期层最贵,也最接近真实。事件时序、状态同步、超时恢复,只有真浏览器或真设备能测。这一天真正造成损失的三个 bug(一个取消不掉的计时器、一个只听单帧的状态开关、一个死循环)全部落在这一层,而前三层一条都拦不住。
四层的比例比总数重要。如果一套测试从 20 项长到 60 项,其中 50 项是形态断言,那不是进步,是自欺——维护成本涨了,抓真事故的能力没变。
我的实际分布是 7 条契约、6 条纯逻辑、9 条形态、4 条端到端,跑完约 9 秒。九秒是个心理阈值:超过它,测试就会变成“发布前跑一次的仪式”。
二、同一个事实只能有一个账本
这个原则听起来像常识,但违反它的人几乎都能找到理由:“页面自己也记一份,响应更快”。
违反的代价不是“偶尔不一致”,而是没有任何机制会发现不一致。我遇到过三次,每次的形态都不同:
第一次,忙闲状态。 页面有个 pending 变量,只听服务端推送的“空闲”事件;服务端有自己的 busy。重连、切会话、手机锁屏都会漏掉那一帧,于是 pending 永久为真——之后每条消息都被当成“插队”发出去,界面上显示“排队中”,而服务端根本没排队。活干了,标签全错。
第二次,界面版本。 磁盘上的 HTML 改了,手机上那份 JavaScript 还是三天前的。装到桌面的应用被唤醒时只恢复页面,不重新请求文档,所以“我改完了”和“你看到了”是两件事。修法不是让用户记得刷新,而是让页面自己发现过期:服务端暴露一个文件指纹,页面每六十秒比对一次,不一致就亮出“界面已更新”。
第三次,计数。 一个分布式键值存储里的计数,被当成限流的依据。它的读有六十秒缓存,写有每秒一次的上限——计数序列永远停在 1。这不是“性能差一点”,是语义完全不对:我需要“读到我刚写的值”,它提供“读到一个可能过时的值”。
三处的修法一模一样:指定一个账本为准,另一个降级成视图。 具体动作是三步:找出所有“我也记一份”的地方;问一句“这份副本错了谁会知道”;如果答案是“没人知道”,就删掉它。
顺带一个副产品:跨进程的字符串约定必须容错。我给一个字段写的是 follow_up,前端判断的是 followup,差一个下划线,结果所有条目都显示成同一个标签——这种 bug 不报错、不崩溃,只是安静地显示反。
三、流程里可靠的是断言,不是纪律
“我记得要脱敏”迟早会变成“我忘了”。而“命中次数不等于 1 就中止”,永远不会忘。
我做过一次发布流程的重构,起因很具体:准备把代码推到公开仓库,翻文件头发现有六行硬编码——一个对象存储的 Basic 认证、一个资源 id、四个内网域名。再加上部署路径、内网网段前缀、前端里“粘贴 token,例如 xxx”的占位文案(那个 xxx 是真令牌),一共十二处。
第一版做法是“push 之前 diff 看一眼”。它成功过一次,但那一次是 59 行差异;如果当天改动是五百行,这一眼就会变成扫一眼。人眼筛的是“变化”,不是“属性”——你看见的是“我改了什么”,而风险藏在“我一直带着什么”。
第二版把它写成十二句替换,每句带一个断言:
如果这条模式在文件里出现的次数不是 1,脚本中止。
三种情况它都会立刻红:写法变了(次数变 0)、被复制到两处(次数变 2)、目标文件结构调整(整段匹配不上)。它的价值不在防出错,在防沉默——静默失败是自动化最危险的形态,它比崩溃更糟,因为崩溃会打断你,静默失败会让你带着错误结论继续走。
第三版加了第二道闸:脱敏之后按特征扫一遍产物(密码片段、资源 id 模式、真实令牌、内网网段、自有域名),命中任何一条就中止。
这道闸第一次跑就误报了——但误报的不是代码,是我新放进仓库的文档,里面写着“静态站点部署在某域名”。域名是公开信息,写在文档里完全合理。这次误报说明:闸门的范围比规则更容易出错。 我改的是范围(只扫脚本会写出去的文件),不是规则。理由是一个每天误报三次的检查,两天后所有人都会学会跳过它——包括未来的我。宁可漏报,不可误报到失去可信度。
与之配套的还有一条默认值:安全组件默认关闭。找不到规则文件时,端点直接返回“服务不可用”,而不是“跳过检查继续跑”。这叫 fail closed。代价是某次部署漏文件会导致功能整个不可用,但那比“看起来能用、其实没检查”好得多。
四、选型的判据是“语义是否匹配”,只能靠实验
我给一个对外接口加限流,规则一句话:每 IP 每分钟 30 次。实现花了三个晚上,换了三种方案。
方案一,分布式键值存储计数。 读当前值、加一、写回,带六十秒过期。部署后连打 70 次,0 次被拦。键确实写进去了,问题是读:它的读在边缘有六十秒缓存,所以每次读到的都是旧值,计数永远停在 1;而写还有每秒一次的上限,快的连打直接写失败。
方案二,进程内计数。 模块级的一个字典,逻辑上无懈可击——前提是同一个 IP 的连续请求落到同一个实例上。再打 70 次,还是 0。低流量服务的实例复用没有保证,所以我实际写的规则是“每 IP 每实例每分钟 30 次”,效果约等于无限。
方案三,有状态的单实例。 按 IP 定位到一个实例,请求串行、状态强一致。同样的验证,80 次里出现 13 次 429,停 62 秒后恢复。
13 这个数字还骗过我一次:我第一次打 45 次,一次 429 都没有,差点判定“又失败”。真实原因是测试机的出口有两个 IP,每个各打了 22 次,都没到阈值。重新打 80 次才越过。限流测试必须先知道客户端有几个 IP,否则数字没有意义。
三次失败留下的判断题,我后来搬到了别处:
- 存储的语义要对得上(要“读到我刚写的”,就别用最终一致的存储);
- 作用域的边界要写清(写出一个模块级变量,就问“它活多久、和谁共享”);
- 先量化测试环境(几个身份、阈值多少、要打多少次才能跨越)。
五、验证的纪律:先问观测手段会不会骗你
那三个晚上最耗时的不是写代码,是判断“到底生没生效”。而三次里有一次,我看到的“事实”本身是错的。
我列出那一章里三次被观测工具骗的经历:
- 查一个键值库的键,返回空列表——因为我查的是本地模拟环境,没加远端参数;
- 平台文档写着某功能在免费计划里有一条额度,实际面板只有购买按钮——文档支持、凭证支持、账号支持是三个不同的东西;
- 限流明明生效,我判定失效——因为客户端有两个 IP。
三次的共同点:工具给出的结果都是“成功”的(退出码 0、页面正常、HTTP 200),没有报错,只有沉默的错误结论。
所以验证之前先做三个动作,加起来不到一分钟:
- 确认观测对象(我查的是哪个环境);
- 确认观测范围(只查了一个还是全部);
- 确认观测单位(这个数字的口径是什么)。
限流这类功能还要加三条:跨越阈值(阈值 30 就打 31 次以上)、考虑多身份(阈值乘以身份数再乘 1.2)、验证窗口恢复(停一个周期再打,必须恢复)。最后一条尤其重要,否则你无法区分“限制生效”和“端点被打坏了”。
六、一次连环翻车:三个问题叠成一个“没反应”
方法论讲到这里容易显得顺理成章,所以补一次真实的翻车。
用户报了一个问题:在装到桌面的应用里点“下载”,卡在一个没有关闭按钮的界面上,只能杀进程。我修了一版,回了一句“好了”。用户说:还是不行。
第二次我推断是平台限制(独立窗口的网页不能调起浏览器),于是改成“复制链接,去浏览器打开”。用户说:链接复制出来打不开,浏览器找不到。
查下去才发现是三个问题叠在一起:
- 拼出的地址是相对路径。
/files/a.md?token=…这样的东西粘进地址栏,当然找不到。缺的是域名。 - 提示被弹层盖住了。 提示条的层级是 60,弹层是 80,所以“失败原因”确实弹出来了,但它在弹层后面——用户一个字都看不见。
- 服务端还没重载。 新加的参数要重启才生效,而我改完只刷新了前端。
三个问题任何一个单独存在,表现都是“点了没反应”。这就是排查里最消耗人的形态:症状单一,病因多个。
它给出两条可复用的做法。第一条:先把“没反应”拆成“请求有没有发出去”——加一行可见的状态(正在做、成功、失败原因),把它写在用户视线之内,而不是依赖一闪而过的提示。第二条:改完之后自己走一遍用户的路径,而不是只验证函数返回值。那次我验证了“函数返回了正确的对象”,却没验证“页面还活着”。
顺带记一个更贵的教训:当天早些时候,我为了一个正则表达式的问题,让整个页面冻死了十几分钟——原因是用同一个带全局标志的正则既做匹配又做替换,替换会重置匹配位置,循环永不结束。这类错误静态检查看不出来,只有“页面是否还响应”这个问题能抓住它。
七、最小起步:两天能做完的三件事
如果从零开始,我会按这个顺序做三件,不再改。
第一天上午,定下“状态只有一个来源”。 这不是技术选型,是一条规矩:界面上显示的每个状态,必须能指回服务端的某个字段。成本几乎为零(少写几个本地变量),收益覆盖后面所有功能——因为绝大多数“界面抽风”的根因都是这条被违反。
第一天下午到第二天上午,写一层契约测试。 不用覆盖全部接口,只钉住那些“改了会让别人出错”的形状:返回码、必填字段、错误格式。这一层最便宜,挡的却是最常见的回归。
第二天下午,把发布流程当成第一个功能来做。 大多数人是代码写完才想怎么发,于是发布永远手忙脚乱。反过来做:先有“一条命令就能安全出门”的能力(生成产物、校验、脱敏体检、推送),后面每加一个功能都顺手发布,历史连续,回滚才有意义。
三件事加起来约两天。它们不会让系统变漂亮,但会让接下来每一次修改都变便宜。
八、代价:每个方法都有失效条件
以上方法都带前提,我把失效条件列在这里,方便判断它在你那里成不成立。
测试分层的失效条件:如果事故代价主要是“停机会赔钱”,那重点应该放在监控与回滚,而不是测试分层;如果团队有几个人,形态断言的价值会上升(它防的是别人改回你不认同的形状)。
单一账本的失效条件:唯一真相在服务端时,离线状态就答不出“我忙不忙”。这是必然让步,只能选择把哪一边的错误留给用户。
断言代替纪律的失效条件:断言只能防手滑,防不了判断错误。十二句断言保证“这十二处被换掉了”,保证不了“这十二处就够了”。
实验选型的失效条件:它假设你有能力快速部署和回滚。如果一次发布要排队半天,那“先跑一次看数字”的成本会超过收益。
验证纪律的失效条件:它假设你真的会花那一分钟。人在连续三次“应该好了”之后,最容易跳过的就是这一步。
八点五、两个可以直接复制的动作
方法论容易飘,所以补两个当天就能做的动作。
动作一:给每个“状态显示”找它的服务端字段。 拿一张纸,左边写界面上所有会变的状态(连接中、回复中、未保存、同步中、版本旧),右边写它对应的接口字段。右边留空的那一行,就是将来出事故的地方。
动作二:给发布流程加一条断言。 不用重构整个脚本,只加一条:某个必须被替换的字符串,如果命中次数不等于 1,就中止。这一条会立刻暴露“写法变了”和“被复制到两处”两类问题,而它们正是手动检查最容易漏的。
两个动作加起来不到一小时,但它们改变的是“有没有可核对的依据”这件事本身。
九、三个反直觉的结论
这套东西试下来,有三条结论跟我最初的直觉相反。
第一,测试数量不是指标,层间比例才是。 我一度以为“把测试写多”就是工程化,后来发现 60 条形态断言不如 20 条混合分层有用——原因很简单:前者维护成本高、抓真事故能力弱。判断标准应该是“这套测试能回答哪几类问题”,而不是“一共多少条”。
第二,让流程变麻烦一点,反而更容易被遵守。 发布脚本里那道泄露体检误报过三次,每次都卡住我,而我每次都在改范围而不是改规则——因为改规则要想清楚“这个特征该不该拦”。这个摩擦是对的:让放宽规则变麻烦,是闸门能长期有效的前提。 反过来,任何“点一下就跳过”的检查,都会在两周内被无条件跳过。
第三,承认代价不会削弱说服力,反而增强。 一份只说收益的方案,读者只能在“全信”和“全不信”之间选;一份把失效条件写在末尾的方案,读者可以自己判断“这个条件在我这儿成不成立”。后者才可能被真正采用。
结语:四个问题
这套东西真正的产出不是几个做法,而是四个可以随身带的问题:
- 这层验证测不到什么?
- 同一个事实有几本账?
- 这条流程靠的是断言还是纪律?
- 这个方案的失效条件是什么?
如果哪天它们变成了条件反射,这套东西就算真的装上了。
最后说一句实话:这么折腾完,系统仍然很难看——单文件前端四千行、两份副本靠约定同步、权限模型粗糙、测试跑九秒已经是耐心上限。它不优雅,也谈不上“架构”。
但它每天在干活,也确实在每次事故之后变得更容易被检查。优雅是给别人看的,可检查是给自己用的。 而一个人最需要的,恰恰是在半年后的某个深夜,能快速判断“我是不是把它改坏了”。