systematic-debugging — 先找根因,再动手
概述
随手改一处试试看,既浪费时间又制造新 bug;快速补丁只是盖住了真正的问题。
核心原则:先找到根因,再提修法。只修症状就是失败。
没做完根因调查,不提修法
第一阶段没走完,就不能提修法。
何时用
任何技术问题:测试失败、bug、意外行为、性能问题、构建失败、集成问题。
越是想跳过越要用:时间紧、「就改一下试试」看着很显然、上一次修没生效、问题看着很简单。系统排障比瞎试更快——简单 bug 也有根因。
四个阶段
一个阶段做完再进下一个。
第一阶段:根因调查
- 仔细读错误信息——完整堆栈、行号、错误码。答案常常就在里面。
- 稳定复现——精确步骤、可靠触发。复现不了 → 继续收集数据,不猜。
- 看最近改了什么——git diff、最近提交、新依赖、配置、环境。
- 在组件边界收集证据(多组件系统:CI → 构建 → 签名,API → 服务 → 库):提修法之前,先记下每个组件的进出,跑一次,让证据说明在哪一段坏了——再去查那一段。
- 沿数据流向上追——坏值从哪来?沿调用链一路追到源头;在源头修,不在症状处修。完整技法见本目录 root-cause-tracing.md。
第二阶段:模式分析
- 在同一代码库里找类似但能工作的代码
- 通读参考实现——略读只能得到片面理解
- 列出能工作的和坏掉的之间每一处差异,再小也列,不假设「这个不可能有关系」
- 弄清涉及的依赖、配置与假设
第三阶段:假设与验证
- 陈述一个单一、具体的假设:「我认为根因是 X,因为 Y」
- 用最小改动验证它,一次只动一个变量
- 没生效 → 提新假设;不在旧改动上叠新改动
- 有不懂的地方就说「我不理解 X」,去查——不装懂
第四阶段:实施
- 先有失败的复现:能自动化的,先写一个能复现的失败测试(用
test-driven-development)——最简复现,修之前必须存在;不能自动化的,用第一阶段的精确步骤复现症状,修完要能展示症状消失。 - 只做一处修复——针对根因的一个改动,不带「顺手」改进,不夹带重构。
- 验证:测试通过、其它测试没坏、症状确实消失;交回物里如实写跑了什么(没跑的写「未执行」)。
- 修了没用? 停。数一数尝试次数:不到 3 次 → 带着新信息回第一阶段;3 次及以上 → 质疑架构。
修 3 次都失败:质疑架构
每次修复都在别处冒出新问题,或每次都要「大改」,说明模式本身可能是错的。3 次是停止试错、升级诊断的程序阈值,不是「架构已经错了」的结论——它只说明继续同样地修不划算。停下,和用户讨论,再决定要不要继续修。
计数按同一个根因算:三次尝试修的是同一处,才构成这个信号;三个互不相干的失败累加起来不是。
红旗——停下,回第一阶段
- 「先快速修一下,之后再查」「先把 X 改了看看」
- 一次改多处;跳过失败测试
- 「大概是 X」「我不完全理解但这样可能行」
- 还没追数据流就提方案
- 已经试了 2 次以上,还想「再试一次」
- 用户在质疑你的做法(「验证过吗?」「别猜了」)——这是纠偏,不是噪音
| 借口 | 实际 |
|---|---|
| 「问题很简单,不用走流程」 | 简单问题也有根因;流程对它们本来就很快 |
| 「紧急,没时间走流程」 | 系统排障比瞎试更快 |
| 「先确认修法有效再补测试」 | 没测试的修复留不住。先失败测试才证明修好了 |
| 「参考太长,我照着模式改」 | 片面理解必出 bug。通读 |
| 「再试一次」(已试 2 次以上) | 同一根因 3 次没修好 = 停下升级,别再修症状 |
速查
| 阶段 | 做什么 | 完成标志 |
|---|---|---|
| 1. 根因 | 读错误、复现、看改动、收证据 | 知道什么坏了、为什么 |
| 2. 模式 | 找能工作的例子、逐项比对 | 差异列清 |
| 3. 假设 | 单一理论、最小验证 | 被证实或被替换 |
| 4. 实施 | 失败复现、单一修复、验证 | bug 消失、测试通过 |
查不出根因时
真是环境或时序问题:记录查了什么,做合适的处理(重试 / 超时 / 清晰的错误信息),加日志以便下次追。但先老实回答一句:四个阶段真的都走完了吗?「查不出根因」多数时候是调查没做完。
配套技法
本目录:root-cause-tracing.md(沿调用链向上追到源头,含定位「哪个测试污染了环境」的二分法)、defense-in-depth.md(找到根因后的多层校验)、condition-based-waiting.md(用条件轮询替换随手写的等待时长)。
相关技能:test-driven-development(第四阶段的失败测试)。修完在交回物里只写实际跑过的验证。