前沿洞见 · 方法论 · · 国科智飞 Gavin

90% 的修复在打地鼠:错信息不是根因的坐标

两次数据库故障,修了三次,最后发现问题从来不在报错的那一层。错信息是症状的表达,不是根因的坐标。

先说一件真事。我们有一个服务,用 SQLite 存状态,它在两周内出了两次故障:第一次报 database disk image is malformed,某个索引页自相矛盾;第二次集中报 disk I/O error。回头看,两次是同一个根因的不同表现——但第一次,我们只修了表层。

三次「修好了」

真相是:VM 进程通过 Docker 的 gRPC-FUSE 桥持有数据库的写句柄,与宿主机进程抢同一个 inode。两层锁管理器握手失败,把 EIO 直接抛给了 SQLite。

错信息为什么会骗人

disk I/O errordatabase disk image is malformed 这类报错,至少有四类根因会给出几乎一样的错误信息:

根因类型 表现 检测手段
磁盘满 / inode 满 任何写都失败 df -h / df -i
外部进程持锁 单个写失败 lsof <file>
文件系统卷层冲突 偶发失败 系统日志
跨虚拟化层锁竞争 偶发失败 lsof 查 VM / Sandbox 进程

前两类查得到,后两类在错信息里完全看不出来——必须追到进程层。

跨虚拟化层的特殊之处在于:宿主机上的两个进程直接竞争,反而不会出这个错,因为只有一层文件锁;只有跨了 VM 边界,才会出现「我以为我能写、但文件系统说我不能写」的握手失败。WAL 模式也救不了它——WAL 解决的是单进程内的读写阻塞,不解决跨进程锁竞争;它还会把冲突的裁决推迟到 checkpoint,让错误晚几分钟才浮出来。

这里有一条更一般的规律:**错误信息是被逐层翻译过的。**磁盘的 EIO 被文件系统接住,翻译成 errno;文件系统返回给 SQLite,翻译成「磁盘 I/O 错误」;SQLite 再写进日志,翻译成一条它自己看得懂的报错。每翻译一层,就丢失一层上下文——你看到的永远是最后一站的措辞,不是第一现场的事实。

我们前两次错在哪

  1. 看到页面损坏就以为是数据库内部问题,没追问一句:是谁在写它?写路径上还有哪些进程?
  2. 修好就放手。那是治标,根因没动,所以它一直在错,只是量少没爆。
  3. 以为 WAL 能救命(见上)。
  4. 以为「容器退了」就等于「VM 释放了」——在 macOS 上,两者的生命周期是解耦的。

为什么两天没人发现

事后看,中间那两天其实一直有信号:数据库持续在报 disk I/O error,只是量少而散。我们没发现,原因不在信号缺失,而在监控的口径:

这件事的教训可以推而广之:**监控要盯着路径,不只是盯着状态。**状态是结果,路径是原因。对低频但持续的错误,设一个会累计的阈值告警,比事后翻日志便宜得多。

四步挖到根因

第一步,给错信息归类:是确定性错误(每次都复现),还是概率性错误(偶发)?概率性错误,九成是外部环境或竞争问题,先别翻应用代码。判断方法很简单:固定输入多跑几次,看它是不是每次都在同一处倒下;如果是偶发,再看错误的时间间隔有没有规律——规律本身就指向某个外部节律。

第二步,记住错信息不指向自己:报错的是 SQLite,但犯错的不一定是 SQLite。错信息是「对内层症状的最近表达」,不是根因的坐标。

第三步,追到错信息生成的那一层之前:SQLite 报 EIO,是拿到了操作系统返回的错误;操作系统返回错误,来自文件系统层的 errno;再往下是系统调用、虚拟化层、磁盘驱动。每一层都可能把根因「翻译」成自己能理解的形态。具体动作就是反复问一句:「这个错误,是谁返回给谁的?」一直问到不能再问为止。

第四步,把「已修复」和「根因已消除」分开:前者意味着故障表不报错了,后者意味着根因的触发条件不再存在。两者可以完全独立。

挖根因也要算账

四步法不是要把每个故障都追到底。根因分析本身有成本,有些时候「先止血、记录、观察」才是对的。

判断值不值得挖,可以看四件事:

这也是「打地鼠」和「修系统」的分界线:打地鼠不丢人,丢人的是把打地鼠当成修系统。

一个排查清单

把上面的方法压成一张单子,故障来了照着走:

  1. 报错在说什么?把它当成症状,而不是结论;
  2. 是确定性还是概率性?概率性先查环境和竞争;
  3. 谁在用它?谁在抢它?资源竞争永远先查持有者;
  4. 这个错误是谁返回给谁的?逐层往生成层之上追;
  5. 修完之后,触发条件消失了吗?
  6. 怎么证明根因消除了——能不能构造一次复现,验证它不再出现?

复盘怎么写才不浪费

这个案例最后留下来的最有用的东西,不是那次修复,而是一份写清楚的复盘。复盘不是检讨,也不用写得像事故报告,它只需要回答四个问题:

  1. **触发条件是什么?**不是「报了什么错」,而是「什么情况下这个错会出现」——这次是「跨虚拟化层的第二个写句柄存在时」;
  2. **这次做了什么修复?**止血动作和根因动作分开写,避免下次又把止血当成根治;
  3. **怎么验证根因消除?**写清楚验证方法,让下一个人可以重复验证;
  4. **下次的判断规则是什么?**比如「SQLite 报 I/O 类错误,第一动作加一条 lsof」——把一次教训变成一条规则,才算真正沉淀。

没有第四条的复盘,只是把故事讲了一遍。

这件事和 AI 陪跑是同构的

客户报问题,90% 给的是症状:

不会挖根因的陪跑,是高级客服;能挖根因的陪跑,才配叫合伙人。具体动作也同理:先问症状从什么时候开始、是否稳定复现、最近什么变了,再决定往哪一层追,而不是客户说哪就修哪。

判断标准很简单:修完之后,故障的「触发条件」是不是也跟着消失了?消失了,才是根因消除;没消失,就还在等下一次随机爆炸。

一句话总结

错信息是最近症状的表达,不是根因的坐标。把「修好了」和「根因没了」分开看,是工程师和客服的分水岭。


本文根据 2026 年 6 月两次真实故障复盘整理。