| name | debugging |
| description | 遇到 bug、报错、崩溃或行为异常,需定位根因时。 |
Debugging
概述
用科学方法定位 bug 的根因,而不是凭直觉乱改碰运气。核心:调试是观察 → 假设 → 设计实验验证 → 缩小范围 → 定位根因的循环,每一步都有依据。改到"不报错"不算修好——那可能只是把症状盖住了,根因还在。
何时使用
- 遇到报错/崩溃/行为异常,要定位原因
- bug 不稳定复现,不知触发条件
- 调试卡住、改了好几处都不行
- 看了代码"没发现问题",但程序确实有问题
不该用:行为符合预期的"不是 bug"(先确认是不是 bug,别把功能当 bug 调);环境/部署问题(先排查是不是代码外原因)。
与相邻 skill 的边界:debugging 管定位根因(调查:复现、假设、二分、读堆栈),verify-and-fix 管修复与验证(改对:修病因不症状、防回归)。两者接力——debugging 找出"问题是什么、在哪",verify-and-fix 接手"怎么改对、怎么确认修好"。debugging 的终点(定位的根因)就是 verify-and-fix 的起点。
当 bug 报告太模糊时("页面打不开""功能不工作"却没给报错/复现步骤),先回到 clarifying-questions 的思路——要可观察的现象(报错信息、触发操作、环境、是所有情况还是特定情况)再分析。没有现象的调试就是盲猜。
核心内容
第一原则:先看报错,别跳过它直接猜
报错信息(异常类型、消息、堆栈、行号)往往直接包含根因线索,是调试最便宜的情报。最常见、最浪费的错法是不看报错就凭直觉改——明明堆栈第 3 行写着 Cannot read 'id' of undefined at line 42,却跳过它去猜"是不是网络问题""是不是缓存"。
读报错的顺序:
- 异常类型 + 消息:发生了什么(TypeError?NullPointer?超时?)
- 第一个你自己代码的堆栈帧:在哪发生的(行号 + 函数)——注意是"你的代码",不是框架/库内部的帧
- 触发上下文:什么操作/数据触发的
读堆栈的技巧:堆栈常被框架/异步包装得很难读,几个技巧帮你找到真正的根因帧:
- 跳过框架帧:堆栈顶部往往是一堆框架内部代码(React 调度、Express 中间件、ORM 反射),真正的根因在第一个属于你项目源码的帧——往下翻找到你认识的文件名/行号。
- 异步代码的堆栈可能不连续:
async/await、Promise、回调、事件循环的堆栈经常断开(一个错误在 setTimeout 里抛,堆栈却看不到触发它的代码)。现代运行时有 --async-stack-traces 或类似的异步堆栈支持,开启它;否则要在触发处手动打日志补全调用链。
- 错误被转发后原始堆栈会丢:如果错误被 catch 又重新抛(尤其改了消息或包了新异常),原始堆栈可能藏在
error.cause 或 originalError 里——别只看最外层,挖嵌套的 cause 链。
养成习惯:遇到 bug,第一件事是完整读一遍报错,而不是打开编辑器开始改。读不懂报错时,先查懂它(搜异常类型、读文档),别跳过。
先复现,再调试
不能稳定复现的 bug 几乎无法调试——你改了不知道有没有效,因为"不报错"可能是修好了,也可能是这次没触发。调试前先建立可复现:
- 找到触发 bug 的最小条件:什么输入、什么操作顺序、什么状态组合下必现?
- 最小化:剥离无关因素,直到只剩"做 X 就必崩"。最小复现让你能反复试验、验证修复。
不稳定复现的 bug(偶发)尤其要先攻克复现——它通常意味着有隐含条件没找到(并发时序、特定数据、资源竞争、时间相关)。找这个条件本身就是定位根因的关键。
反例:bug 偶发,你直接多加几个 try/catch 把可能出错的地方包起来"这样就不崩了"——错误被吞了看不见,但触发条件和根因一行没动,换个场景又炸,而且现在连报错都没了,更难查。
科学方法:假设 → 实验 → 验证
定位根因靠假设驱动,不是碰运气:
- 观察:报错是什么、何时发生、复现条件。
- 假设:"我猜根因是 X"——基于观察和代码理解提出具体、可证伪的假设(不是"大概是哪里有问题")。
- 设计实验:如果是 X 导致的,那应该观察到 Y(可验证的预测)。
- 验证:跑实验,看 Y 是否成立。成立 → 假设支持,继续深入;不成立 → 排除这个假设,换下一个。
- 缩小范围:每次实验排除一部分可能性,把根因锁定在更小的范围。
关键纪律:一次只验证一个假设。同时改多个变量,有效了也不知道是哪个起作用——这是 shotgun debugging(散弹枪式乱改)的典型。
什么样的假设是好假设:调试假设要具体且可证伪——能说"如果是 X 导致的,那应该观察到 Y",并且能设计实验验证 Y。
- 差的假设:"大概是缓存的问题""可能是哪里有 bug"——模糊、无法验证,等于没假设,只会让你乱试。
- 好的假设:"我猜是用户列表为空时 find 返回 undefined 导致后续 .name 抛错——如果是,那传一个空数组应该必现,传非空不报。" 具体(空数组触发)、可证伪(传非空应该不报)、能设计实验(构造两种输入对比)。
提不出具体假设,说明你对问题观察得不够——回到读报错、看运行时真实数据、建立复现,先把现象搞清楚。
二分法:范围太大时快速缩小
当代码量大、不知问题在哪时,用二分法(git bisect 是经典实现):
- 注释/禁用一半代码,看 bug 还在不在
- 还在 → bug 在剩下的一半里;消失了 → bug 在被禁用的那一半里
- 对剩下一半重复,每轮砍一半
二分法把"在 10000 行里找 bug"变成"log₂(10000) ≈ 13 次实验"。它不依赖理解代码,纯靠排除——当你读代码读不出问题时,二分是最有力的工具。
适用场景:回归 bug(之前好的现在坏了,用 git bisect 定位是哪次提交引入的)、不知问题模块的大型代码库、读代码陷入僵局。
回归 bug:用好"曾经正常"这个参照点
"之前好的,现在坏了"是最容易调的一类 bug——你有一个已知正常的版本作参照,省掉了"重建预期行为"的功夫。策略:
- git bisect 是首选:在"好的"和"坏的"提交之间二分,每轮 git 自动 checkout 中间提交让你测,log₂(N) 次就能锁定引入 bug 的那次提交。这比读 diff 猜快得多。
- 锁定提交后看 diff:那次提交改了什么,根因大概率就在 diff 里——范围从"整个代码库"缩到"一次提交的几处改动"。
- 想清楚"为什么之前没事":很多回归 bug 的根因是"旧代码依赖了一个隐含假设,新改动破坏了它"。问"这个改动为什么会引发这个症状"往往直接指向根因,比泛泛调试快。
区分"代码问题"和"运行时问题"
"看了代码没发现问题"不等于"代码没问题"——也可能是运行时问题,代码逻辑没错,但运行时的数据/环境/状态让行为出错:
- 代码问题:逻辑写错(看代码能发现)。
- 运行时问题:数据不符合预期(null、空、格式错)、环境差异(依赖版本、配置、权限)、状态问题(时序、并发、缓存残留)、外部依赖故障(网络、第三方服务)。
调试时两者都要查。如果代码读起来"没问题",转向查运行时:实际数据是什么样?打印/日志看一下真实值,而不是盯着代码猜。很多 bug 是"代码假设数据是 A,实际数据是 B"——光看代码发现不了,看真实数据立刻暴露。
常见错误
| 问题 | 修法 |
|---|
| 不看报错就凭直觉猜 | 第一件事完整读报错(类型/消息/堆栈/行号) |
| 用 try/catch 把错误盖住当"修好" | 那是掩盖症状,先定位根因(见 verify-and-fix) |
| 乱改试错(同时改多处) | 一次只改一个变量、验证一个假设 |
| 不能复现就硬调 | 先建立最小复现,否则改了也不知道有没有效 |
| 改到"不报错"就停 | 不报错≠修好,可能只是症状被盖住,查清根因 |
| 只读代码不看运行时数据 | 代码没问题可能是运行时问题,打印/日志看真实值 |
| 陷入循环(A↔B),继续硬改 | 停,回到稳定状态,换方法(二分/重新假设) |
| 范围太大读代码读不出 | 用二分法快速缩小,不依赖理解代码 |
| 回归 bug 逐行读 diff 猜 | 用 git bisect 在好/坏提交间二分,锁定引入提交再看那一次的 diff |