如何调试代码

调试不是盯着代码看得更用力,而是不断缩小嫌疑范围,直到只剩下一种说得通的解释。下面这套流程,是我在错误信息难看、截止时间逼近、又忍不住想靠猜的时候使用的方法。

🎙️ 发布并录制于: · 更新于 ·

01先复现,再修复

无法触发的错误,只能算传闻。请写出从干净状态走到故障点的最短、最准确路线,包括输入、命令、环境、预期结果和实际结果。然后连续运行两次。如果这条路线时灵时不灵,先记下每次运行之间有什么变化,再去碰代码。

# 糟糕的报告:“上传坏了”
# 有用的复现步骤:
1. 用空数据目录启动应用
2. 上传名为 report.final.csv 的 0 字节文件
3. 点击一次 Import
预期:出现 "empty file" 验证提示
实际:TypeError: Cannot read properties of undefined (reading 'trim')
我的原则
不要从修复方案开始,要从一份必然失败的操作步骤开始。干净的复现已经完成了一半诊断,因为它把关于“可能性”的争论,变成了一件可以观察的事实。

02先读最后一行有用的信息

很长的调用栈容易让人慌,也容易让人从头逐行往下读。这两件事都别做。开头几行往往只是在描述哪个外层组件发现了崩溃。先看最末尾的异常,再往上找到第一个属于你自己代码的栈帧。库的内部代码是证人,通常不是案发现场。

Traceback (most recent call last):
  File "app.py", line 41, in <module>
    total = price * quantity
TypeError: can't multiply sequence by non-int of type 'float'

# 检查操作数,不要盯着乘号:
print(repr(price), type(price), repr(quantity), type(quantity))
# '12.50' 是文本 → 在输入边界转换:
price = float(raw_price)

这条消息说明,运行时看到的是文本,而你脑中以为它是数字。应该在数据进入系统的地方完成转换,不要在崩溃点旁边随手加一个类型转换。

03把搜索范围砍掉一半

一次请求经过浏览器、API、后台任务和数据库时,逐行检查太浪费时间。先在路径中间附近放一个观测点。这里的值已经错了吗?保留有问题的那一半,丢开正常的另一半,然后重复。这就是把二分查找用在因果关系上。

# 在边界放探针,不要随手加二十个 print
client payload  ✓ {"count": 3}
API input       ✓ count=3
queue message   ✗ {"count": ""}
worker input      {"count": ""}

# 缺陷位于 API 输入和队列消息之间。
# 现在只对这段序列化路径做二分排查。
明确的看法
“把所有代码都读一遍,直到哪里看着可疑”不算方法。在边界放观测点,然后持续对半缩小范围。这个习惯不仅适用于代码,也适用于部署、配置,甚至历史提交。要专门排查 Git 历史,请参阅 Git

04记录状态、身份和时间

一条有用的日志应该回答:哪项操作带着什么输入到达了什么状态,以及花了多长时间。“运行到这里了”一个问题也答不上。请加入请求标识或任务标识,这样才能在交错的输出中追踪同一次操作。在边界记录关键决定,不要逐行复述程序。

# 糟糕
print("got here")

# 有用,而且便于搜索
logger.info("invoice_send", extra={
  "invoice_id": invoice.id,
  "customer_id": customer.id,
  "attempt": attempt,
  "elapsed_ms": elapsed_ms,
})

绝对不要把密码、令牌、浏览器会话信息或完整支付数据写进日志。日志更多,不代表证据就更多。海量日志会改变程序时序、淹没关键事件,甚至自己制造一场安全事故。

05在现实开始偏离预期的地方暂停

只有当你带着一个问题时,断点才有价值。在错误值第一次被使用之前暂停,检查局部变量和调用栈,再单步越过最小的可疑操作。比起让循环停一万次,条件断点好用得多。

# 只在失败订单上暂停,不要拦住每个订单:
condition: order.id == "ord_8472"

# 观察你认为始终成立的不变量:
order.total >= 0

# 常见发现:
subtotal = 18.00
credit   = 25.00
total    = -7.00
选对工具
日志用来解释那些不能暂停的故障,调试器用来检查能够复现的局部状态。不要硬把生产事故塞进断点调试流程,也不要用永久日志淹没一个稳定复现的本地错误。

06建立最小复现

把失败路径复制到一个很小、用完就扔的案例中,然后逐个移除输入、依赖和准备步骤。每移除一项都运行一次。最后一次让故障消失的删除操作,指出了一个必要条件。所谓最小,就是留下的每一行都有留下的理由。

# 生产环境中的症状:
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x96 in position 14

# 最小证明:文件编码是 Windows-1252,不是 UTF-8
raw = b"July \x96 August"
raw.decode("utf-8")       # 复现错误
raw.decode("cp1252")      # "July – August"

# 修复:在数据进入系统时检测或声明源编码,
# 然后只统一转换一次 UTF-8。

不要把整个应用贴进问题单,然后称它为复现。删减过程本身就是调查,最后那个小案例才是调查结果。

07把问题讲给橡皮鸭听

逐项说清楚代码必须做什么,不要使用“处理”这类含糊的动词。每经过一个边界,都说出值是什么、类型是什么。一旦你的解释跳过某一步,或者依赖“这不是显而易见吗”,就去检查那一步。

# “检查缓存里有没有用户”掩盖了错误。
if cached_user:
    return cached_user

# 准确说出来:
# “空字典代表一个没有字段的缓存用户,
#  但这个分支把它当成了没有缓存值。”
if cached_user is not None:
    return cached_user
为什么有效
橡皮鸭什么忙也帮不上。重点就在这里。把脑中模糊的故事改成一句句明确陈述,会在你拉别人下水之前,暴露藏在其中的错误假设。

08别把海森堡错误吓跑

海森堡错误会因为被观察而发生变化。加一条日志,它就消失;开启调试模式,它反而稳定出现;某台机器上,它永远不来。遇到这种情况,要怀疑竞争条件、未初始化状态、时钟、缓存、资源限制,以及意外依赖迭代顺序的代码。在加入重型观测手段前,先尽量保留原本的时序。

# 真实的间歇性症状:
FileNotFoundError: [Errno 2] No such file or directory: 'result.tmp'

Thread A: write result.tmp → rename result.json
Thread B: delete result.tmp during cleanup

# 逐步修复:
1. 用文件 ID 和单调时间戳关联两个操作
2. 在重复或并发负载下复现
3. 明确文件所有权;清理任务忽略仍在使用的文件
4. 仅在 close/fsync 后执行原子重命名
5. 添加运行数千次迭代的压力测试

我的态度很明确:加入休眠不是并发修复。它只是挪动了竞争窗口,然后把错误送给一位机器更慢的用户。

09证明修复有效,不要只把故事讲通

修改前,先让复现案例失败。修改后,让同一个案例通过。条件允许时,再撤销或关闭这次修改,亲眼看着它重新失败。最后这一步能抓住一种很尴尬的情况:真正让症状消失的,其实是重启、旧缓存或变化后的测试数据,而不是你的修改。

# 三次观察
before patch   → FAIL: duplicate invoice sent
with patch     → PASS: one invoice sent
patch reverted → FAIL: duplicate returns

# 把最小失败案例保留为回归测试。
# 还要测试相邻边界:零、一个、多个;重试;超时。
补丁不等于证据
“好像修好了”意味着你根本没有设计验证步骤。请明确说出哪种观察结果能推翻你的解释,然后真的去观察一次。

10凌晨两点卡住时的检查清单

请按顺序使用这张清单。它故意设计得很无聊,因为人累的时候,无聊的流程胜过聪明的小花招。

□ 能否从干净状态开始复现?
□ 什么发生了变化:代码、配置、数据、依赖,还是环境?
□ 最后一条准确报错是什么?属于我代码的第一个栈帧在哪里?
□ 哪个值最先偏离预期?
□ 能否把剩余搜索范围再缩小一半?
□ 日志是否包含身份、状态和时间,同时没有泄露秘密?
□ 条件断点能否回答一个具体问题?
□ 能否删掉一半复现内容,同时保留故障?
□ 观察行为是否可能改变了时序或顺序?
□ 同一个案例是否在修改前失败、修改后通过,并已变成测试?
□ 临时日志、开关、休眠和调试权限是否已经移除?

调试速度来自拒绝瞎猜。复现、缩小范围、检查、解释、证明。工具会变,这个顺序不会变。

Tell me what missed

A correction is more useful than a compliment. This goes straight to the person who writes SwiftGrasp.

Was this page useful?
0/1000

Please do not include passwords, private keys, or personal information.