代码调试方法论:为什么你 80% 的时间花在了复现上?从 print 到 rr 的 4 个层次(附 git bisect / Go race 实操)

🔑 关键词:代码调试, git bisect, 时间旅行调试, 断点调试, 并发调试

📖 摘要:把调试拆成复现、观察、推理、验证四层之后,我发现大部分工具和教程都教错了地方。这篇用一次真实的 flaky 测试排查过程,讲清 print、断点、git bisect、rr、eBPF 各自的成本和边界。

去年 11 月,我们 CI 上有个测试开始飘。不是必挂,是那种 2% 上下的失败率——一晚上跑 3000 多个 job,红的也就五六个,点进去重跑一次就绿。

图片

一开始大家都当它是环境抖动。我当时也这么想,直到它连续三天挂在我负责的那个 PR 上。

本地复现?我把那个测试循环跑了 100 次,100 次全过。这就是调试里最难受的一类问题:你连失败都拿不到。

后来我干了件挺笨的事:写了个 shell 脚本,把那个测试跑 50 遍,只要挂一次就 exit 1,然后扔进 git bisect run。

git bisect start
git bisect bad HEAD
git bisect good v2.14.0
git bisect run ./scripts/flaky_hunt.sh

那个区间大概 1800 个 commit。每次迭代要跑 50 遍测试,一遍 8 秒左右,所以单次迭代差不多 7 分钟——但它在后台跑,我该干嘛干嘛。11 次迭代之后(log2(1800) ≈ 10.8),它指到了一个我完全不认识的 commit 上:一次依赖的 minor 版本升级。升级日志里有一行「调整了内部连接的超时重试策略」,没人当回事。

图片

调试的成本大头不在「想」,在「拿到现场」

我们平时聊调试,聊的几乎都是推理那部分——怎么读栈、怎么缩小范围、怎么形成假设。但真到生产级的问题上,你花掉的时间分布是这样的:

  1. 复现:让 bug 稳定出现。flaky 问题里这一项能占掉 70% 以上。
  2. 观察:让程序在出错那一刻,把该看的变量现场交给你。
  3. 推理:看到现场之后,想明白为什么。
  4. 验证:改完怎么证明它真的好了,而不是被别的东西掩盖了。

几乎所有调试技巧和工具,解决的其实都是第 1 和第 2 项。但我们的注意力、教程和面试题,全扑在第 3 项上。这是个挺要命的错位——你会看到有人能把一个空指针的调用栈讲得很漂亮,却在面对「10% 概率复现」的问题时束手无策。

按这个框架给常见手段排个序,差别非常明显:

图片

手段 复现能力 观察能力 代价
print / logging 无 弱,必须提前埋 极低
断点 + 单步 无 强 低,仅限单进程
日志点 / 条件断点 无 中 低
git bisect 强 无 纯时间换
rr / TTD 极强 极强 需要环境权限
eBPF / bpftrace 弱 强,生产可用 需要 root 或 CAP_BPF

注意最右边那列。git bisect 一个调试器功能都没有,它纯粹是在帮你把「复现概率 2%」变成「复现概率 100%」,但就这一点,比我那天用一整晚断点有用得多。

时间旅行调试:为什么它是降维打击,以及为什么你还没用上

rr 是 Mozilla 做的,Linux x86-64 上能录能放。录完之后你可以 reverse-continue、reverse-step,往回走。

rr record ./my_service --config prod.yml
# 崩了
rr replay
(rr) break my_file.c:412
(rr) continue
(rr) reverse-continue

图片

reverse-continue 这个命令用过一次就很难回去。它能直接跑到「上一次这行代码被执行之前」,意味着你可以从崩溃点倒推回哪一次写入把变量搞坏的。传统调试是正向逼近,这是反向定位——听起来像小技巧,实际是把一个 O(n) 的搜索过程变成了 O(log n)。

代价方面,rr 录制大概 1.2x 到 2x 的性能损耗,比 GDB 自带的 record full 好太多(那个能慢几个数量级,官方文档自己都劝你别常用)。但它依赖 ptrace,容器里得加 --cap-add=SYS_PTRACE 和 --security-opt seccomp=unconfined。我们当时是在 K8s 里单独开了一个带这些权限的 pod 来录,跑完就把 pod 删掉。

Windows 上有 WinDbg 的 TTD,Java 这边有 JFR 的一部分能力,但生态整体还是零散的。

不过我想泼一盆冷水:时间旅行调试卖给你们的其实不是「反着走」,是「可复现」。 它 90% 的价值在于你可以无限次重放同一个崩溃现场,随便加断点、随便换观察角度,不用担心重跑一次 bug 就没了。反着走只是顺带的赠品。想清楚这一点,你就知道该不该为它折腾那套权限配置了。

并发 bug 会被调试器本身赶跑

图片

这里有个反直觉的事:ptrace 会停住被调试的线程。你在一个多线程竞态里下断点,等于人为改变了调度时序,bug 就消失了。这类 heisenbug 我踩过不止一次。

所以并发问题上的主力工具基本不是调试器:

  • Go:go test -race ./...。官方说法是内存涨 5 到 10 倍,执行时间涨 2 到 20 倍。别在 CI 全量跑,挑有并发的包跑就行。
  • C/C++:ThreadSanitizer,编译加 -fsanitize=thread -g -O1。
  • 内存泄漏:Python 用 tracemalloc.start(25) 拿 25 帧回溯,再用 snapshot.compare_to() 做快照对比;Go 用 go tool pprof http://localhost:6060/debug/pprof/heap 看 inuse_space。

我之前遇到过一个 Python 异步服务,内存每 24 小时涨 200MB 左右,撑到第 3 天必被 OOM kill。tracemalloc 一开,对比两个快照,涨的全是 asyncio 的 Task 对象——一个三方 SDK 在每次请求里 create_task 却没有 await,任务对象被事件循环的弱引用表一直挂着。这个 bug 如果靠打断点,我估计得查到下个季度。

调试器的正确用法:验证假设,不是探索

图片

最后说个我自己的毛病。我大概有两年时间是这么用调试器的:打开,下断点,进去,F10 F10 F10,单步 300 次,然后忘了自己在找什么。

后来我给自己定了个规矩:在下第一个断点之前,必须能说出「如果 X 是 3,bug 就在 A;如果 X 是 4,就在 B」。 说不出来就别开调试器,去读代码、去加日志。调试器很贵,贵在它逼你一次只看一行,你得知道自己在找什么才划算。

配套几个挺好用的东西:

  • VS Code / JetBrains 的日志点(logpoint):不打停,只输出,语法是 {expression}。往热路径上埋 20 个都不用重启,比改代码加 print 快。
  • 条件断点:写 i === 8471 && user.role === 'admin'。在循环里比单步快一万倍。
  • 硬件 watchpoint:GDB 里 watch -l obj->field。但它用的是 x86 调试寄存器 DR0 到 DR3,最多只能下 4 个,第 5 个会直接报错,很多人第一次遇到都以为是 GDB 的 bug。
  • Chrome DevTools:Console 里敲 debug(fnName) 给函数下断点,monitor(fnName) 每次调用打一行参数,getEventListeners($0) 看当前选中元素挂了哪些事件。这几个比在 Sources 面板里翻半天强不少。

工具就说到这。真正让我调试变快的不是任何一个工具,是接受了一件事:调试不是解密,是做实验。实验的前提是有可重复的现场,有明确的假设,有一个能给你「是 / 否」的判据。剩下的,真的都是体力活。

🏷️ 标签: