4  七个形状

这一章是全书的骨架。后面每一章都会回指这里的形状编号, 所以先读它,后面会省事很多。

4.1 为什么按形状组织,而不是按事故

一次事故是一个点。你的系统里没有我提到的那些具体组件 —— 没有那套构建系统、 没有那个日志聚合器、没有那个测试框架。所以”某个日志组件的保留期配了 但和容量不匹配”这件事,对你完全没有用,读完你只会觉得”哦,他们踩了个坑”。

但同一个形状在四个不同的层上出现,就成了可迁移的知识 —— 因为你的系统里一定有那个形状,只是穿着别的衣服。

我举一个具体的。有一次监控服务的健康检查返回 200,而用户登录返回 500, 两个结果都是真的。根因是磁盘满了:进程还活着、能读配置、能响应健康检查, 但写不进去,而健康检查只走了读路径。你的系统里可能没有那个监控服务, 但”探针只覆盖了一半路径”这件事,你一定有。

所以这一章不讲事故,讲形状 —— 一类失败的骨架,剥掉所有具体组件名字 之后剩下的那个东西。七个形状来自四十多次真实故障和二十多条被沉淀下来的规则, 而它们有一个共同点值得先说:七个里有五个的失败形态是”静默”的。 系统不会告诉你它坏了,因为按它自己的标准,它没坏。所以我的重心 在”判定”而不是”修复”。

flowchart TB
    A["A 探针测错<br/>(放大器)"]
    B["B 两个写者"]
    C["C 修实例没修机制"]
    D["D 声明未生效"]
    E["E 边界降级"]
    F["F 资源无界"]
    G["G 本地≠远端"]

    A -.->|"让其余六个<br/>不被发现"| B
    A -.-> C
    A -.-> D
    A -.-> E
    A -.-> F
    A -.-> G
    D -->|"没人测量它"| A
    F -->|"到达上限后<br/>开始丢弃"| E
    B -->|"两侧执行顺序不同"| G

    style A fill:#f8d7da
    style B fill:#fdf3e3
    style G fill:#fdf3e3

图里那些实线是连锁:一个形状会引发另一个,而修上游通常比修下游便宜。 虚线是 A 的特殊地位 —— 它本身是一种失败,同时也是其余六个的放大器: 每一个形状如果被及时发现,代价都是有限的,而 A 让它们不被发现。

4.2 形状 A · 探针测的不是你以为的东西

这是全书最重要的形状,也是我花最多篇幅对付的一个。

先看它在四个层上的样子。运维层:一个监控服务的健康检查返回 200, 而用户登录返回 500 —— 磁盘满了,进程能读不能写,而健康检查只走了读路径。 构建层:测试过滤器给了裸方法名,一个用例都没匹配上,测试二进制以 0 退出, 构建系统报 PASSED;要求跑八遍就报八个 PASSED。测试层:一条无条件的 跳过语句永久删除了覆盖,而测试文件还在,套件还是绿的。定时任务层: 一个自动刷新任务因为空指针崩溃挂了四个月,而构建全程是绿的。

四种不同的原因,同一个后果,同一个信号。把它们串起来的是这句话:

探针和被测对象之间,存在一条未被验证的因果假设。

健康检查假设”能读 ⇒ 能写”;构建报告假设”任务跑完 ⇒ 用例跑过”; 测试文件的存在假设”代码在 ⇒ 行为被覆盖”。假设不成立的那天, 探针不会告诉你 —— 因为探针本身没坏,它在正确地测量它测量的那个东西。

flowchart LR
    P["探针"] -->|"测的是"| X["能读吗<br/>任务跑完了吗<br/>文件还在吗"]
    X -.->|"未被验证的假设"| Y["能写吗<br/>用例跑过了吗<br/>行为被覆盖吗"]
    Y -->|"你以为它测的是"| W["被测对象"]
    style X fill:#e8f4e8
    style Y fill:#f8d7da

它主要在哪讲小节 12.3 · 章节 18

4.3 形状 B · 同一份状态有两个写者

这是出现次数最多的形状 —— 我那套系统里超过一半的路径不变量在回答这一个问题。

它的样子也很多。音频那次是录音之后编辑器播放静默失败,根因是多方各自 配置音频会话、互不知晓。构建那次是并发任务同时崩溃,因为同节点的多个进程 共享了一个输出根。测试那次是偶发的段错误,来自一个无锁的共享测试替身。 后端那次是用户身份串号,因为空邮箱被当成了同一个身份。

最漂亮的一个实例在数据侧。服务端写 Production、客户端写 production、 下游的过滤器比的是小写 —— 三方各自都有测试,各自都通过, 而服务端的退款一行都进不了任何报表。现场看起来是”数据管道没开”, 而原始表说它开着。

通用形态是:写入权没有被显式收敛,于是它被隐式地分给了所有能写的人。 排查困难在于,每个写者单独看都是对的,各自的测试也都是绿的 —— 问题只在它们同时存在时出现,而没有任何一方的视角能看到这一点。

它主要在哪讲章节 7 · 小节 14.5

4.4 形状 C · 修了实例,没修机制

这个形状的主要证据来自我自己的系统,所以我讲得会具体一点。

我给自己写过一条演进纪律:同一个问题出现第二次,就该把机制升到共享层。 在磁盘容量这件事上,我重复了五次。

第一次是登录返回 500,根因是磁盘满导致缓存文件损坏 —— 我清了盘。 第二次是 CI 任务失败,容器磁盘耗尽 —— 我清了盘。第三次是全站 500, 日志插件的缓存撑满了 —— 我清了盘。第四次是监控机满了,这次我看出 是日志没有保留期,于是我配了保留期,觉得这回从根上解决了。 第五次是同一台监控机又满了 —— 保留期确实配了,但它和磁盘容量不匹配, 清理速度跟不上写入速度。

真正的机制上移 —— 任何分区的水位告警 —— 到第五次的记录里才被提出来。 那次记录里写着一行字:“没有磁盘水位告警,潜伏 5 天”

我把这一段留在书里,是因为它说明了一件事:

知道原则和执行原则之间隔着注意力,而注意力是有限的。

同一个人,在代码这一侧把”第二次就上移”执行得极其彻底 —— 第二部讲的每一条都是它的产物;在基础设施那一侧执行得很差。 区别不在认知,认知是同一个人的。区别在哪一侧有判定覆盖。

它主要在哪讲章节 15 · 章节 19

4.5 形状 D · 配置声明了,但从未生效

你写了配置,系统读了配置,配置没起作用 —— 而且没有任何东西会告诉你。

最典型的一次是 CI 执行器的资源配额:配额写在配置文件里,格式正确, 没有任何报错,而整段被配置格式的子表解析吞掉了,从未生效。 类似的还有前面那个保留期(配了但跟不上)、一次路由文件没被发现导致 监控全断,以及二十八条工作流里有五条从注册之后一次都没跑过。

通用形态是声明与生效之间缺一层判定。“注册表是唯一事实源”这句话是对的, 但“注册了”和”在跑”是两件事

最极端的一个变体记在某份策略文件的注释里:这份清单的文件名不能以 某个前缀开头,因为版本控制的忽略规则里有一条未锚定的通配 —— 一旦文件名以它开头,这份策略对版本控制就直接隐形了。规则还在文件里写着, 在本地跑也正常,但它根本没被提交。这不是”配置没生效”, 是”配置根本不存在,而你在本地看得见它”。

它主要在哪讲章节 19 · 小节 13.6

4.6 形状 E · 边界处的静默降级

请求穿过一层边界之后语义变了,而两侧各自都”正常”。

我举全书最好的那个素材。有一个功能靠按 IP 的限流做防刷 —— 访客每小时 三十次,这是那个功能唯一的闸门。生产链路上请求要先过一层边缘节点, 框架取客户端 IP 时按可信网段从右往左走转发头,取到的右端是边缘节点 自己的公网地址。于是所有请求被算成同一个 IP,全世界共用一个配额。

关键在于:功能看起来完全正常,限流也确实在工作。 你去读代码, 逻辑是对的;去看日志,限流在触发;去看监控,请求量正常。 唯一的问题是这个闸门实际上不存在。

同类的还有几个:一次 SSH 连不上,因为数据包在云厂商的边界就没到主机; 一次内部登录 404,因为请求被反向代理误路由到了另一个服务; 以及模拟器上所有依赖页面浏览的功能永不触发,因为模拟器被判成了 非生产环境,埋点事件直接短路。

它主要在哪讲小节 9.3 · 章节 2

4.7 形状 F · 资源无界增长

没有上界的东西,最终会用完某种东西。

僵尸进程加模拟器残留吃光了执行机的进程数;三十天的孤儿进程压垮了端口回收; 反向代理内存耗尽被系统杀掉,导致全站不可用;一个分析平台的快照没有自动清理, 累积到 880G 撑爆磁盘。

这几个都好理解。但有一个不一样的,值得单独说:一个产品吃掉了全部 CI 机器。

按功能拆测试 bundle,曾把 8 个产品拆成 30 个目标,而每个目标是一个 独占的 CI 任务,再乘以系统版本矩阵 —— 一次共享层的改动就要 43 个任务去抢 5 台机器。这里”无界”的那个资源不是内存也不是磁盘, 是构建图上的目标数。一个看起来只是”组织方式”的决定, 在构建图上是一个无界的资源申请。

它主要在哪讲章节 17 · 小节 12.8

4.8 形状 G · 本地与远端跑的不是同一件事

这是唯一一个有明确对照的形状 —— 你手上就有两个环境可以比 —— 所以它也是七个里最容易被发现的一个。

差异通常来自四个地方,按可疑度从高到低排:并发争用(多个模拟器 互相抢资源,最常见)、覆盖率插桩(它改代码生成,能翻出只在插桩下崩的问题)、 环境包装(语言环境的默认值)、版本漂移(CI 的系统更新之后, 界面框架展平成原生视图层级的方式变了,于是层级断言只在一边成立)。

还有一类和”环境差异”无关:一次监控面板全丢,因为有人手动启动容器时 漏了网络参数、卷也挂错了。它是一次手工操作和一次自动化操作的差异, 而教训和上面四种是同一条:

本机绿是必要条件,不是充分条件。

这一条对 Agent 尤其重要,因为 Agent 拿到”本机绿了”这个事实之后, 默认会认为工作完成 —— 必须明确告诉它这个结论的适用范围。

它主要在哪讲小节 12.4 · 小节 13.2

4.9 七个形状的共同点

把七个放在一起,有三件事值得注意。

第一,五个是静默的。 A(探针测错)、C(修了实例)、D(配置未生效)、 E(边界降级)以及 F 的一部分 —— 这些失败发生的时候,系统不会告诉你, 因为按它自己的标准,它没坏。只有 B 和 G 会主动表现出来(崩溃、测试红), 而这正是它们通常被更快修好的原因。

第二,它们在层与层之间高度同构。 同一个形状会出现在网络层、构建层、 测试层、业务层,这意味着在一层学到的教训可以直接迁移到另一层 —— 一个知道”健康检查只测了读路径”的人,更容易看出”这个测试只测了构造函数”。

第三,它们全都是”判定”的问题,不是”实现”的问题。七个形状里 没有一个是”代码写错了”。它们全都是”我们以为我们知道,但我们不知道”—— 测量错了、覆盖漏了、机制没上移、配置没生效、语义变了、资源没有界、 环境不一样。所以后面几部讲的是怎么建立可信的判定,而不是怎么写出正确的代码。

4.10 怎么在自己的系统里找形状

七个名字本身不重要,能在自己的系统里指认出实例才重要。 给四个探针,每个十分钟以内。

探针一,找形状 A:对每一道检查问「它坏了会怎样」。答案是”通过”的 每一处,都是形状 A 的一个候选。具体做法是设想它测的那个东西完全失效了, 但检查本身没坏 —— 健康检查还能返回 200 吗?测试套件还会报绿吗?

探针二,找形状 B:搜索”配置”和”初始化”。任何一个可以被多处设置的 全局状态都是候选,典型的搜索词是”设置某某模式”“初始化某某”“注册某某”。 判据很干脆:如果两个地方都调用了它,谁最后调用谁赢吗?如果是,你有两个写者。

探针三,找形状 C:翻你的故障记录,按根因所在的组件分组。 注意是按根因分,不是按表象分 —— 按表象分是没用的,因为表象每次都不一样。 同一个组件出现三次以上,就说明那里该有一个机制而不是三次修复。 这个探针的成本几乎为零,你的工单系统或者聊天记录里就有数据, 而它通常会给出一个让人意外的结果:最常出问题的那个组件, 往往不是大家以为的那个。 这套系统自己翻的时候,原以为根因集中在日志系统, 翻完发现是磁盘。

探针四,找形状 D:对每一份配置问「怎么证明它生效了」。不是”它写对了吗”, 是有什么可观测的东西能证明它在起作用。答不上来的每一份配置, 都可能已经失效了很久。

4.11 形状之间会连锁

七个形状不是并列的,它们之间有结构,而知道这个结构能帮你少走弯路。 有三条常见的连锁路径值得单独记。

第一条是 D → A → C。 配置声明了但没生效(D),而没有任何东西测量它(A), 于是同类问题反复出现、每次都在症状点修(C)。磁盘那五次就是这条链, 而它的入口在 D —— 我第四次的那个”修复”(配保留期)本身就是一个 “声明了但没验证生效”的动作。

第二条是 F → E。 某个资源无界增长(F),到达上限后开始丢弃, 而丢弃发生在某层边界上,两侧看起来都正常(E)。队列积压导致的 数据丢失几乎总是这条链。

第三条是 B → G。 同一份状态有两个写者(B),而两个写者在本地和 CI 上的 执行顺序不同,于是表现为”只在 CI 上失败”(G)。大部分”诡异的 CI flake” 是这条链,而它经常被误诊成”CI 环境的问题”,然后靠重试掩盖过去。

知道链条的用处是:当你确认了一个形状之后,去它的上下游看看 —— 通常那里还有一个,而且修上游比修下游便宜。

4.12 每个形状的最小防线

如果每个形状只建一道防线,建哪一道?

形状 A 建”断言探针的工作量不为零”(执行数、扫描数、处理条数),半小时。 形状 B 建”给每份关键的可变状态显式指定一个 owner,写在代码里”,一天。 形状 C 建”故障记录按根因组件分组,出现三次就上机制”,每次半小时。 形状 D 建”每份配置配一条’它生效了’的断言”,每份十分钟。 形状 E 建”在每层边界的两侧分别测一次同一个量”,每层一小时。 形状 F 建”给每个只增不减的量配一个上界和告警”,每个十分钟。 形状 G 建”让本地和 CI 跑同一条命令”,半天到几天。

七道防线,总成本大概三到五天。它们的共同特点是:每一道都是 “加一个测量”,而不是”改一段逻辑”。这是全书内容的缩影 —— 判定先于修复。

4.13 为什么是七个

不是刻意凑的,但也不是穷尽的。

这七个是从四十多次故障和二十多条规则里归纳出来的,而归纳的标准只有一条: 它至少在三个不同的层上出现过。 只在一个层上出现过的模式没有进这张表, 因为它们大概率是那个层的特定问题,不是可迁移的形状。

所以这张表的完整性是有限的,而且它偏向于我撞过的那些墙。一个不同类型的系统 (比如高并发的交易系统、或者一个数据密集的批处理管线)大概率会有它自己的 第八、第九个形状。

用法不是”记住这七个”,是”照着这个方法找出你自己的那几个” —— 翻你的故障记录,按根因所在的组件分组,找出那些在三个以上不同的地方 出现过的模式。那才是你自己的形状表,而它比这七个更有用。

4.14 形状 A 的四个变体

形状 A 出现得太频繁,值得把它的内部结构拆开。 四个变体,而它们的排查方法完全不同 —— 把它们混在一起,会导致你用错的手段去找一种它抓不到的失效。

4.14.1 变体一:探针测的是另一个量

健康检查测读路径,而你以为它测服务可用性。 这个变体的特征是探针本身完全正常—— 它一直在正确地测量它测量的那个东西,只是那个东西不是你要的。 排查方法是写下”它实际测什么”和”你想知道什么”,对比两句话。 这是四个变体里唯一无法用机制解决的,它需要一次人的检查。

4.14.2 变体二:探针没有执行

零个用例、缺可执行宿主、任务被跳过。 这个变体的特征是探针的”工作量”为零, 排查方法是断言工作量不为零 —— 执行数、扫描数、处理条数。 这是最容易机制化的一个变体,而它也最常见, 两件事加起来构成了我最强的一条建议。

4.14.3 变体三:探针执行了但覆盖面缩小了

规则的匹配范围因为重构而变小(小节 15.9.3)。 特征是工作量非零,但比它应该的小, 排查方法是把工作量记成时间序列,看变化率而不是看绝对值 —— 因为”应该是多少”这个问题没有固定答案, 但”它突然掉了三分之二”是一个明确的信号。

4.14.4 变体四:探针失效但仍在输出

一条从来不会失败的测试。特征是工作量正常、输出正常, 但它没有失败的能力—— 而这一点从外部完全观察不到。 排查方法只有一种:主动注入已知故障,也就是变异验证和机内自检。

4.14.5 四个变体的排查成本

变体 检测方式 成本 能否自动化
测错量 人工对照 不能
没执行 断言工作量非零 极低
覆盖缩小 工作量的时间序列
失效但输出 主动注入故障 部分

第二行的成本最低而收益最高, “断言测试执行数不为零”之所以是全书投入产出比最高的一条建议,理由就在这里。 第一行值得单独记住:它是四个里唯一不能自动化的, 所以它需要被排进一个人的日程,而不是排进一条流水线。

4.15 形状与后面几部的对应

七个形状不是孤立的分类,书的后面几部分别在回答它们。 第二部(环境)针对的是让形状少发生:目录结构防 C, 架构约束防 B,载体安排防”规则没在场”这一类, 工具链防 G。第三部(判定)针对的是让形状被发现: 测试与变异验证防 A,结构检查防 B 和 D,路径不变量防高风险路径上的所有形状。 第四部(回路)针对的是让”发现机制自己”可信: 传感器故障对应 A 的四个变体,观测器对应”根本没有测量”的那些角落。

三部曲的逻辑是:先少发生,再能发现,最后确认发现机制还活着。 三步的顺序不能颠倒 —— 一个不可信的发现机制, 会让前两步的所有努力都变成一种感觉良好的假象。

4.16 形状为什么值得被命名

给一类现象起一个名字,看起来只是修辞。它不是 —— 名字改变的是”这件事能不能被讨论”,而这有三个具体的效果。

名字让类比成为可能。 在有”形状 A”这个名字之前, “健康检查只测读路径”和”测试跑了零个用例”是两件毫不相干的事; 有了名字之后,它们是同一件事的两个实例 —— 而这意味着解决其中一个的方法,可能适用于另一个。

名字让”我们以前遇到过这个”变成可查的。 一份故障记录如果只按表象归档,那么”磁盘满”和”登录 500” 是两个条目;按形状归档,它们是同一条 (小节 4.10 的探针三)。

名字让预测成为可能。 小节 4.11 那三条连锁, 没有名字就写不出来 —— 你没法说”D 会导致 A”, 如果 D 和 A 都还没有名字。能写出连锁, 就意味着确认一个形状之后你知道该去哪里找下一个。

4.17 但名字也有它的风险

一套分类会让人只看见分类里的东西。 七个形状是从一个特定系统的失败里归纳的(小节 4.13), 所以它偏向于那个系统撞过的墙。一个不同的系统 会有它自己的第八、第九个形状 —— 如果读者只用这七个去套,就会漏掉它们。

所以这一章最后那句”照着这个方法找出你自己的那几个”不是客气话, 它是这一章唯一真正重要的建议。 而”这个方法”具体是三步: 翻你的故障记录,按根因所在的组件(不是表象)分组, 找出在三个以上不同层出现过的模式。

第二步是关键,也是最容易做错的一步—— 因为故障记录天然是按表象写的(那是发现它时的样子), 而按表象分组会把同一个形状的实例分散到七八个类别里, 于是每一类看起来都只出现过一次, 而”只出现过一次”正是”不需要建机制”最有说服力的理由。

4.18 七个形状各自的”最早可发现点”

小节 4.21 按”发现它需要多大力气”排了序, 这里换一个维度:在一次改动的生命周期里, 每个形状最早能在哪一步被抓到?这个维度更实用, 因为它直接对应”该在哪里加检查”。

形状 B(两个写者)和形状 D(配置未生效)最早能在写代码时抓到—— 它们都是结构事实,一次依赖图查询或一次配置类型校验就能发现, 不需要把程序跑起来。所以它们归结构检查那一层。

形状 E(边界降级)和形状 G(本地≠远端)最早只能在跑起来时抓到, 而且必须是在真实的边界两侧跑 —— 一次只在本机跑的集成测试 对这两个形状毫无办法,因为它两侧用的是同一个环境。

形状 F(资源无界)最早能在设计时抓到, 而这一点很容易被忽略:一个没有上界的队列、 一个没有保留期的日志、一个随产品数量线性增长的构建目标数 —— 这些在写第一行代码之前就能看出来, 条件是有人问一句”这个东西的上界是什么”。 没有人会自发地问这句话,除非它是一份清单上的一项。

形状 C(修了实例)最早只能在第三次出现时抓到, 按定义如此 —— 它需要跨越多次故障才能显形, 所以它的检测机制不在流水线里,在故障记录的归档方式里 (小节 4.10 的探针三)。

形状 A 没有”最早”。 它可以在任何一步发生, 而且在任何一步都不产生信号 —— 它唯一的发现方式是主动去问 “这个探针坏了会怎样”(小节 18.14)。

这份排序合起来给出一条投入建议:能在写代码时抓到的, 就不要留到跑起来时抓,因为前者的回路延迟小两个数量级 (小节 17.3)。对那些按定义只能晚发现的, 投入的方向不是”更早发现”,是”发现之后的信息更完整”。

4.19 形状 B 为什么出现得最多

小节 A.9 那个统计显示: 整个规则体系里超过 40% 在回答”这块状态归谁写”。

这个比例值得解释,因为它不是偶然。

三个原因,每一个都在 Agent 场景下被放大:

一、写入权是隐式分配的默认状态。

一个新加的模块,如果没有人显式规定”这块状态只能由 X 写”, 那么它的写入权默认属于所有能访问它的地方。

“能访问”通常是宽松的 —— 一个全局单例、一个公开的属性、 一个系统 API。所以形状 B 是默认发生的,不是需要被制造的。

二、每个写者单独看都是对的。

这是它最难被发现的原因。A 模块设置音频会话的代码是对的, B 模块的也是对的,它们各自的测试也都是绿的。

问题只在它们同时存在时出现,而没有任何一方的视角能看到这一点。

三、并行的 Agent 让它更容易发生。

几十个 Agent 同时工作,它们看不到对方在改什么小节 7.10.1)。人的团队里, “我在改这块你别动”这类沟通拦下了一部分; 而 Agent 之间没有这个机制。

所以这个形状不只是最常见的,它还是唯一一个 会因为 Agent 规模化而变得更常见的。

4.20 形状表的一个用法:事后复盘的模板

每次故障之后,用七个形状过一遍,产出一份结构化的复盘:

这次故障是哪个(哪些)形状?
  → 如果找不到匹配的,可能是你的第八个形状,记下来

同一个形状,在别的层还有实例吗?
  → 这个问题通常能挖出一到两个还没爆的

这个形状的最小防线是什么(@sec-minimal-defenses)?
  → 建它,成本通常是半小时到一天

有没有连锁(@sec-shape-chains)?
  → 上游还有一个的话,修上游更便宜

四个问题,十五分钟,而它的产出比一份自由格式的复盘可行动得多。

因为自由格式的复盘会停在”根因是 X,我们修了 X”, 而这四个问题会把它推到”这一类问题的防线在哪”。

这正是 小节 4.4 那个形状要防的东西 —— 修了实例,没修机制。

4.21 七个形状按”发现成本”排序

最后给一个实用的排序 —— 按”发现它需要多大力气”, 因为这决定了你该先查哪个:

形状 发现成本 为什么
B 两个写者 通常会崩溃或产生错误数据
G 本地≠远端 有明确的对照(本地 vs CI)
F 资源无界 撞上限时会有明确的错误
E 边界降级 需要在边界两侧分别测
D 声明未生效 需要主动验证”它生效了”
C 修了实例 需要跨越多次故障才能看出模式
A 探针测错 最高 它的全部表现就是一片绿色

排序和重要性正好相反。

A 最难发现,而它是最重要的(小节 4.9 里 “它是其余六个的放大器”)。

所以正确的投入顺序不是按重要性,而是按”发现成本 ÷ 收益” —— 而 A 的最小防线(断言探针的工作量不为零)恰好成本极低, 这就是为什么它排在所有建议的第一位。

4.22 形状表怎么用:走一遍

用一个假想但典型的场景,完整走一遍这张表怎么用。

场景:你们的部署流水线有一步是”等服务健康后继续”。 最近开始偶发地在这一步之后失败,但重跑就好了。

逐个形状问:

A(探针测错):那个健康检查测的是什么? 如果它测的是”进程起来了”而不是”能处理请求了”, 那么”健康”和”可以继续”之间有一条未被验证的假设。 → 高度可疑。

B(两个写者):这一步之后有没有两个地方在写同一份状态? → 待查,但没有明显迹象。

C(修了实例):这类问题以前出现过吗? → 如果出现过而且每次都是”重跑一下”, 那么真正的问题从来没被修过。

D(声明未生效):那个”等待”的超时配置生效了吗? → 值得花两分钟验证,因为验证成本极低。

E(边界降级):健康检查和真实请求走的是同一条路径吗? 如果健康检查走内网直连而真实请求走代理, 两者测的不是同一个东西。高度可疑。

F(资源无界):有没有连接池、队列之类的东西在这时候还没准备好? → 待查。

G(本地≠远端):本地能复现吗? → 最便宜的检查,应该第一个做。

结论:七个问题里,A 和 E 高度可疑,G 最便宜。 顺序:先做 G(几分钟),再查 A 和 E(各半小时)。

如果 A 或 E 命中了,小节 4.11 提示去看 它的上游有没有 D —— 通常”健康检查测错了” 的背后是”健康检查的配置从来没被验证过”。

十五分钟的结构化排查,替代了一次漫无目的的”到处看看”。

4.23 什么不算一个形状

反过来划一次边界,因为”形状”这个词很容易被用得太宽。

一个具体的 bug 不是形状。”这个函数在空数组时越界”是一个缺陷, 它有明确的修法,修完就没了 —— 而形状描述的是一类失败会怎么躲过判定, 不是它长什么样。

一个技术选型的后果不是形状。”我们用了这个框架, 所以它的这个坑我们绕不开”是一个约束,不是一个形状 —— 换个框架它就消失了,而七个形状换任何技术栈都还在。

一个流程问题不是形状。”评审太慢”或者”没人写文档” 是组织现象;它们可能导致形状,但它们本身不是。 判据是:一个形状必须能被表述成”某个判定在什么条件下会给出错误的结论”。 表述不出来的,就不是。

这条边界之所以要划,是因为分类的价值来自它的排他性。 一个把所有问题都装得下的分类,等于没有分类 —— 它不会让你在下一次故障时更快找到方向,只会让你多一个标签可以贴。 七个形状能有用,正是因为绝大多数具体缺陷不属于其中任何一个, 所以当一次故障确实落进某一格时,那一格立刻告诉你该去查什么。

4.24 故障该被归到哪个形状

给一个可以照着走的判定顺序,因为一次故障经常同时沾上几个形状, 而归错了会把你引到错误的修法上。

先问”发现它花了多久”。 如果是立刻发现的(崩溃、红灯、 用户报障),那么它不是形状 A —— A 的定义就是它不产生信号。 如果它已经存在了很久才被发现,那么无论表象是什么, 形状 A 一定在场:某个本该发现它的判定失效了, 而那个失效才是最该被修的东西。

再问”这是第几次”。 第一次的话,按表象走正常排查; 第三次的话,形状 C 已经成立了 —— 而这时候正确的动作 不是再修一次,是问”为什么前两次的修复没有防住这一次”。

最后问”两侧的看法一致吗”。 如果一侧认为正常、 另一侧认为异常(本地对 CI、上游对下游、监控对用户), 那么它是 E 或 G —— 而这两者的区别在于分歧是发生在 一次请求的路径上(E)还是两个执行环境之间(G)。

三个问题走完,通常能定位到一到两个形状。 如果三个问题都答不上来,那本身是一个发现—— 它说明这次故障的基本事实还没有被弄清楚, 而在事实清楚之前做的任何修复都是猜测。

4.25 为什么形状 A 单独占一个位置

七个形状里,形状 A 的地位和另外六个不一样, 而这个不一样值得说清楚,因为它决定了投入的顺序。

另外六个形状描述的是系统里发生了一件坏事: 两个写者互相踩、配置没生效、边界处降级、资源用完了。 它们各自有一个客观的、和你的观测无关的存在 —— 不管你有没有在看,那件坏事都在那儿。

形状 A 描述的是你的观测本身坏了。 它不在系统里,它在你和系统之间。 所以它不和另外六个并列,它作用在它们上面 —— 每一个形状如果被及时发现,代价都是有限的, 而形状 A 让它们不被发现。

这个位置差异有一个实际推论:形状 A 的收益是乘性的, 另外六个是加性的。修好一个形状 B 的实例, 你少了一次故障;修好一个形状 A 的实例, 你让所有依赖那个探针的判断重新变得可信 —— 而那可能是几十条改动、几个月的时间。

这一点也决定了它排在所有建议的第一位。 小节 4.21 那张表里它的发现成本最高, 按性价比排它不该排第一 —— 而它排第一是因为它的最小防线(断言探针的工作量不为零) 恰好是那张表里成本最低的一项。 最难发现的形状,有一个最便宜的部分防线, 这是一个运气很好的巧合,而我把它用足了。

4.26 这七个形状和”AI 特有问题”的关系

这七个形状里, 有几个是 Agent 场景特有的?

严格说,一个都不是。 它们全都在人的团队里也存在, 而且存在了几十年。

但有三个在 Agent 场景下被显著放大:

形状 A(探针测错) —— 因为 Agent 的工作循环 把”检查通过”当作终止条件(小节 12.7), 而人会有一些说不清的怀疑。

形状 B(两个写者) —— 因为并行的 Agent 之间 没有任何非正式的协调(小节 7.10.1), 而人的团队里那部分工作由沟通承担。

形状 C(修了实例) —— 因为 Agent 不积累 (小节 5.2)。一个人修过三次同一类问题, 第四次会说”这个我们该从根上解决了”; 而 Agent 每次都是第一次。

4.26.1 而这有一个重要的推论

我讲的东西,大部分对纯人类团队也有效 —— 它们只是在 Agent 场景下从”有用”变成了”必需”。

所以如果你的团队还没有大规模用 Agent, 这些仍然可读 —— 只是那些”必须”可以读成”值得”。

反过来说:如果你的团队在用 Agent 之后 突然发现一批以前没有的问题,那大概率不是新问题 —— 是原本被人的积累和沟通兜住的老问题,现在没人兜了。

这个认识很实用,因为它意味着: 解法通常也不是新的 —— 而是把原本隐式的东西显式化。