Skip to content
Published on

像科学家一样调试:假设驱动的调试

分享
Authors

引言 — 调试不是猜测

bug 出现了。生产环境里支付偶尔会失败。复现不出来,日志含糊不清,截止日期就是今天。这种时候,很多开发者会这样做:盯着代码看,把「这里看起来可疑」的那一行改掉,再跑一遍,不行就再改另一行……我把这种做法称为霰弹枪式调试(shotgun debugging)——朝四面八方开枪,总有一发能中。

问题是,这偶尔真的管用。于是它就变成了习惯。可是靠运气修好的 bug,你并不知道它为什么被修好了;不知道为什么被修好,下次它就会再回来。更糟的是,在「修复」的过程中,你往往还顺手动了另外三处本来好好的代码。

这篇文章的主张很简单:调试是一门科学。 科学家理解自然的方式——立一个假设,预测这个假设若为真会发生什么,用实验去验证,再根据观察结果修正假设——原封不动地照搬到代码上就行。靠方法抓 bug,而不是靠运气。

科学方法 — 假设、预测、实验、观察

遇到 bug 时该走的循环,恰好就是科学方法本身。

  1. 观察(observe):发生了什么?到底哪里不对?
  2. 假设(hypothesize):提出一个关于「为什么」的、可验证的解释。
  3. 预测(predict):如果这个假设成立,那么我做 X,就应该观察到 Y。
  4. 实验(experiment):去做 X。
  5. 观察:Y 真的出现了吗?出现了,假设就更可信;没出现,就丢掉或修正这个假设。

核心在第 3 步,预测。好的调试和坏的调试,分野就在这里。改代码之前,你应该能大声说出「改了这个之后应该会发生什么」。没有预测就去改代码,那不是实验,只是赌博。

举个具体例子。从「支付偶尔会失败」这个观察出发。

  • 糟糕的做法:「说不定是超时。把超时值调大一点吧。」(没有预测,也不知道为什么要调大)
  • 好的做法:「假设:当卡组织的 API 耗时超过 3 秒时,我们的客户端在 2 秒就把连接切断了。预测:如果这成立,失败请求的日志里一定会记录到超时错误,而所有成功的请求都应该在 2 秒以内。实验:把失败/成功请求的响应时间分布提取出来看看。」

第二种做法即便实验失败了,也能学到东西。如果失败的请求其实是 500 错误而不是超时,假设是错的,但现在你知道原因不在卡组织那边,而在自己的服务器上。错的假设也会给你信息——这正是这套方法的力量所在。

假设必须是可证伪的(falsifiable)。「网络就是很奇怪」这种没法验证,所以不算假设。「只有特定地区的请求会失败」可以验证,所以是假设。

对问题空间做二分搜索

抓 bug 最强的单一技巧是二分搜索(binary search)。它不只是用来在有序数组里查值的——原封不动地拿来找问题的成因照样好用。

原理是这样的:bug 出现的地方(症状)和代码正常的地方(输入)之间,某处就藏着原因。取它们之间的一个中点,问一句「到这里为止还正常吗?」,一次实验就能把搜索空间砍掉一半。反复几次,哪怕是几千行代码,大约十次实验就能缩小到元凶身上——毕竟 2 的 10 次方是 1024。

具体能拿来做二分搜索的轴有好几个。

  • 代码路径:如果请求经过 A → B → C → D,就在 C 这个点把值打印出来。值正常,原因就在 C~D 之间;不正常,就在 A~C 之间。
  • 时间(提交历史):昨天还能用,今天不行了?对这期间的提交做二分搜索。(这正是接下来要讲的 git bisect。)
  • 输入数据:一份 10 万行的输入触发了崩溃?截成 5 万行看还复不复现。复现了,问题数据在前半部分;没复现,在后半部分。
  • 配置/依赖:把功能开关关掉一半试试,或者把依赖去掉一半试试。

二分搜索之所以强大,是因为每次实验都被设计成能给出最大信息量。用信息论的话说,一个能把空间一分为二的问题,一次能提取整整 1 比特信息。随便找个地方戳一下的实验,给出的信息量要少得多。

最小可复现示例 — 把 bug 关进笼子

「我复现不出来」是调试中最常见的一堵墙。这里的决定性工具是最小可复现示例(minimal reproducible example,MRE)。

MRE 是能触发 bug 的最小代码片段。搭建它的过程本身就是调试。你把整个应用里和 bug 无关的部分一点点去掉,直到削减到仍能复现 bug 的最小状态为止。在这个过程中,会发生以下两种情况之一。

  1. 去掉某个东西之后 bug 消失了 → 刚去掉的这个东西和成因有关。 你找到嫌疑人了。
  2. 全部去掉之后只剩下 20 行代码,却依然能复现 → 现在你只需要盯着这 20 行看就行了。问题一下子变得无限好处理。

搭建 MRE 时的规则是这样的:

  • 去掉外部依赖:把数据库、网络、文件系统都换成写死的值。bug 依然出现,说明外部不是原因。
  • 把数据最小化:100 条记录里 1 条就能复现,那就只留这 1 条。
  • 让它自成一体:别人应该能直接复制粘贴、原样跑起来。做到这一点,不管是找同事帮忙还是提交 issue,都会容易得多。

搭建 MRE 的过程中,经常会遇到 bug 自己消失了的情况。那不是失败。它意味着你在删减的过程中越过了成因所在的地方,把最后删掉的那个东西放回去,嫌疑人就会现身。

git bisect — 哪个提交是元凶

「上周还能用,现在不行了。」针对这种情况的工具是 git bisect——它是 git 里一颗隐藏的宝石,能对提交历史自动跑二分搜索。

原理就是前面讲的二分搜索,应用在时间轴上。你告诉它一个曾经正常的旧提交(good)和现在有 bug 的提交(bad),git 就会把你带到两者中间的那个提交上。你在那里测试 bug 是否存在,把结果告诉它,git 就会带你去剩余区间的中点。哪怕有 1000 个提交,大约十步就能缩小到唯一的元凶提交。

# 开始二分搜索
git bisect start

# 当前提交有 bug
git bisect bad

# 3 周前的这个提交是正常的
git bisect good v1.4.0

# 现在 git 会把你带到中间的某个提交。
# 在这里测试 bug,然后根据结果:
git bisect good   # 这个提交是正常的
# 或者
git bisect bad    # 这个提交也有 bug

# 反复这个过程,git 最终会指出元凶提交:
# "abc1234 is the first bad commit"

# 结束后回到原来的位置
git bisect reset

真正的魔法在于自动化。只要有一个能判定 bug 是否存在的脚本(比如失败时返回非零退出码的测试),git bisect run 就能不需要人工介入,把整个过程跑完。

git bisect start
git bisect bad
git bisect good v1.4.0
# 脚本 exit 0 判定为 good,其他情况判定为 bad
git bisect run ./test-for-bug.sh

几秒钟后,元凶提交就出来了。看一眼那个提交的 diff,原因通常一目了然。git bisect 要跑得顺,提交需要小而且每个都能构建——这也是应该把提交拆细的另一个理由。

如果想把 git bisect 这类 git 工作流练到肌肉记忆里,可以去Git 实验场安全地练习提交、分支和二分搜索。

要验证假设,就得观察系统内部。观察的工具大致分三种。没有哪一种是唯一正确答案,要看情况来选。

最原始、也最被低估的工具。埋一个 print(或 console.log),用肉眼看值。它很容易被人瞧不上,但其实非常强大。

  • 优点:到处都能用,不需要任何配置。能一眼看清一个值随时间变化的走向(很多时候比在调试器里一步步跟踪更快地看清整体流程)。在异步、多线程、分布式这类调试器不好暂停的地方尤其有用。
  • 缺点:得动代码。忘了删就会把日志弄得一团糟。可能需要重新编译、重新部署。

打印时的诀窍是:给打印的内容打上标签。 print("after validation, x =", x)print(x) 要好得多。如果有好几个值都重要,就一起打印出来,方便看它们之间的相关性。

调试器

打一个断点,让执行暂停,查看那一瞬间的完整状态(所有变量、调用栈),再一行行地单步跟踪的工具。

  • 优点:不用改代码。在暂停的那一点能看到一切。条件断点(「只在 i == 4821 时暂停」)、单步进入/跳过、查看调用栈,甚至能在运行中修改变量。理解复杂状态或很深的调用栈时无可替代。
  • 缺点:需要配置。异步或依赖时序的 bug,可能在暂停的那一刻条件就变了(海森堡 bug),导致复现不了。生产环境里大多用不了。

日志

print 的成年版本。把结构化的日志连同级别(DEBUG/INFO/WARN/ERROR)一起永久留存下来。

  • 优点:在生产环境里持续运行。可以事后调查已经过去的事件(调试器只能看到正在发生的事)。用级别控制噪音,结构化之后还能搜索、聚合。
  • 缺点:得提前埋好。真正需要的那个点上恰好没有日志,就没用。量一大,就变成成本和噪音。

总结一下:钻研可复现的本地 bug 的复杂状态,用调试器;快速梳理流程,或者在异步/分布式环境里,用 print;在生产环境里调查已经过去的事件,用日志。能在三者之间灵活切换的人,才是真正会抓 bug 的人。

真正去读错误信息

这事听起来太理所当然了,可现实中却惊人地少有人真正做到。把错误信息从头到尾真正读一遍。 别扫一眼就跳过,别无视,也别「啊,又是这个」地一带而过——真的去读它。

错误信息和堆栈跟踪,是 bug 自己交代自己在哪里、为什么死掉的自白书。可很多开发者一看到红色文字,眼睛就闭上了,直接切进猜测模式——明明答案就写在那里。

读的时候要提取的信息:

  • 确切的异常类型和消息:是 NullPointerException 还是 IndexOutOfBoundsException,完全是两回事。消息里的每一个词都是线索。
  • 文件和行号:精确告诉你在哪里出的问题。
  • 堆栈跟踪:最上面是出问题的地方,往下是调用它的那条链。找到「自己写的代码」第一次出现的那一行(通常那才是真正的起点,而不是库内部深处)。
  • 「Caused by」链:真正的根本原因,往往就藏在最底下那个「caused by」后面。

把错误信息里的确切措辞原封不动地复制去搜索,就能解决一半的问题。不过如果消息里混着文件路径或 ID 这类你自己环境特有的值,搜索时把这部分去掉。

"从来不是编译器的错"

调试圈子里有句老话:"It's never the compiler."(从来不是编译器的错)。编译器(或者解释器、运行时、标准库、知名框架)的锅,几乎从来轮不到它们背。

抓不到 bug 又沮丧的时候,这种念头会慢慢冒出来:「这不会是语言本身的 bug 吧?」「编译器是不是把优化搞错了?」「这个库是不是有问题?」有可能。但从概率上说,一个成熟的工具是数百万人每天在敲打、验证的东西。你新写的这 100 行代码,和十年间被执行了数亿次的编译器,到底谁更可能是 bug 所在?

这件事之所以在实践中很重要,是因为一旦得出「是工具的锅」这个结论,你就会停止继续搜索。而真正的原因十有八九就藏在自己的某个假设里,一旦把锅甩给别人,你就再也不会去看那里了。所以默认姿态应该是「元凶就在我的代码、我的假设、我的理解里」。极少数情况下确实是工具本身的 bug,但那个结论只应该在把自己这一侧彻底查完之后,最后才下。

同样思路下有个常见的陷阱:正则表达式「为什么就是匹配不上」的时候,大多数情况下也不是正则引擎的问题,而是你的表达式写错了。这种时候,把表达式和输入丢进正则表达式测试工具,实际看看它到底匹配了什么,比在脑子里空想快上一百倍。

橡皮鸭 — 大声说出来

最后一个技巧听起来有点滑稽,但真的管用。橡皮鸭调试法(rubber duck debugging)。在桌上放一只橡皮鸭,把问题一行一行地大声讲给它听。

为什么这招有用?用眼睛读代码时,大脑会自动跳过「这里显然没问题」的部分。bug 恰恰就藏在那些被跳过的假设里。可是要讲给别人(鸭子)听,你就得把这些假设用语言说出来,而一旦说出口,就会卡在「咦,这真的成立吗?」上。被迫去解释,会把隐含的假设变成明确的东西。

这正是「向同事求助讲到一半突然说『啊没事了我刚想明白了』然后挂断」这种现象背后的真相。同事什么都没说。是你自己在准备解释的过程中自己发现的。鸭子免费,而且耐心无限,所以在叫同事之前,先讲给鸭子听。

想让效果更好,就讲得非常具体、从最基础的地方讲起。不要说「这个函数把用户取出来,然后……」,而要说「这个函数接收一个 user_id 整数作为参数,往数据库发这条查询,把结果的第一行映射成这个对象,然后……」这种程度。讲得越具体,藏起来的假设就越容易冒出来。

综合 — 把一个 bug 追到底

把前面这些拼片编成一条完整的流程。回到最初那个 bug:「支付偶尔会失败」。

  1. :把失败请求的错误日志真正读到最后。PaymentGatewayTimeoutException 出现在堆栈跟踪最底部的「caused by」后面。
  2. 假设:卡组织的 API 慢的时候,是我们自己先把连接切断的。
  3. 预测:如果成立,失败的请求应该全都死在接近我们超时值的地方。
  4. 观察:把失败请求的响应时间提取出来。全都集中在 2000ms 左右。假设更可信了。
  5. 二分搜索(时间):问「从什么时候开始的?」跑一次 git bisect。结果指向那个把超时值从 5 秒降到 2 秒的提交。
  6. 最小复现:用一个把卡组织延迟 2.5 秒的 mock,在本地复现。失败被复现出来了。
  7. 修复与验证:把超时值调高,或者加上重试。预测:这样一来,即便加上 mock 的延迟也应该会成功。实验。成功了。

每一步都有假设和预测,每一次实验都在缩小搜索空间。运气一滴都没有。而且最重要的是,你精确地知道为什么它被修好了。这就是「像科学家一样调试」的含义。

结语

擅长调试和不擅长调试的人,差别不在于知识量,而在于方法。擅长的人不会盯着代码瞎猜。他们观察,立一个可证伪的假设,做预测,把问题空间一点点砍半,并且真正去听错误信息在说什么。

下次遇到 bug 时,在改代码之前先问自己:「我的假设是什么?如果改了这个,我预测会发生什么?」如果答不上这两个问题,你还没准备好去做实验。等你能答上来的那一刻,你就不再是赌徒,而成了科学家。

参考资料