根因不是发现的,是选择的
一句话
事故复盘里最常见的无意义争论是「到底哪个才是真正的根因」。这个争论没有答案,因为根因不是一个客观存在的点,是你在因果链上选择的一个切断位置。 选哪里,取决于哪个切断点最便宜且最可靠。
交付物不是一个点
写「根因是 X」这种句式,会逼着人把一条链压成一个词。改成链之后,思考质量会跟着变:
拿一次真实事故做例子。可见信号是 Kafka 消费积压,真因在 ClickHouse 的系统表里(完整复盘)。它的链是这样:
七个节点,也就是七个可能的切断点。链画出来之后,「根因是升级残留」和「根因是内存上限太低」这两种说法不再互相矛盾,它们只是选了不同的切断位置。
切断点的成本比较
这是画链之后立刻能拿到的收益。同一条链上,每个切断点的代价、可靠性、周期完全不同:
| 切断点 | 动作 | 代价 | 可靠性 | 撤回成本 |
|---|---|---|---|---|
| 升级不留残留 | 改升级流程,升级后审计并清理 | 高,跨团队,周期长 | 高,从源头断 | 撤回免费 |
| 僵尸表不长大 | 给内部日志表加 TTL,关掉最大的几张 | 低,配置层 | 高 | 撤回免费 |
| merge 不引爆 | 限制单次 merge 的内存与并发 | 中 | 中,只是降低概率 | 撤回免费 |
| 抬高内存上限 | 改服务器内存上限 | 最低 | 低,只是推后 | 撤回免费 |
| 消费者不死循环 | 改重试策略 | 中 | 低,治的是症状 | 一次中断 |
团队常常执着于修最深的那个原因,而最深的往往最贵、周期最长、还依赖别的团队。画出链之后,「修哪个」从一个立场问题变成一个成本比较问题。 这次真正落地的是配置层那一档,加 TTL 加内存封顶,同时在升级 runbook 末尾加一步残留审计。
反事实是唯一的因果判据
相关不是因果,时间上先发生也不是因果。唯一能把因果边和伴随现象分开的问题是:如果上游节点不发生,下游还会发生吗?
三种回答,三种处理:
| 回答 | 含义 | 处理 |
|---|---|---|
| 不会 | 上游是下游的必要条件 | 进主链 |
| 还会 | 上游是伴随现象 | 移出主链,留在时间线里 |
| 不知道 | 无法判定 | 标为未验证,不得进入主链 |
在上面那条链上跑一遍,会砍掉一条边。
事故当时的报错文本里带着 current RSS: 8.85 GiB,读起来像内存慢性泄漏,调查早期很自然会把它当成一条因果边:RSS 长期偏高,所以一次 merge 就撑爆了。反事实一问就露馅:如果 RSS 不是 8.85 而是基线的 2 GiB,那次 merge 还会杀掉并发插入吗?会。 因为杀掉查询的是一秒内冲到上限的瞬时分配,不是基线水位。RSS 偏高是伴随现象,它进时间线,不进主链。
这一步是 RCA 里最容易被跳过的一步。绝大多数复盘写的是「时间上先发生的事」,而不是「因果上必要的事」,所以修完之后事故照样复发,因为修的是伴随现象。
能实验的就实验,去掉一个条件再压一次;不能实验的靠推理,但必须标成未验证。未验证的边有几条,本身就是这份 RCA 的质量指标。
必要与充分要分开标
一条边通过反事实,只说明上游是必要条件,不说明它充分。
僵尸表存在,单独不会 OOM。还需要 merge 调度挑到它,以及内存上限恰好在那个量级。三个条件是合取关系,链图里 merge 调度被触发 和 服务器内存上限 两个节点就是从旁边并进来的另外两条必要条件。
合取关系一旦画出来,有一个非常实用的后果:
打断合取里的任意一项,事故就不再发生。所以防复发动作应该打在最便宜的那个必要条件上。
不是打在最"根本"的那个上。这也解释了为什么上面那张表里,配置层的 TTL 是性价比最高的选择:它打断的是「僵尸表体积累积到 TiB 级」这一项,而这一项和「改升级流程」在阻断效果上等价,成本差一个数量级。
未解释残余必须写出来
这是防伪根因最强的一个机制,也是人写的复盘里几乎从不出现的一节。
做法是穷尽式的:遍历这次收集到的全部观察,逐条检查当前解释是否覆盖,把覆盖不了的列出来。这一步不是数值比较,它要读懂每条观察说的是什么、再判断当前解释有没有涵盖它,所以它该留给会读语义的那一层,而不是下沉成一条规则。如果有三条观察解释不了,这个根因很可能不完整。
放到上面那个 case 上,这一节会逼出几个真问题。为什么是这几个租户先出现 lag 而不是全部?merge 挑到僵尸表是随机的还是有触发条件,如果是随机的,为什么之前几个月没有引爆过?这些问题的答案不影响本次修复动作的正确性,但它们决定了「这个解释是否完整」,也决定了几个月后同一条链会不会以另一种形态再来一次。
这一节 agent 比人更容易做好。 人会不自觉地忽略不符合自己叙事的观察,这是叙事引力,跟能力无关。agent 会穷尽地走完每一条证据去检查覆盖率。这是我认为 agent 在 RCA 上比人有结构性优势的少数地方之一。
反过来它也有一个特有的坑:它不知道自己没查什么。 只查了三个维度时,覆盖率 100% 毫无意义。所以覆盖率必须和「查了哪些维度、没查哪些」一起报,否则这个指标会自我恭维。
RCA 和 triage 的门禁方向相反
同一套假设管理机制,在这两件事上的用法是镜像的,混用会出问题。
| Triage | RCA | |
|---|---|---|
| 受压的是 | 时间 | 正确性 |
| 成功判据 | 止住了就算成功,可以不知道根因 | 必须定位到根因 |
| 主要失效模式 | 过早收敛,看到变更记录就锁定 | 伪根因,把相关当因果 |
| 门禁方向 | 防止停得太晚:连续几条假设被否就强制升级 | 防止停得太早:强制反事实、强制列残余、强制保留次强解释 |
| 证据可得性 | 数据还在线上 | 数据可能已经被 retention 删掉 |
| 结论去向 | 下一个动作 | 架构变更投资 |
最后一行解释了为什么 RCA 值得付更高的严格度成本。triage 的结论错了,代价是多花十分钟;RCA 的结论错了,代价是团队花一个季度修错地方,而且事故会再来。
次强解释要保留到最后,并写清为什么被排除。读复盘的人需要知道你考虑过什么,否则他会在会上把你考虑过又排除掉的那个再提一遍。
一个往前伸的设计
RCA 最大的实际困难不是分析,是证据已经没了。指标过了 retention、日志滚掉、pod 被重建、临时表被清理。等到事后坐下来做复盘,反事实检验需要的数据往往已经不可得。上面那个 case 之所以能破,靠的是引擎内部 1 秒粒度的 metric log,而那份数据也有自己的保留期。
所以 RCA 的设计要往前伸进 triage:事中就把关键查询的原始返回落盘,不只是把结论写进报告。
这件事成本极低,写文件而已;收益要几天后才兑现。这种"不做也不会立刻疼"的事,靠自觉一定不会做,必须写成硬要求。在我的 oncall harness 里这是一条验收标准,每次调查建工作目录时就建一个 evidence/,每次查询的原始返回按查询和时间窗命名落进去。
边界
这套设计我目前实装的是 triage 那一半,止损导向的部分。RCA 这一档还靠事后手写案例承担,因果链、反事实三态、覆盖率门禁都还是设计,没有代码。
另外反事实检验有一个诚实的天花板:分布式系统里很多边没法做实验。你不可能为了验证一条边就在生产上再造一次事故。这时候反事实只能是推理,而推理和证据的差别必须在文档里可见,这就是「未验证」这个状态存在的全部理由。承认一条边是推理出来的,比把它写得像证据要有用得多。