资料库 · 调试方法论与实战
第 ⑤ 卷,条目 81–100。全部来自 jvns.ca(Julia Evans)。
这一卷和前四卷不一样:它讲的不是某个工具的原理,而是"怎么想"。
我把它放在最后,是因为它的保鲜期最长。Git 会出新版本,终端配置会换一轮,但"先确认问题能不能复现"这条二十年前对、二十年后还对。如果这个库你只读一卷,读这一卷。
分三段:
- 81–88 方法论 —— 调试到底是什么技能,怎么练
- 89–92 工具与练习 —— 具体手段,以及刻意练习的办法
- 93–100 实战案例 —— 八个完整的排查过程,看别人怎么想
第三段的读法要注意:别只看结论,看作者在每一步是怎么决定下一步查什么的。 结论对你没用(那是别人的 bug),决策过程才是能迁移的东西。
方法论:调试是一门可以学的技能
81. A debugging manifesto
来源:Julia Evans,jvns.ca,2022 年 12 月。
读它解决什么:给调试这件事一个明确的立场和原则。
要点:
- 作者对调试的原则性表述
- 2022 年 12 月,与《Pocket Guide to Debugging》zine 同期
我的补充:这一篇的核心主张是调试是一门技能,不是天赋——听起来像口号,但它有个很实际的推论:技能可以拆解、可以单独练、可以教。
我自己把它拆成五个可以分别练的子技能:
- 稳定复现 —— 把"有时候会挂"变成"这样操作一定挂"。这一步做到,问题基本就解决一半了。
- 缩小范围 —— 二分。删一半代码、回退一半提交、关一半配置。
- 读错误信息 —— 认真读到底,包括栈回溯的中间几层和被折叠的部分。
- 验证假设 —— 每一步只改一个变量,改完确认结果。
- 知道该看哪 —— 这一条最依赖经验,也是唯一真需要积累的。
新人和老手的差距,九成在第 1 和第 5 条,而不是在工具熟练度上。我见过太多人在问题还没稳定复现的时候就开始改代码,然后"改好了"(其实只是那次没触发),过两天又出现。
一条具体的规则我一直在用:只要还没稳定复现,就不许动代码。 强制自己先在复现上花时间,长期看快得多。
82. Some ways to get better at debugging
来源:Julia Evans,jvns.ca,2022 年 8 月。
读它解决什么:调试能力具体由哪些部分组成,哪一部分能刻意练。
要点:
- 尝试对调试技能做分类
- 与上一条同一条线,稍早
我的补充:这一篇做的是技能分层。我在实践里发现的最大瓶颈往往不在技术层面,而在系统知识层面——你不知道这个系统有哪些部件,所以想不到该去看哪。
举个具体例子:一个 HTTP 请求超时。可能的环节有 DNS 解析、TCP 连接、TLS 握手、代理、负载均衡、应用处理、数据库、下游服务。如果你脑子里没有这张图,你就只会反复看应用日志,因为那是你唯一知道存在的地方。而问题可能在 DNS(第一次慢、之后快 = 缓存)或者连接池耗尽(并发上去才出现)。
所以提升调试能力最有效的办法不是学工具,是给自己常打交道的系统画一张部件图,并且知道每个部件怎么观察:
浏览器 → DNS → CDN → LB → 应用 → 缓存 → 数据库
dig 日志 日志 APM redis-cli 慢查询日志图画出来之后,"该看哪"就从灵感变成了查表。
另一个具体做法:每次解决一个费了很久的问题,写三行记录——症状、真正的原因、我走错的那条路。第三行最有价值。积累几十条之后你会发现自己走错的路是有模式的(我自己的模式是"过早怀疑框架,太晚怀疑配置"),认出模式就能改。
83. How I got better at debugging
来源:Julia Evans,jvns.ca,2015 年 11 月。
读它解决什么:一个人调试能力提升的实际路径。
要点:
- 作者早期的个人经验总结
- 2015 年,是本卷时间最早的一篇之一
我的补充:这一篇和第 82 条隔了七年,值得对照读——早期讲"我做了什么",后期讲"这件事的结构是什么"。从个人经验到可传授的框架,中间是七年的积累。
从我自己的经历看,能力跃升的转折点有三个,都不是学了什么新工具:
一、开始相信"计算机是确定性的"。 新手会说"它随机出问题"。几乎不存在随机——存在的是你还没找到那个变量(时序、并发顺序、缓存状态、某台机器的配置、时区、闰秒)。"随机"是"我还没找到条件"的同义词。 接受这一点,你才会继续找。
二、开始怀疑自己的假设,而不是先怀疑系统。 "这段代码肯定执行了"——打一行日志确认。九成的困惑来自某个没被验证的假设。我的习惯是把"我确信的事"列出来,然后逐条实际验证,通常前三条里就有一条是错的。
三、学会读源码。 依赖库的行为和文档不一致时,去看代码。这件事的门槛比想象的低——你不需要读懂整个库,只要能找到那个函数然后往下读几层。pip show -f、node_modules/、go doc -src、IDE 的 "go to definition" 都能到。
84. When debugging, your attitude matters
来源:Julia Evans,jvns.ca,2020 年 4 月。
读它解决什么:心态对排查效率的实际影响。
要点:
- 讨论调试时的情绪与心态因素
- 属于方法论里偏"人"的一面
我的补充:这一条容易被当成软话跳过,但它有非常具体的效果。着急的时候你会做出更差的技术决策,这一点我在自己身上验证过很多次:
- 着急 → 跳过复现步骤,直接改代码 → 改了三处,不知道哪处有用 → 状态更乱
- 着急 → 不读完整的错误信息 → 在错的方向上搜索半小时
- 着急 → 一次改多个变量 → 结果无法归因
反过来,几个具体的自我调节手段:
一、卡住超过 30 分钟就停下来写。 写三句话:现在的症状是什么、我已经排除了什么、下一步准备验证什么。光是写这三句,很多时候答案就出来了——因为写作强迫你把模糊的直觉变成明确的陈述。
二、真的卡住就去散步。 这不是玄学,是给潜意识时间。我印象里最难的几个 bug 都是在离开电脑的时候想通的。
三、线上事故时,先止损再排查。 回滚、扩容、切流量,把压力降下来,然后从容地找根因。带着"用户正在受影响"的压力找根因,效率极低而且容易做出破坏性的操作。 这也是为什么"能快速回滚"是比"代码质量高"更重要的基础设施能力。
四、承认"我不知道"。 假装知道会导致你在错的方向上投入。跟同事说"我不理解这里为什么会这样"通常能在五分钟内得到答案。
85. Reasons why bugs might feel "impossible"
来源:Julia Evans,jvns.ca,2021 年 6 月。
读它解决什么:"这不可能发生"的感觉从哪来,怎么破。
要点:
- 列举导致 bug 显得不可能的各种成因
- 面向"我已经检查过所有地方了"这种状态
我的补充:"这不可能" 这句话在调试里的准确含义是:"我的心智模型和现实不一致"。而不一致的原因通常是下面几类之一——我把它当检查清单用:
- 你看的不是正在跑的代码。 改了没保存、没重新编译、没重启、部署到了别的环境、CDN/浏览器缓存、Docker 用了旧镜像层、
__pycache__里的旧.pyc。这一类占了"不可能"里的一大半。 验证办法很粗暴但有效:故意加一行必崩的代码,看它是不是真的崩。 - 有两份配置/代码在打架。 环境变量覆盖了配置文件、
.env.local覆盖了.env、全局安装的包遮住了本地的。 - 缓存在骗你。 应用缓存、数据库查询缓存、DNS 缓存、HTTP 缓存、构建缓存。
- 不是你以为的那个进程/机器在处理。 负载均衡后面有台机器版本不对;有个旧进程没被杀掉还在监听;本地跑着一个忘了关的服务。
- 时序问题。 并发顺序、竞态、超时、时钟漂移。这类"随机"(见第 83 条)。
- 你的观测工具本身有问题。 日志被采样了、监控面板的时间范围不对、日志的时间戳是别的时区。
破解的第一步永远是"证明我看的东西是活的":加一行 print("HERE " + str(time.time())),确认它真的出现在你看的那个日志里。这一步看起来幼稚,但它排除了第 1、4、6 类,也就是大部分情况。
86. What does debugging a program look like?
来源:Julia Evans,jvns.ca,2019 年 6 月。
读它解决什么:调试过程的实际形态,以及一批相关资源。
要点:
- 描述调试的一般流程并汇总参考资源
- 属于导览性质的一篇
我的补充:调试的流程可以写成一个循环,而这个循环里最容易被跳过的是最后一步:
观察症状 → 提出假设 → 设计能否证伪的验证 → 执行 → 更新模型 → 循环关键在"能否证伪"。新手常见的做法是"我猜是 A,那我把 A 改一下看好不好"——这个验证的问题是:改好了你也不知道是不是因为 A(可能是重启的副作用),没改好也不能排除 A(可能改的方式不对)。
好的验证是能明确区分两种可能的:不是"改一下试试",而是"如果假设成立,日志里应该出现 X;如果不成立,应该出现 Y"。然后去看是 X 还是 Y。
这个区别在实践里很大。举个例子,怀疑是连接池耗尽:
- 差的验证:把连接池调大,看还出不出问题。(调大之后可能只是概率变低了)
- 好的验证:打印当前活跃连接数和等待队列长度。如果假设成立,出问题时活跃数应该等于上限。
第二种给你的是确定的答案,第一种给你的是更多的疑问。
另一个实用原则:一次只改一个变量。 改两个之后好了,你有四种可能的解释,而你没法区分。这条规则在压力下最容易被违反,也最该坚持。
87. Coding strategies
来源:Julia Evans,jvns.ca,2013 年 12 月。
读它解决什么:写代码时的策略选择——什么时候该规划,什么时候该动手试。
要点:
- 作者早期关于编码方法的思考
- 2013 年,本卷时间最早的一篇
我的补充:这一篇讲的是一个每天都要做几十次的决策:这一步该想清楚再写,还是先写一个看看?
我的判断标准是反馈成本:
- 反馈快、错了代价小 → 直接试。改个 CSS、调个参数、试个 API 的返回格式。想五分钟不如跑一次。
- 反馈慢或者错了代价大 → 先想清楚。数据库 schema 迁移、公开 API 的接口设计、分布式系统的一致性方案、任何要写迁移脚本才能改回来的东西。
大部分人的错误在两个方向都有:在前一类上想太久(分析瘫痪),在后一类上试太快(写完才发现数据模型不对,已经上线了)。
一个降低"想清楚"成本的技巧:先写调用方的代码。要设计一个 API,先假装它已经存在,把使用它的那段代码写出来。这样你会立刻发现参数顺序别扭、返回值不好用、错误处理没法写这些问题——而这些问题在实现完之后才发现的话,改起来就贵了。
还有一个和调试相关的策略:写代码时就为将来的调试做准备。具体就三件事——在关键决策点打日志(不是遍地打)、错误信息里带上上下文(哪个 ID、哪个文件、哪个值)、避免把多件事写在一行里(一行里三个链式调用,栈回溯只会给你一个行号)。这三件事在写的时候多花两分钟,出问题时能省一小时。
88. Tips for analyzing logs
来源:Julia Evans,jvns.ca,2022 年 12 月。
读它解决什么:面对一大堆日志,怎么找到有用的那几行。
要点:
- 日志分析的具体技巧
- 与《调试宣言》同月
我的补充:日志分析的难点不是命令,是知道该找什么。我的固定流程,按顺序:
一、先定时间窗口。 问题发生在什么时候?把日志切到那个窗口前后各五分钟。不做这一步,后面全是噪音。
二、先看数量的变化,再看内容。 具体做法:
# 按分钟统计日志量,找异常的时间点
awk '{print $1, $2}' app.log | cut -c1-16 | uniq -c
# 错误类型排行
grep -i error app.log | sed 's/[0-9]\+/N/g' | sort | uniq -c | sort -rn | head -20第二条里的 sed 's/[0-9]\+/N/g' 是关键技巧:把所有数字替换成 N,这样 "user 123 not found" 和 "user 456 not found" 就归成一类,能统计出真正的错误分布。不做这一步,uniq -c 出来全是 1。
三、找第一个异常,不是最多的那个。 级联故障里,量最大的错误通常是后果(下游全部超时),而根因是最早那一条(某个连接池满了)。按时间正序找第一个异常,比按数量排序有用。
四、看上下文,不只看匹配行。 grep -B5 -A20 'Exception'。异常前五行常常有关键线索(哪个请求触发的)。
五、日志没有你要的信息时,加日志。 这是最常见的真实情况。加的时候带上关联 ID,这样一次请求跨多个服务的日志能串起来。
一条给写代码时的建议:日志里永远带上标识符(request id、user id、订单号)。没有标识符的日志在并发环境下基本没用——你看到"开始处理"和"处理失败",但不知道是不是同一个请求的。
工具与练习
89. Debugging by starting a REPL at a breakpoint is fun
来源:Julia Evans,jvns.ca,2021 年 9 月。
读它解决什么:断点处开一个交互式解释器,比看变量值强在哪。
要点:
- 讲在断点位置进入 REPL 的调试方式
- 适用于 Python、Ruby 这类有交互解释器的语言
我的补充:这是我认为动态语言里最被低估的调试手段。区别在于:看变量值是被动的(只能看你想到要看的东西),而在 REPL 里你能主动做实验——调用那个函数试试、把数据换个形状看看、直接改掉一个值继续跑。
各语言的入口:
breakpoint() # Python 3.7+,比 import pdb; pdb.set_trace() 好记
# 环境变量 PYTHONBREAKPOINT=ipdb.set_trace 能换成 ipdbbinding.irb # Ruby 2.4+debugger; // 配合 node --inspect 或浏览器 DevToolsGo 和 Rust 没有 REPL,用 dlv 和 rust-gdb,体验差一些但基本能力都有。
几个实用技巧:
pdb里的pp locals()一次看全所有局部变量,比一个个p快- 条件断点:
breakpoint() if user_id == 12345 else None,或者在 pdb 里b file.py:42, cond。循环里跑几万次只想看某一次的时候必须用这个 pdb的interact命令进入完整的 Python REPL(有补全和多行),比 pdb 自己的提示符好用post_mortem:异常已经抛了才想调试的时候,python -m pdb -c continue script.py会在崩溃点自动进入 pdb,现场还在
注意生产环境的红线:breakpoint() 忘了删推上线,会让请求线程永久挂起(等一个永远不会来的输入)。这不是理论风险,我见过。CI 里加个检查:grep -rn 'breakpoint()\|pdb.set_trace\|binding.irb\|debugger;' src/ 命中就失败。
90. New zine: The Pocket Guide to Debugging
来源:Julia Evans,jvns.ca,2022 年 12 月。
读它解决什么:想要一份把调试方法论浓缩成手册的东西。
要点:
- 调试主题 zine 的发布公告
- 与第 81、88 条同月,是那一批思考的成品
我的补充:收这一条是因为公告本身列出了调试的完整流程框架,可以当目录用。
跟着这个思路,我给自己整理过一份卡片,卡住的时候按顺序过一遍。分享出来:
- 我确定症状是什么吗?(不是"它坏了",而是"输入 X 时输出 Y,期望 Z")
- 能稳定复现吗?不能的话,最小的复现条件是什么?
- 我看的是正在运行的代码吗?(第 85 条第 1 类)
- 最近改了什么?(
git log、部署记录、配置变更、依赖升级) - 完整的错误信息和栈回溯我读到底了吗?
- 我能二分吗?(
git bisect、注释掉一半、关掉一半配置) - 我的假设里哪一条没有被实际验证过?
- 有没有更简单的解释?(配置、环境、缓存、权限、磁盘满、时钟)
第 4 条在实践中命中率最高。"昨天还好today不行"这类问题,答案九成在"改了什么"里——而且经常不是你改的(依赖的间接升级、上游服务的变更、证书过期)。
第 8 条值得单独强调:排查复杂 bug 之前,先花两分钟排除愚蠢的可能性。 磁盘满了、证书过期了、密码改了、时间不对、权限不够——这几个的排查成本极低,但命中的时候能省几小时。我有一次查了两小时的"数据库连接异常",最后是磁盘满了写不了日志。
91. Notes on building debugging puzzles
来源:Julia Evans,jvns.ca,2021 年 4 月。
读它解决什么:怎么造练习题来练调试,以及为什么这件事难。
要点:
- 记录设计调试练习题的过程与困难
- 属于"怎么教调试"这个方向
我的补充:这一篇点出了调试教学的核心难题:真实的 bug 不能被简化,简化之后就不难了。 真实 bug 的难度来自"系统很大、你不知道该看哪",而一个 50 行的练习题里你能看完所有代码。
但刻意练习调试确实有办法,我自己用过有效的三种:
一、给自己的项目注入故障。 随机改一个字符(改个比较符号、删个 await、改个变量名),然后从症状出发排查回去。这个练的是"从症状推位置",而且用的是你熟悉的代码库,所以难度接近真实。
二、复现别人的 bug 报告。 从任何开源项目的 issue 列表里挑一个有复现步骤的,自己搭环境复现、定位。这是最接近真实工作的练习,而且顺带能提交 PR。
三、读 postmortem。 大公司公开的事故报告(Cloudflare、GitLab、AWS 的都很详细)。读的时候先看症状那一段,自己想十分钟"我会怎么查",然后再看他们实际怎么查的。这个对比是最有价值的部分。
第三种的性价比最高,因为你能接触到平时碰不到的系统规模和故障类型。我从 Cloudflare 那次正则表达式回溯导致全球 CPU 打满的报告里学到的东西,比自己排查十个业务 bug 多。
92. Linux debugging tools I love
来源:Julia Evans,jvns.ca,2016 年 7 月。
读它解决什么:Linux 上有哪些调试工具,各自解决哪一类问题。
要点:
- 汇总作者常用的 Linux 调试工具
- 2016 年,工具本身大多至今仍是主流
我的补充:这一篇的工具清单十年后基本还有效(这些工具的稳定性远超应用层框架)。按你想知道什么来组织比按工具名列表有用:
进程在干什么?
strace -p PID—— 系统调用。卡住的进程首选,一眼看出它在等什么(等 read 就是在等 IO / 网络,等 futex 就是锁)ltrace—— 库函数调用py-spy dump --pid PID/jstack/dlv attach—— 语言级栈,不用重启进程
网络怎么了?
ss -tnp(netstat的现代替代)—— 连接状态。SYN-SENT堆积 = 对方不理,CLOSE-WAIT堆积 = 你的程序没关连接tcpdump -i any -nn port 5432 -w out.pcap—— 抓包,复杂的用 Wireshark 读dig、curl -v、mtr
性能瓶颈在哪?
top/htop起步,vmstat 1看整体趋势perf top、perf record -g—— CPU 热点,出火焰图iostat -x 1—— 磁盘。看%util和awaitbpftrace/bcc—— 现代的动态追踪,2016 年之后成熟起来的,是这份清单里唯一的大变化
文件和权限?
lsof -p PID—— 打开的文件和 socket。"文件删了但磁盘没释放"就是它查(有进程还持有句柄)ls -l /proc/PID/fd、cat /proc/PID/environ(进程的实际环境变量,排查配置问题很有用)
我的建议是先把 strace、ss、lsof 这三个练熟。 这三个覆盖了排障里最常见的"卡住了"、"连不上"、"文件哪去了"三类问题。
93. Three steps to learning GDB
来源:Julia Evans,jvns.ca,2014 年 2 月。
读它解决什么:gdb 门槛高,怎么用最少的命令让它开始产生价值。
要点:
- 把学 gdb 拆成三个渐进的步骤
- 面向"知道 gdb 但一直没学会"的人
我的补充:gdb 的问题是命令太多,教程往往一上来就讲全套,学的人在没得到任何收益之前就放弃了。分步走确实是对的办法。
我认为的最小可用集,四个命令,学会就能开始用:
gdb ./prog
(gdb) run <参数>
(gdb) bt # 崩了之后看栈回溯 —— 只这一个就值回票价
(gdb) p 变量名 # 打印变量
(gdb) quit如果只学一个,学 bt。 拿到一个崩溃的 C/C++/Rust 程序,gdb ./prog + run + bt,三十秒就知道崩在哪一行。这比对着 core dump 干瞪眼强太多。
之后按需要加:
(gdb) b file.c:42 # 断点
(gdb) b func if x > 100 # 条件断点
(gdb) n / s / c # 下一行 / 步入 / 继续
(gdb) info locals # 所有局部变量
(gdb) thread apply all bt # 所有线程的栈(查死锁)
(gdb) watch x # 变量被改时中断 —— 查"谁改了它"的利器watch 那个值得单独提:查"这个值不知道被谁改了"的问题,硬件观察点是唯一靠谱的办法,靠加日志得试很多次。
两个实用建议:
- 装
gdb-dashboard或者用tui模式(gdb -tui)。原版 gdb 的纯文本界面让人看不到上下文,这是它难用的主要原因,不是命令的问题。 rust-gdb/rust-lldb是带 Rust 类型美化的包装,直接看得懂Vec和String的内容,别用裸 gdb 调 Rust。
实战:八个完整的排查过程
下面八条都是完整的排查记录。读法:看到症状描述先停下,自己想"我会先查什么",再往下读。 对比自己的思路和作者的思路,这是这一段唯一有价值的用法——直接读结论等于什么都没学到。
94. Debugging a segfault in my Rust program
来源:Julia Evans,jvns.ca,2017 年 12 月。
读它解决什么:Rust 也会 segfault?什么情况下会,怎么查。
要点:
- 一次 Rust 程序段错误的排查记录
- Rust 的安全保证有边界,这一篇落在边界之外
我的补充:Rust 号称内存安全,但 segfault 确实可能,而且可能的路径是有限的、可以枚举的——这是排查这类问题的关键:
unsafe块里的代码。 包括你自己写的和依赖里的。- FFI 调用 C 库。 这是实践中最常见的来源。C 库的错误使用(生命周期不对、传了空指针、二次释放)Rust 编译器管不了。
- 栈溢出(深递归)。这个所有语言都有,见第四卷第 77 条。
- 编译器 / 标准库的 bug。 极少见,但存在。
排查顺序就按这个列表来:先 grep -rn 'unsafe' src/ 看自己有没有;再看依赖里有没有 -sys 结尾的包(那些都是 C 绑定);再看是不是递归。
工具上,Rust 特有的两个很值得知道:
# 内存错误检测(需要 nightly),能抓出 UB
cargo +nightly miri test
# AddressSanitizer
RUSTFLAGS="-Z sanitizer=address" cargo +nightly testmiri 在解释器层面执行 MIR 并检查未定义行为,是查 unsafe 代码问题的最强工具。它跑得很慢(几十倍),但能发现"现在碰巧能跑、换个编译器版本就崩"的那类问题。
一个通用的经验:任何"安全语言"里出现内存级崩溃,先怀疑边界——FFI、unsafe、JNI、cgo、C 扩展。Python 里的 segfault 几乎总是某个 C 扩展(numpy、lxml、数据库驱动);Java 里的是 JNI 或者 JVM 自己;Go 里的是 cgo。
95. A couple of Rust error messages
来源:Julia Evans,jvns.ca,2022 年 12 月。
读它解决什么:Rust 的报错信息怎么读,尤其是所有权和生命周期那类。
要点:
- 分析几个具体的 Rust 编译错误
- 与调试方法论那一批同月
我的补充:Rust 的编译错误是我见过所有语言里最好的——它会告诉你哪一行、为什么、通常还给出修改建议。但它有个特点:信息量大到让人不想读完,一个借用检查错误可能有二十行输出,配三个代码片段。
读法上有个具体的技巧:从下往上读。Rust 把最有帮助的部分(help: 和 note:)放在最后,而人的习惯是读第一行然后去搜索。倒着读效率高得多。
# 展开某个错误码的完整解释和示例
rustc --explain E0502这个命令很多人不知道。E0502(可变借用与不可变借用冲突)、E0499(两次可变借用)、E0597(活得不够久)是新手最常撞的三个,--explain 给的解释比搜索结果好。
这条也适用于其他语言,是个通用的判断:报错信息的质量是语言/工具链成熟度的重要指标,而且它直接影响你的学习速度。对比一下就很明显——C++ 的模板错误能刷几百行且几乎不可读,TypeScript 的复杂泛型错误也难懂,而 Rust 和 Elm 在这方面投入了大量工程。
选技术栈时值得把"报错好不好读"当一个真实的评估项,尤其是团队里有新人的时候。这个成本平时看不见,但它每天都在发生。
96. Nancy Drew and the Case of the Slow Program
来源:Julia Evans,jvns.ca,2015 年 3 月。
读它解决什么:一个程序慢,怎么从"感觉慢"定位到具体原因。
要点:
- 性能问题的排查记录,标题用了侦探小说的比喻
- 2015 年
我的补充:性能排查有一条铁律:先测量,再优化。 人对性能瓶颈的直觉准确率极低——我自己猜过很多次,错的比对的多。
排查顺序(从粗到细,每一步都比上一步贵,所以别跳):
一、确定是哪一类资源。 top / htop 看:CPU 打满?内存换页(si/so 有值)?磁盘 IO(%wa 高)?还是什么都不忙——那就是在等(网络、锁、下游服务),而"什么都不忙但很慢"是最常见的情况。
二、分段计时。 在代码里粗粒度打时间戳,先确定慢在哪个阶段。这一步经常直接给出答案,而且成本最低。
三、上 profiler。
py-spy top --pid PID # Python,不用改代码不用重启
perf record -g ./prog && perf report # C/C++/Rust/Go
go tool pprof # Go四、看火焰图。 perf 或 py-spy record -o out.svg 出的火焰图,横轴宽的就是耗时多的。
几个高频的真实原因,先排除这些能省很多时间:
- N+1 查询。 循环里发数据库请求。Web 应用性能问题的头号原因,没有别的能比。
- 没有索引。 慢查询日志 +
EXPLAIN。 - 同步 IO 在热路径上。 日志同步刷盘、循环里读文件。
- 序列化/反序列化。 大 JSON 的解析成本经常超过业务逻辑本身。
- 锁竞争。 现象是 CPU 不满但吞吐上不去,加机器也没用。
一个具体的判断技巧:如果并发一上来就慢但单请求很快,那是资源竞争(锁、连接池、单线程瓶颈);如果单请求本身就慢,那是算法或者 IO。这两类的排查方向完全不同,先分清能省一半时间。
97. Investigating Erlang by reading its system calls
来源:Julia Evans,jvns.ca,2016 年 5 月。
读它解决什么:面对一个完全不熟悉的运行时,怎么从外部搞清它在干什么。
要点:
- 用系统调用观察 Erlang 运行时的行为
- 标题原文是 "Erlang seems really complicated"
我的补充:这一篇教的方法是本段里最通用的一个:不懂这个技术栈,也能靠系统调用搞清它在干什么。
原理很简单:任何程序想跟外界打交道,最终都要走系统调用。读文件、发网络包、创建线程、拿锁,全部要经过内核。所以 strace 是一个语言无关的观察层——你不用会 Erlang,也能看出它开了几个线程、连了哪些端口、读了哪些配置文件。
我用这个方法解决过的实际问题:
- 某个闭源二进制读的是哪个配置文件?
strace -f -e trace=openat ./prog 2>&1 | grep -v ENOENT。比看文档快,而且文档经常是错的。 - 一个 Java 应用连不上数据库,日志只说 "connection failed"。
strace -f -e trace=connect看它实际连的 IP 和端口——发现连的是老地址,配置没生效。 - 某个进程 CPU 100% 但看不出在干什么。
strace -c -p PID统计系统调用次数,发现在疯狂futex(锁竞争)。 - 服务启动慢。
strace -f -T -e trace=openat,connect看每个调用的耗时,发现在等一个不通的 DNS。
常用参数记这几个就够:
strace -f # 跟踪子进程和线程(几乎总是要加)
strace -T # 显示每个调用的耗时
strace -c # 只统计,不打细节
strace -e trace=openat,connect,futex # 只看关心的
strace -p PID # 附到已运行的进程
strace -o out.txt # 输出到文件(stderr 量很大)macOS 上 SIP 挡着 dtruss,用 fs_usage(文件)和 lsof;Windows 上用 Process Monitor。这个技能的价值随着你接触的技术栈变多而增加——它让"完全不熟悉的系统"变成"可以观察的系统"。
98. Surprises in Ruby HTTP libraries
来源:Julia Evans,jvns.ca,2016 年 3 月。
读它解决什么:HTTP 客户端库的默认行为经常和你以为的不一样。
要点:
- 对比几个 Ruby HTTP 库的行为差异
- 结论对其他语言的 HTTP 客户端同样适用
我的补充:HTTP 客户端的默认值是我见过最不能信的一类默认值,而且它们造成的故障有个共同特征:平时没事,压力一来就雪崩。
上生产前必须显式确认的四项:
一、超时。 很多库默认没有超时(或者只有连接超时没有读超时)。后果很具体:下游卡住 → 你的连接池被占满 → 你的服务也挂 → 级联故障。连接超时和读超时要分开设,前者几秒,后者按业务定。
二、重试。 有的库默认重试,有的不。默认重试对非幂等请求是危险的(重复下单、重复扣款),对幂等请求则是必要的。而且重试必须带指数退避加抖动,否则下游抖动时你的重试会变成 DDoS。
三、连接池大小。 太小成瓶颈,太大压垮下游。而且连接池是按客户端实例的,很多人不小心在每次请求时都新建客户端——那样连接池等于没有,还会耗尽本地端口(TIME_WAIT 堆积)。
四、TLS 验证。 有的库在某些配置下会静默跳过证书验证。这不是性能问题,是安全问题。
我的做法是在项目里包一层:所有 HTTP 出站请求走同一个封装,超时、重试、连接池、日志、指标在那一层统一定义。这样这四项只需要正确一次,而且之后要加追踪(trace id 透传)也只改一处。
通用教训:任何涉及网络的库,上生产前把超时和重试的默认值找出来看一眼。 这件事花十分钟,能避免一类最难排查的故障——因为超时缺失的症状是"偶发的、上游的、看起来跟你无关的"。
99. Debugging netlink requests
来源:Julia Evans,jvns.ca,2017 年 9 月。
读它解决什么:Linux 上配置网络的那套接口(netlink)怎么观察和调试。
要点:
- 一次 netlink 请求的排查记录
- netlink 是内核与用户态之间配置网络的通道
我的补充:netlink 是 ip、ss、容器网络插件(CNI)背后的机制——它们不是去读 /proc,而是通过 netlink socket 跟内核对话。
这条知识在容器网络排障时非常有用,因为容器网络的所有配置最终都落在 netlink 上:
# 看容器的网络命名空间里是什么样
nsenter -t <PID> -n ip addr
nsenter -t <PID> -n ip route
nsenter -t <PID> -n ss -tnp
# veth 对的另一端在哪
ip -d link show
# 观察 netlink 消息(内核在被要求做什么)
ip monitor allip monitor all 这个命令知道的人不多但很好用:它实时打印所有网络配置变化。容器启动时跑着它,你能看到 veth 创建、IP 分配、路由添加的完整过程——"容器起来了但网不通"这类问题,靠它能看出到底哪一步没做。
nsenter 是另一个关键工具。容器网络问题的最大障碍是你看到的和容器看到的不是同一个网络栈,在宿主机上 ping 通不代表容器里通。nsenter -t PID -n 直接进入容器的网络命名空间,之后所有网络命令都是从容器视角执行的。这比 docker exec 进去装工具强得多(很多镜像里连 ping 都没有)。
这一条体现的通用原则:搞清一层抽象底下的机制,你的排查能力会跨越抽象边界。 只会用 docker 命令的人,遇到网络问题就只能重启容器;知道底下是 netns + veth + iptables/nftables 的人,能直接定位到是哪一层没配对。
100. Reverse engineering the Notability file format
来源:Julia Evans,jvns.ca,2018 年 3 月。
读它解决什么:面对一个没有文档的私有文件格式,怎么把它搞清楚。
要点:
- 逆向一个笔记应用的文件格式的完整过程
- 目标是取回自己的数据
我的补充:拿这一条收尾,因为它示范了调试能力的最终形态:面对一个完全黑盒的东西,仍然有系统的办法搞清它。
方法是通用的,我按顺序列出来(这套流程我自己用过好几次,比如解析某个设备导出的私有格式):
file和xxd | head。 先看魔数(文件头几个字节)。很多"私有格式"其实就是 zip(PK\x03\x04)、sqlite(SQLite format 3)、plist、protobuf 或者 gzip 套一层。这一步经常直接结束战斗——比如发现是 zip,解开就看到 XML 了。strings。 能看到可读字符串就说明没加密,而且能猜出字段名。- 做对照实验。 存两个只差一点的文件,
xxd之后diff或者cmp -l。这是最有效的一步:改一个字的内容,看哪几个字节变了,就知道那个字段在哪。 - 找已有的努力。 搜
<格式名> file format reverse engineering、<格式名> parser在 GitHub 上。这一步应该更早做——大概率有人已经做过了。 - 看官方有没有导出接口。 逆向之前先确认这个工作有没有必要。
这套方法的适用面比"逆向文件格式"广得多:一个没文档的内部 API、一个只有二进制的老工具、一个前同事留下的没人懂的系统——都是同一套办法:先找边界(输入输出是什么),再做对照实验(改一点看变什么),再找已有资料。
也顺带说个立场:逆向自己的数据是完全正当的——你的笔记、你的记录,被锁在私有格式里是厂商的选择,不是你的义务。这也是为什么选工具时"数据能不能导出成开放格式"值得当一个硬指标。
本卷小结:81–88 条是方法论(第 81 条的"先稳定复现"和第 85 条那份"不可能"清单是全卷最实用的两条);89–93 条是工具与练习(REPL 调试、strace/ss/lsof 三件套、gdb 的最小可用集);94–100 条是七个实战案例(第 97 条那个"用系统调用观察任何不熟悉的运行时"的方法最通用,第 100 条那套逆向流程适用面远超文件格式)。
这一卷的东西不会过期。 前四卷的内容会随 Git 和终端工具演进,这一卷讲的是怎么想问题——十年后还有效。
上一卷: ④ 终端与 Shell 机制 · 回到: 专题资料库总览
