代码调试卡了三个小时找不到问题?聊聊我记了 47 条笔记后总结的反直觉排查顺序
上周三晚上十点多,我盯着一段两百来行的 Python 脚本,报错是 KeyError: 'user_id'。我把所有给这个 key 赋值的地方翻了两遍,字段名拼写是对的,类型也没问题,甚至加了断言确认了上游返回的东西确实有 user_id。最后发现是我本地 .env 少了一行配置,代码走了兜底分支返回了一个空 dict。改完看时间,22:47,前后搭进去快两小时。
那天我翻了自己的调试笔记,从年初到现在记了 47 条。闲着数了一下,其中有 9 条最后定位到的原因,和我一开始的猜测完全没关系——不是「差不多」,是真的八竿子打不着。这个比例大概五分之一,比我预期的要高不少。所以我现在有个习惯,一旦在一个地方卡超过二十分钟,就先停下来问自己:我是不是在验证一个根本没被证实的假设?
先说个反直觉的:断点有时候会骗你
我猜大部分人看到「调试」两个字,第一反应就是打断点,然后在变量面板里翻。这没错,但它有个前提——你的程序是单线程、同步、无竞态的。一旦涉及并发、超时、消息队列,断点本身就可能变成 bug 的一部分。
去年调过一个 Go 写的订单服务,本地怎么跑都不出错,压测一千次也没事,线上每天丢两三笔。我在关键位置打了断点,暂停状态下看变量,全是对的,两个 goroutine 各干各的谁也不碍谁。后来把断点删了改成 log.Printf 打时间戳,才发现两个 goroutine 在 3 毫秒的窗口里同时读了一个 map 做读改写的操作。断点把线程串行化了,这个窗口自然就消失了。
这类问题有个专门的名字叫 Heisenbug,观察行为本身改变了被观察对象。处理它的办法反而很土——用日志,不要用断点。日志写入是原子的小操作,开销在微秒级别,对时序的扰动比暂停一整个线程小好几个数量级。Go 的话还可以直接跑 go test -race ./...,或者编译时加 -race 标志,开销大概是正常运行的 5 到 10 倍 CPU、5 到 10 倍内存,但在 CI 里跑一次是完全值得的。Java 那边对应的手段是 jstack <pid> 抓线程栈,连续抓五次比对,能看出哪几个线程卡在同一把锁上。
时间轴,比代码本身更值得先看
我有段时间养成一个坏毛病,一遇到 bug 就一头扎进代码里,从入口函数开始往下读。读代码这事很耗人,一个中等规模的服务,主流程读一遍一小时起步,而且读着读着注意力就散了。后来我强迫自己先做一件事:确定这段代码最后一次正常是什么时候。
这不是「什么时候发现的问题」,是「哪个版本、哪个 commit 开始不正常的」。这两个问题的答案经常差很远。如果你用 Git,那就是 git bisect:
git bisect start
git bisect bad HEAD
git bisect good v2.3.1
git bisect run ./scripts/smoke-test.sh
前两行划定范围,第三行如果测试脚本能自动判断通过还是失败,那就是全自动的,二分过程每次自动 checkout 中间的 commit 跑一遍,1000 个 commit 最多 10 次就定位到具体那一个。我见过一次同事在代码里找了一整个下午,最后用 bisect 十分钟搞定,问题是某个依赖在 package.json 里被顺手升了个小版本号。
如果测试没法自动化,那就手动二分。范围从 1000 个 commit 缩到 1 个,中间隔了 log2(1000) ≈ 10 步,也就是十次手动验证。听起来多,但每次验证也就一两分钟,总共不到半小时。比漫无目的地读代码快太多了。
把「确认一件事」的成本压到最低
调不出来的时候,很多时候不是不知道该查什么,而是查一个假设太慢了。改一行代码、重启服务、点几下页面、看结果,一轮下来五分钟。五分钟验证一个假设,一晚上只能验证二三十个,这效率当然低。
所以我的原则是尽量让验证成本降到十秒以内。几个具体做法:
日志加唯一标记。不要写 console.log(userId),太容易和别的输出混在一起。写 console.log('[DBG-7f3a]', userId, cart),然后 tail -f app.log | grep DBG-7f3a。这样一次可以在五个不同位置打标记,同时观察,互不干扰。JS 里有个更省事的,Node 的 --inspect 加 debugger 语句,Python 3.7 之后直接 breakpoint() 就进 pdb,不用记 import pdb; pdb.set_trace() 了。Rust 里 dbg!(x) 会连值和表达式一起打出来,比 println! 多给一个文件行号。
环境差异先排除。node -v、python -V、go version、pip freeze | sort,两边机器各导一份 diff 一下。我之前那个 .env 的问题就属于这一类,代码完全正确,环境不对。这种坑的特点是:本地必现、CI 上不出现,或者反过来。凡是符合这个特征的,别读代码了,先比环境。
还有个小技巧是二分注释法。一行一行读代码找 bug 很慢,但把代码块一段一段注释掉再跑,看问题在哪一段消失,速度会快很多。一个大函数砍成两半,二十次以内能砍到具体那一行。
一份我自己在用的排查顺序
写下来大概是这样的,不一定适用所有场景,但可以当个兜底:
- 先能稳定复现。复现不了的程序,等于没有 bug,你只是在猜测。把最小复现步骤写成一条能直接粘贴执行的命令。
- 记录环境。版本号、系统、依赖锁文件。这一步花两分钟,能省掉大量「在我机器上是好的」。
- 判断是代码问题还是环境问题。同一个二进制换台机器跑,或者同一个环境换份代码跑,二选一。
- 时间维度二分。git bisect,把范围从一堆 commit 缩到一个。
- 空间维度二分。注释法、日志法,把范围从一整个模块缩到一行。
- 如果以上都排除了,那大概率是我对某个 API 的行为理解错了,去翻文档,不要靠猜。
说实话,这套东西不新,也没什么技术含量。但我觉得调试这件事,分水岭真不在于你会不会用条件断点、会不会看火焰图,而在于你愿不愿意早点承认自己最初的判断是错的。我笔记里那 9 条和猜测无关的记录,每一个事后看都明显得不行,但当时就是在心里给自己定了个方向,然后一门心思往那个方向挖。
下次卡住的时候,不妨先退一步,看看自己是不是在挖一口本来就不该挖的井。