调试不是盯着代码看得更用力,而是不断缩小嫌疑范围,直到只剩下一种说得通的解释。下面这套流程,是我在错误信息难看、截止时间逼近、又忍不住想靠猜的时候使用的方法。
🎙️ 发布并录制于: · 更新于 ·
无法触发的错误,只能算传闻。请写出从干净状态走到故障点的最短、最准确路线,包括输入、命令、环境、预期结果和实际结果。然后连续运行两次。如果这条路线时灵时不灵,先记下每次运行之间有什么变化,再去碰代码。
# 糟糕的报告:“上传坏了”
# 有用的复现步骤:
1. 用空数据目录启动应用
2. 上传名为 report.final.csv 的 0 字节文件
3. 点击一次 Import
预期:出现 "empty file" 验证提示
实际:TypeError: Cannot read properties of undefined (reading 'trim')很长的调用栈容易让人慌,也容易让人从头逐行往下读。这两件事都别做。开头几行往往只是在描述哪个外层组件发现了崩溃。先看最末尾的异常,再往上找到第一个属于你自己代码的栈帧。库的内部代码是证人,通常不是案发现场。
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)这条消息说明,运行时看到的是文本,而你脑中以为它是数字。应该在数据进入系统的地方完成转换,不要在崩溃点旁边随手加一个类型转换。
一次请求经过浏览器、API、后台任务和数据库时,逐行检查太浪费时间。先在路径中间附近放一个观测点。这里的值已经错了吗?保留有问题的那一半,丢开正常的另一半,然后重复。这就是把二分查找用在因果关系上。
# 在边界放探针,不要随手加二十个 print
client payload ✓ {"count": 3}
API input ✓ count=3
queue message ✗ {"count": ""}
worker input {"count": ""}
# 缺陷位于 API 输入和队列消息之间。
# 现在只对这段序列化路径做二分排查。一条有用的日志应该回答:哪项操作带着什么输入到达了什么状态,以及花了多长时间。“运行到这里了”一个问题也答不上。请加入请求标识或任务标识,这样才能在交错的输出中追踪同一次操作。在边界记录关键决定,不要逐行复述程序。
# 糟糕
print("got here")
# 有用,而且便于搜索
logger.info("invoice_send", extra={
"invoice_id": invoice.id,
"customer_id": customer.id,
"attempt": attempt,
"elapsed_ms": elapsed_ms,
})绝对不要把密码、令牌、浏览器会话信息或完整支付数据写进日志。日志更多,不代表证据就更多。海量日志会改变程序时序、淹没关键事件,甚至自己制造一场安全事故。
只有当你带着一个问题时,断点才有价值。在错误值第一次被使用之前暂停,检查局部变量和调用栈,再单步越过最小的可疑操作。比起让循环停一万次,条件断点好用得多。
# 只在失败订单上暂停,不要拦住每个订单:
condition: order.id == "ord_8472"
# 观察你认为始终成立的不变量:
order.total >= 0
# 常见发现:
subtotal = 18.00
credit = 25.00
total = -7.00把失败路径复制到一个很小、用完就扔的案例中,然后逐个移除输入、依赖和准备步骤。每移除一项都运行一次。最后一次让故障消失的删除操作,指出了一个必要条件。所谓最小,就是留下的每一行都有留下的理由。
# 生产环境中的症状:
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。不要把整个应用贴进问题单,然后称它为复现。删减过程本身就是调查,最后那个小案例才是调查结果。
逐项说清楚代码必须做什么,不要使用“处理”这类含糊的动词。每经过一个边界,都说出值是什么、类型是什么。一旦你的解释跳过某一步,或者依赖“这不是显而易见吗”,就去检查那一步。
# “检查缓存里有没有用户”掩盖了错误。
if cached_user:
return cached_user
# 准确说出来:
# “空字典代表一个没有字段的缓存用户,
# 但这个分支把它当成了没有缓存值。”
if cached_user is not None:
return cached_user海森堡错误会因为被观察而发生变化。加一条日志,它就消失;开启调试模式,它反而稳定出现;某台机器上,它永远不来。遇到这种情况,要怀疑竞争条件、未初始化状态、时钟、缓存、资源限制,以及意外依赖迭代顺序的代码。在加入重型观测手段前,先尽量保留原本的时序。
# 真实的间歇性症状:
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. 添加运行数千次迭代的压力测试我的态度很明确:加入休眠不是并发修复。它只是挪动了竞争窗口,然后把错误送给一位机器更慢的用户。
修改前,先让复现案例失败。修改后,让同一个案例通过。条件允许时,再撤销或关闭这次修改,亲眼看着它重新失败。最后这一步能抓住一种很尴尬的情况:真正让症状消失的,其实是重启、旧缓存或变化后的测试数据,而不是你的修改。
# 三次观察
before patch → FAIL: duplicate invoice sent
with patch → PASS: one invoice sent
patch reverted → FAIL: duplicate returns
# 把最小失败案例保留为回归测试。
# 还要测试相邻边界:零、一个、多个;重试;超时。请按顺序使用这张清单。它故意设计得很无聊,因为人累的时候,无聊的流程胜过聪明的小花招。
□ 能否从干净状态开始复现?
□ 什么发生了变化:代码、配置、数据、依赖,还是环境?
□ 最后一条准确报错是什么?属于我代码的第一个栈帧在哪里?
□ 哪个值最先偏离预期?
□ 能否把剩余搜索范围再缩小一半?
□ 日志是否包含身份、状态和时间,同时没有泄露秘密?
□ 条件断点能否回答一个具体问题?
□ 能否删掉一半复现内容,同时保留故障?
□ 观察行为是否可能改变了时序或顺序?
□ 同一个案例是否在修改前失败、修改后通过,并已变成测试?
□ 临时日志、开关、休眠和调试权限是否已经移除?调试速度来自拒绝瞎猜。复现、缩小范围、检查、解释、证明。工具会变,这个顺序不会变。