第 24 章 调试与排错:pdb 与 traceback 阅读
这一章教你两件事:线上炸了怎么从 traceback 一眼定位根因,以及卡住时怎么用 pdb 像 Go 里 delve 那样逐行打断点看现场——而不是满屏
墨叔:老哥,你写 Go 卡 bug 的时候,一般怎么查?我猜是 dlv debug 打个断点,或者至少 log.Printf 到处插。那我问你:Python 出了个 TypeError: unsupported operand type(s),后面跟十几行调用栈,你第一眼先看哪一行?
老哥:……一般从上往下扫,看到自己写的文件就点进去。
墨叔:这是 90% 新手都会犯的方向性错误。Python 的 traceback 要从下往上看。最底下那一行才是「炸弹炸响的地方」,往上才是「谁引来了它」。今天咱们把排错这摊事拆成:traceback 的正确读法、pdb 断点调试、logging 辅助定位、以及几个经典「诡异 bug 套路」。每样都和 Go 对照。
一、traceback 从下往上读:炸弹在底,引线在上
先看一个真实感的错误现场:
def shipping_fee(weight):
return 5 + weight * 2 # 假设 weight 是数字
def build_order(item):
return {"fee": shipping_fee(item["weight"])} # item["weight"] 可能是 str
def handle(payload):
return build_order(payload["items"][0])
handle({"items": [{"weight": "3kg"}]}) # 故意传错类型跑起来你会看到(节选):
Traceback (most recent call last):
File "demo.py", line 9, in <module>
handle({"items": [{"weight": "3kg"}]})
File "demo.py", line 7, in handle
return build_order(payload["items"][0])
File "demo.py", line 4, in build_order
return {"fee": shipping_fee(item["weight"])}
File "demo.py", line 2, in shipping_fee
return 5 + weight * 2
TypeError: can only concatenate str (not "int") to str
墨叔:老哥,你看这个栈,最上面 line 9 是入口,最下面 line 2 才是真正崩溃的那行。为什么从下往上读?因为错误信息 can only concatenate str ... to str 说的是「在 shipping_fee 里,5 是个 str 而不是 int,跟它相加的东西是 int」——等等,5 明明是字面量 int,怎么变 str 了?顺着栈往上一格看 line 4:item["weight"] 被当参数传进去了,而调用方 line 9 传的是 "3kg" 这字符串。所以真凶在 line 9 的数据,炸弹在 line 2 被引爆。
这就是精髓:类型错误往往「引爆点」和「病因点」是两层。从下往上看,先看「炸在哪、报的什么」锁定机制,再顺着往上找「谁把这个坏数据喂进来的」锁定源头。Go 的 panic 栈也是同构(goroutine ... [running] 之后从下往上也是被调在最下),思路完全一样——只是 Go 你更习惯直接看栈顶函数名,Python 你得看「最底行的表达式 + 报错类型」。
几个 traceback 阅读铁律:
- 先看最后一行的异常类型(
TypeError/ValueError/KeyError/AttributeError…),它直接告诉你「这一类问题」。 - 看最底行你自己的代码(不是库代码),那通常最近真实现场。
- 看
^指针:SyntaxError会画出箭头指向出错字符,比报错文字更准。 During handling of the above exception, another exception occurred:说明 except 块里又抛了新异常,真正的根因在「above」那一段,新异常只是二次伤害——别被下面那段带偏。
二、pdb:Python 的 delve,断点打到行
Go 里你 dlv debug 然后 b main.go:42、c、n、s、p var。Python 标准库自带 pdb,命令几乎一一对应,而且零安装。
最省事的两种进入方式:
# 方式一:在代码里埋点,运行到这就停
import pdb; pdb.set_trace() # 3.7+ 也可以写 breakpoint(),等价
# 方式二:命令行直接调,不用改代码
# python -m pdb your_script.py
# 启动后停在第一行,用命令往下走进入 pdb 交互后,常用命令(对照 delve):
| 动作 | pdb 命令 | Go delve 近似 |
|---|---|---|
| 列出当前行附近代码 | l(ist) | list |
| 下一步(不进函数) | n(ext) | next |
| 单步进入函数 | s(tep) | step |
| 继续跑 | c(ontinue) | continue |
| 在指定文件:行设断点 | b file.py:42 | b main.go:42 |
| 打印变量 | p var / pp var(漂亮打印) | print var |
| 查看当前函数参数 | a(rgs) | locals/args |
| 在当前行设断点 | b | break |
| 退出调试器 | q(uit) | quit |
墨叔:老哥,你发现没?n/s/c/p 这套心智模型和 delve 一模一样,区别只是 Python 是「解释器随时能停」,你甚至可以在出错那一刻事后调试。怎么事后调试?用 python -m pdb -c continue 配合 sys.excepthook,或者更实用:在 except 里掉进 pdb 看现场:
import pdb, traceback
try:
risky_call()
except Exception:
traceback.print_exc() # 先打印栈
pdb.post_mortem() # 直接进入崩溃现场,变量都还在pdb.post_mortem() 是神技:程序已经炸了,但你还能「回到爆炸那一刻」检查所有局部变量——相当于 Go 里 panic 后你还能 dlv 接上去看现场(Go 没这么顺,但 core dump 思路类似)。
老哥:那 pdb 和直接 print 比,胜在哪?
墨叔:胜在「你不用改代码重跑」。print 调试的死穴是:你猜错位置,得加一行、重跑、再看、再改——循环试错。pdb 是「跑一次,现场随便看」,想看哪个变量 p 一下,想往下走 n,想进函数 s,完全交互。而且 pdb 看的是真实运行时的对象,print 有时会因为 __str__ 重写而误导你(打印出来「好看」但对象早不是那么回事)。项目里我的规矩:卡住超过 3 分钟的 bug,关掉 print,开 pdb。
三、logging 辅助定位:把排错从「撞大运」变「有迹可循
print 调试还有个天生缺陷:它进了生产就去不掉、刷屏、没级别、没上下文。标准库 logging 才是正道,而且它和「排错」强相关——好的日志让你不用复现就能定位。
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s:%(lineno)d %(message)s",
)
log = logging.getLogger(__name__)
def charge(user_id, amount):
log.info("扣费开始 user=%s amount=%s", user_id, amount) # 结构化参数,不提前格式化
try:
do_charge(user_id, amount)
except ChargeError as e:
log.exception("扣费失败 user=%s", user_id) # exception 自动附 traceback
raise几个关键点:
log.exception自动把当前 traceback 写进日志——比你自己print(e)信息量高十倍,定位「异常从哪来」直接靠它。- 用参数化
log.info("x=%s", v)而非log.info(f"x={v}"):前者只在真的要输出时才格式化(省 CPU),后者永远先拼字符串;而且参数化对结构化日志更友好。 level分级(DEBUG/INFO/WARNING/ERROR/CRITICAL)让你本地开 DEBUG、生产开 INFO,不用删代码。
墨叔:对照 Go,你用 log/slog(Go 1.21+ 的 structured log)也是 level + key-value。Python 的 logging 比 slog 老但更灵活(handler 能把日志分流到文件/网络/队列)。排错时,logging 是「事前布防」,pdb 是「事中抓捕」,traceback 是「事后验尸」——三件套配合,没有你查不出的 bug。
四、贴近项目的例子:一个「偶发」的 None 解引用
给你一个真实项目里高频的诡异 bug:接口偶发 500,日志只说 AttributeError: 'NoneType' object has no attribute 'id',但本地死活复现不了。
def get_user(uid):
row = db.query("SELECT id, name FROM users WHERE id=?", uid)
if not row:
return None # 查不到返回 None
return User(id=row[0], name=row[1])
def render_profile(uid):
u = get_user(uid)
return {"id": u.id, "name": u.name} # u 可能是 None -> 偶发炸排错流程实战:
- 读 traceback:
AttributeError: 'NoneType' object has no attribute 'id'在最底行,说明u是None,render_profile里对None取了.id。病因在「get_user返回了 None 但调用方没检查」。 - pdb 验证:在
render_profile第一行埋breakpoint(),请求打进来后p u看到None,p uid看到是个已注销的 id——复现条件找到:uid 对应的用户不存在。 - 修法:要么
get_user抛异常而非返回 None(让「找不到」成为显式错误,符合 EAFP),要么调用方if u is None: raise NotFound。
def get_user(uid):
row = db.query(...)
if not row:
raise UserNotFound(uid) # 显式异常,而不是沉默的 None
return User(id=row[0], name=row[1])墨叔:老哥,你看这个 bug 的「诡异」在哪?它不总是炸,只有「特定 uid」才炸,所以你本地用正常数据测永远绿。教训有两条:① 返回 None 是隐式契约,调用方容易忘检查;能在「找不到就是异常」的语义下,宁可抛异常。② 日志要记关键入参(这里 uid),否则事后你看日志连「哪个 uid 触发的」都不知道。这正是 log.info("查询用户 uid=%s", uid) 的价值。
五、经典诡异 bug 套路:四个必踩的坑
讲完工具,给你四个 Python 独有、Go 工程师最容易栽的「反直觉」坑,每个都配排错思路。
1. 可变默认参数(mutable default argument)
def add_tag(tags=[]): # 坑:默认列表在函数定义时创建一次
tags.append("done")
return tags
add_tag() # ['done']
add_tag() # ['done', 'done'] —— 第二次调用拿到的是同一个列表!根因:Python 的默认参数是在函数定义时求一次值并绑定,不是每次调用新建。Go 没有默认参数这机制,所以你从没遇过。排错:看到「状态跨调用累积」就怀疑默认参数是可变对象。修法:tags=None 然后在函数体里 tags = tags or []。
2. 闭包捕获循环变量(late binding)
callbacks = []
for i in range(3):
callbacks.append(lambda: i) # 三个 lambda 都引用同一个 i
print([c() for c in callbacks]) # [2, 2, 2],不是预期的 [0,1,2]根因:闭包捕获的是变量本身(i 的绑定),不是循环当时的值;循环结束 i=2,三个函数读到的都是 2。Go 的 for 在 1.22 之前也有类似闭包捕获问题(后来改了语义),所以这坑你其实半熟。修法:用默认参数把值「冻结」,lambda i=i: i,或 functools.partial。Python 3.11 在列表推导里已修正了推导式的闭包,但普通 for 仍是 late binding,别混。
3. == vs is
a = [1, 2]; b = [1, 2]
print(a == b) # True,值相等
print(a is b) # False,不是同一个对象
x = 256; y = 256
print(x is y) # True?——小整数驻留,别依赖!== 比值,is 比身份(是不是同一个对象)。Python 对小整数(-5~256)和短字符串有驻留优化,导致 x is y 偶尔 True,但这是实现细节,永远用 == 比值,is 只用于 x is None。Go 里你比的是值或指针地址,语义清晰;Python 这俩混用最容易写出「测试过了生产挂」的巧合代码。
4. 编码乱码当逻辑 bug 查
最阴险的一类:读进来的 str 其实已经是乱码(因为 open 用了错编码),你却当成「数据本来就这样」去 debug 业务逻辑,查一下午。排错第一斧:先用 repr(s) 或 s.encode('utf-8') 看字节,确认数据本身没问题,再查逻辑。很多「诡异的字符串不匹配」其实是 utf-8-sig 的 BOM、\r\n 的行尾、或 gbk/utf-8 错配导致的。
五·补、pdb 进阶:条件断点与现场改值
基础 n/s/c/p 够日常,但有两种进阶用法能省你大量时间。
条件断点:只在满足某条件时才停,避免「正常数据跑几百次、我只在出错的那个 uid 停」。
(Pdb) b 42, uid > 100000 # 第 42 行且 uid>10万 才断
(Pdb) b myapp.py:88, tag == "vip"
否则你 c 一下跑过去几百次才撞上目标——条件断点让它「自动挑那一次」。
现场改值:pdb 里 ! 前缀能执行任意 Python 表达式,包括修改当前作用域的变量,用来「假设这个值是对的,后面还炸不炸」快速隔离:
(Pdb) !user_id = 1 # 把当前变量改成 1,继续跑看是否还炸
(Pdb) !import pprint; pprint.pprint(locals()) # 现场导包、打印
这相当于 Go 里你在 dlv 用 set userID = 1 改局部变量再 continue 验证假设——排错时「先假定某个输入是对的,验证下游是否无辜」是极常用的二分法。还有 interact 命令能进一个完整的 Python REPL(继承当前帧的变量),复杂探查不用一行行 p;commands 能给断点绑一串自动执行的命令,适合「每次到这都打印某变量再继续」的循环场景。pdb 看着古老,但这套交互能力在「没法加日志的线上核心」里是救命绳。
六、对照 Go 收个尾
老哥:所以 Python 排错和 Go 比,弱在哪强在哪?
墨叔:强在「解释器天然可交互」——pdb 零安装、post_mortem 直接回爆炸现场,Go 要 dlv 且编译得带调试信息。弱在「类型错误要运行时才暴露」,所以你更依赖 traceback + 测试去兜底。给你一张速查:
| Go 习惯 | Python 对应 | 提醒 |
|---|---|---|
| panic 栈从下往上看 | traceback 从下往上看 | 机制同构,看最底行报错类型 |
dlv debug 断点 | pdb / breakpoint() | 命令 n/s/c/p 几乎一致 |
| panic 后看 core | pdb.post_mortem() | 不用 core,直接回现场 |
slog 结构化日志 | logging + log.exception | exception 自动附栈 |
| 编译期抓类型错 | traceback + mypy | 运行时炸,靠日志留现场 |
显式 error 返回 | 抛异常 / 别返回 None | None 是隐式契约,易漏查 |
一句话带走:traceback 从下往上看、pdb 是随身的 delve、log.exception 留现场、可变默认参数/闭包/is/编码 这四个坑见一次记一辈子。排错不是玄学,是把「爆炸现场」当成证据链,一步步还原作案过程。
【思考题】
- 一段代码偶发抛
AttributeError: 'NoneType' object has no attribute 'items',本地无法稳定复现。请基于这一章,列出你的完整排查流程(从读 traceback 到最后定位),并说明为什么这种「偶发 None」比「稳定报错」更危险、以及如何在代码层面(而不是靠复现)提前防住它。 - 下面这段代码的输出是什么,为什么?如果要让它输出
[0, 1, 2],有几种改法?请至少给出两种,并解释「闭包 late binding」在这背后的机制,以及为什么它和 Go 1.21 及以前for循环闭包的坑是同一类问题。fs = [] for i in range(3): fs.append(lambda: i) print([f() for f in fs]) - 你习惯用
print调试。请对比print和pdb在「定位一个发生在深层调用栈、且只在特定输入下出现」的 bug 时,各自的代价。什么情况下print仍然是可以接受的?什么情况下必须上 pdb?再结合 Go 的dlv,说说你以后会怎么选工具。
【参考答案】
- 完整流程:① 读 traceback 最底行,锁定
AttributeError: 'NoneType'...'items',说明某处对None调了.items();顺着栈往上找哪个变量是None(通常是「查不到/解析失败返回了 None,调用方没判空」)。② 在报错的那个函数入口埋breakpoint()(或加log.exception+ 关键入参日志),用能触发的一次输入跑,进 pdb 后p相关变量确认确实是None。③ 定位根因:上游函数返回None而非抛异常/空字典,调用方直接.items()。④ 修:让上游在「找不到」时显式raise异常(EAFP),或调用方if x is None: raise/返回。为什么偶发更危险:稳定报错你能立刻复现修;偶发意味着「只在特定数据/边界下触发」,常规测试覆盖不到,可能潜伏很久才在生产炸,且你无法本地复现就无从下手。防住它的代码层手段:a) 用 mypy,Optional[X]标注让调用方编译期/静态期被迫处理None;b) 约定「找不到即异常」而非返回 None,把隐式契约变显式;c) 关键路径加assert x is not None或if x is None: raise早失败;d) 日志打印触发该分支的入参,便于事后即使复现不了也能从日志反推。 - 输出是
[2, 2, 2]。原因:三个lambda捕获的是变量名i的绑定,不是循环当次的值;循环结束i停在 2,三个函数执行时都读「当前 i」,所以都是 2。这叫 late binding(延迟绑定):闭包在被调用时才去解析自由变量,而非定义时。改法一(默认参数冻结值):fs.append(lambda i=i: i)——把i的当时值绑成函数默认参数,调用时优先用默认参数。改法二(functools.partial):fs.append(partial(lambda x: x, i))。改法三(立即绑定到局部):包一层def make(i): return lambda: i; fs.append(make(i))。它和 Go 1.21 前for循环闭包是同一类问题:Go 旧版for的循环变量在整个循环是同一个变量地址,goroutine 捕获它,循环结束后读到的也是最终值,表现与 Python 的 late binding 完全一致;Go 1.22 起改为每次迭代新建循环变量才修掉。Python 这边普通for至今仍是 late binding(只有推导式在 3.11 修过),所以得靠上面几种「值冻结」手段。 print调试代价:你得预判 bug 在哪、改代码插 print、重跑、看输出、再挪 print——是「试错循环」,且 print 出的str可能被对象的__str__重写误导;进了生产还得删。pdb 代价:要会命令、交互式、对「纯批处理/远程无终端」场景接入麻烦。可接受 print 的情况:bug 定位明确(你知道大概哪几行)、一次性本地验证、或远程环境根本没 pdb 入口时,临时print(repr(x))拉日志也务实。必须上 pdb 的情况:bug 在深层栈(print 要插七八处)、只在特定输入触发(print 循环试错成本爆炸)、或你想看「运行时对象真实状态」而非它的字符串表示。对照 Go:你用dlv也是「交互式回现场」,和 pdb 同档;当你在 Go 里会开dlv,在 Python 里就该开pdb,而不是退而求其次 print 刷屏。我的选法:定位清晰的浅层问题,print/日志速战;深层、偶发、需看现场对象的问题,直接 pdb(或 log.exception 留现场)。工具选对,排错时间能差一个数量级。