去年 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 版本升级。升级日志里有一行「调整了内部连接的超时重试策略」,没人当回事。
调试的成本大头不在「想」,在「拿到现场」
我们平时聊调试,聊的几乎都是推理那部分——怎么读栈、怎么缩小范围、怎么形成假设。但真到生产级的问题上,你花掉的时间分布是这样的:
- 复现:让 bug 稳定出现。flaky 问题里这一项能占掉 70% 以上。
- 观察:让程序在出错那一刻,把该看的变量现场交给你。
- 推理:看到现场之后,想明白为什么。
- 验证:改完怎么证明它真的好了,而不是被别的东西掩盖了。
几乎所有调试技巧和工具,解决的其实都是第 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 面板里翻半天强不少。
工具就说到这。真正让我调试变快的不是任何一个工具,是接受了一件事:调试不是解密,是做实验。实验的前提是有可重复的现场,有明确的假设,有一个能给你「是 / 否」的判据。剩下的,真的都是体力活。