根因不是发现的,是选择的

一句话

事故复盘里最常见的无意义争论是「到底哪个才是真正的根因」。这个争论没有答案,因为根因不是一个客观存在的点,是你在因果链上选择的一个切断位置。 选哪里,取决于哪个切断点最便宜且最可靠。


交付物不是一个点

写「根因是 X」这种句式,会逼着人把一条链压成一个词。改成链之后,思考质量会跟着变:

拿一次真实事故做例子。可见信号是 Kafka 消费积压,真因在 ClickHouse 的系统表里(完整复盘)。它的链是这样:

flowchart TB A["CH 升级留下带数字后缀的旧系统日志表"] --> B["僵尸表体积累积到 TiB 级"] B --> C["后台 merge 挑到僵尸表的 parts"] M["merge 调度被触发"] --> C C --> D["内存一秒内从 2 GiB 冲到 21.6 GiB 上限"] L["服务器内存上限 21.6 GiB"] --> D D --> E["并发插入被 Code 241 杀掉"] E --> F["失败批次进入 30s sleep 加 rebalance 重试循环"] F --> G["多租户 consumer lag 单调上涨(可见告警)"]

七个节点,也就是七个可能的切断点。链画出来之后,「根因是升级残留」和「根因是内存上限太低」这两种说法不再互相矛盾,它们只是选了不同的切断位置。


切断点的成本比较

这是画链之后立刻能拿到的收益。同一条链上,每个切断点的代价、可靠性、周期完全不同:

切断点 动作 代价 可靠性 撤回成本
升级不留残留 改升级流程,升级后审计并清理 高,跨团队,周期长 高,从源头断 撤回免费
僵尸表不长大 给内部日志表加 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 这一档还靠事后手写案例承担,因果链、反事实三态、覆盖率门禁都还是设计,没有代码。

另外反事实检验有一个诚实的天花板:分布式系统里很多边没法做实验。你不可能为了验证一条边就在生产上再造一次事故。这时候反事实只能是推理,而推理和证据的差别必须在文档里可见,这就是「未验证」这个状态存在的全部理由。承认一条边是推理出来的,比把它写得像证据要有用得多。