← 返回题目列表

Python 怎么用 pdb 调试?breakpoint() 和事后调试怎么用?

中等 第 24 / 27 题 更新于 2026/07/31
pdb调试breakpointpost-mortem

简化版

pdb 是 Python 自带的交互式调试器,核心价值是「让程序在你想要的位置停下来,然后你可以查看任意变量、单步执行、甚至改变量继续跑」——比反复加 print 再重跑快一个数量级。打断点最简单的方式是 Python 3.7+ 的内置函数 breakpoint():在代码里写一行 breakpoint(),运行到这里就进入调试器;它比老写法 import pdb; pdb.set_trace() 更好的地方在于可以用环境变量 PYTHONBREAKPOINT=0 一键全局禁用(上线时不怕漏删),也可以 PYTHONBREAKPOINT=ipdb.set_trace 换成别的调试器。必须记住的命令(都有单字母缩写):n(next,执行下一行、不进函数)、s(step,进入函数)、c(continue,跑到下一个断点)、r(return,跑到当前函数返回)、l/ll(看代码)、p/pp(打印表达式)、w(where,看调用栈)、u/d(在栈帧间上下移动)、b(设断点,支持条件断点 b 42, x > 100)、q(退出)。最实用的两个高级用法:① 事后调试——程序崩溃后用 python -m pdb -c continue app.pypdb.post_mortem() 直接进入异常发生时的现场,能查看当时所有局部变量;② pytest --pdb 让测试失败时自动落进调试器。核心记忆:breakpoint() 打断点,n/s/c/p/w 五个命令覆盖 90% 场景,崩溃现场用 post-mortem。

详细版

高频命令速查

命令全称作用
nnext执行当前行,不进入函数内部
sstep执行当前行,进入函数内部
ccontinue继续跑到下一个断点
rreturn一直跑到当前函数返回
until Nuntil跑到行号 ≥ N(跳出循环的利器)
l / lllist / longlist看当前位置附近代码 / 看整个函数
p / ppprint / pretty print打印表达式 / 美化打印
wwhere打印当前调用栈
u / dup / down在调用栈里上移 / 下移一帧
aargs打印当前函数的所有参数
b 42 / b mod.funcbreak在行号/函数处设断点
b 42, x > 100——条件断点(只在条件为真时停)
cl / disableclear / disable删除 / 停用断点
display xdisplay每次停下来自动显示 x 的值
interact——进入完整的 Python 交互式环境
qquit退出调试器
# ① 最常用:代码里插一行(Python 3.7+)
def compute(items):
    total = 0
    for i, item in enumerate(items):
        breakpoint()                # ← 运行到这里进入调试器
        total += item["value"]
    return total

#   老写法(3.6 及以下):import pdb; pdb.set_trace()
#   ★ breakpoint() 的独有好处:
#     PYTHONBREAKPOINT=0 python app.py          → 所有 breakpoint() 全部失效(上线保险)
#     PYTHONBREAKPOINT=ipdb.set_trace python …  → 换成 ipdb(带补全和语法高亮)

# ② 条件断点:只在"第 500 次循环"或"数据异常"时停
#    (Pdb) b 12, i == 500
#    (Pdb) b utils.py:88, item.get("value") is None

# ③ 事后调试(post-mortem):崩溃之后回到现场
#    方式 A(命令行,最省事):
#      python -m pdb -c continue app.py
#      → 正常跑,一旦抛出未捕获异常,自动停在★出错的那一帧★
#    方式 B(代码里):
import pdb, sys, traceback
try:
    risky()
except Exception:
    traceback.print_exc()
    pdb.post_mortem(sys.exc_info()[2])       # 进入异常现场
#    方式 C(交互式解释器里刚崩完):
#      >>> import pdb; pdb.pm()

# ④ 测试里调试
#    pytest --pdb            # 失败时自动进 pdb
#    pytest --trace          # 每个测试开头就进 pdb
#    pytest -x --pdb -k test_parse    # 只跑某个用例、第一次失败就停

# ⑤ 调试时可以改变量、执行任意代码(不只是看)
#    (Pdb) p item
#    (Pdb) item["value"] = 0          # ★直接改,然后 c 继续跑★
#    (Pdb) pp {k: type(v) for k, v in item.items()}
#    (Pdb) interact                   # 进入完整 Python 交互环境(Ctrl-D 退回 pdb)

⚠️ 三个最容易卡住新手的点:① ns 的区别——n(next)把函数调用当成「一行」整体执行完,s(step)会钻进被调用的函数里。经验法则是默认按 n,只有确认问题在某个函数内部时才 s;不小心 s 进了标准库源码就按 r(跑到该函数返回)或 u(回到上一帧)出来。② 变量名和 pdb 命令冲突:如果你的局部变量叫 nclspb,直接输入名字会被当成命令执行(n 会单步、c 会继续跑)。解决办法是用 p n 显式打印,或用 !n(感叹号表示「这是一条 Python 语句」,如 !n = 5 给变量赋值)。③ c 之后不会再停(除非还有别的断点),所以在循环里调试时,与其反复 c,不如下条件断点 b 12, i == 500 或用 until 直接跳出循环——这是把「调试一小时」变成「调试一分钟」的关键技巧。

完整版教学

一、为什么值得学调试器:print 大法的三个成本

print 调试的真实代价(以"某次循环里数据异常"为例):
  ① 每加一处 print 就要★重跑一遍★
     程序启动 20 秒 + 跑到出错处 3 分钟 → 每验证一个猜测要 3.5 分钟
     试 10 个猜测 = 35 分钟;而 pdb 一次断点就能把所有变量都看一遍
  ② 你必须★提前猜对★要打印什么
     print(item) 之后发现该看的是 item 的类型/长度/上层变量 → 再改再跑
     pdb 里可以现场 p 任何表达式、pp 任何嵌套结构、w 看整条调用栈
  ③ 清理成本 + 泄漏风险
     调完要删干净;漏删的 print 会污染日志,甚至打印出敏感数据

调试器的独有能力(print 做不到的):
  - 看★调用栈的任意一层★的变量(w 看栈、u/d 上下移动)
  - ★修改变量后继续执行★(验证"如果这里是 0 会怎样",不用改代码重跑)
  - ★条件断点★(第 50 万次循环、或某字段为 None 时才停)
  - ★事后调试★(崩溃后回到异常现场,看当时所有局部变量)
  - 执行任意代码(interact 进入完整 Python 环境)

什么时候 print/logging 仍然更好:
  - 多线程/异步的时序问题(断点会改变时序,"一停就不复现")
  - 生产环境(不可能在线上开断点)→ 用结构化日志
  - 需要"整体趋势"而不是"某一时刻"(每次循环的值分布)
  → 结论:logging 用于"长期可观测",pdb 用于"当下这个 bug"

调试器和 print 不是对立关系,但大多数人低估了 print 大法的成本:每验证一个猜测都要改代码 + 重跑一遍,如果程序启动加复现要三分钟,试十个猜测就是半小时;而且你必须提前猜对该打印什么——打印了 item 才发现该看的是它的类型或者上层调用者传了什么。调试器把这个循环压缩成一次:断点停下后,所有局部变量、整条调用栈、任意表达式都可以随时查看,还能改掉变量继续跑来验证假设(「如果这个字段是 0 会怎样」不用改代码重跑)。反过来也要承认它的边界:多线程/异步的时序 bug 一加断点就不复现,生产环境更不可能开断点——这些场景属于 loggingpy-spy 的地盘。一句话分工:logging 负责长期可观测性,pdb 负责当下这个 bug

二、怎么进入调试器:四种入口

入口 1:breakpoint()(★Python 3.7+ 首选★)
  def f(x):
      breakpoint()          # 运行到此进入 pdb
      return x * 2

  相比老写法 import pdb; pdb.set_trace() 的三个优势:
    ① 短、内置,不用 import
    ② PYTHONBREAKPOINT=0 可以★一键全局禁用★
       → 上线前不小心漏删也不会卡住服务(这是它最大的价值)
    ③ PYTHONBREAKPOINT=ipdb.set_trace 可以★换成任意调试器★
       (ipdb 有语法高亮和 Tab 补全;pudb 是全屏 TUI)
  ★ 它的实现就是调用 sys.breakpointhook(),默认指向 pdb.set_trace

入口 2:命令行启动(从第一行就开始调试)
  python -m pdb app.py              # 停在第一行,然后 b 设断点、c 开跑
  python -m pdb -c "b 42" -c c app.py    # ★-c 预先执行 pdb 命令★(自动化)

入口 3:事后调试(程序已经崩了)
  python -m pdb -c continue app.py  # 直接跑,崩了就停在异常帧 ← ★最常用★
  代码里:pdb.post_mortem(tb)  或  交互式里刚崩完:pdb.pm()

入口 4:测试框架集成
  pytest --pdb                      # 断言失败/异常时进入 pdb
  pytest --trace                    # 每个用例开头就停
  pytest -x --pdb -k test_name      # 只跑一个用例、失败立刻停

★ 远程/容器里怎么办(本地 pdb 用不了):
  - 用 remote-pdb / debugpy(VSCode 可 attach)
  - 只想看"卡在哪"而不是断点:py-spy dump --pid 1234(★零侵入、不停服务★)
  - 容器里 docker exec -it 后再 attach(需要进程有 tty)

breakpoint() 是 Python 3.7 引入的内置函数,取代了 import pdb; pdb.set_trace() 这个流传多年的咒语。除了短,它真正的价值是那两个环境变量开关:PYTHONBREAKPOINT=0 能一键让所有 breakpoint() 失效(上线时哪怕漏删一行也不会把服务卡死在调试器里,这是老写法做不到的安全网),而 PYTHONBREAKPOINT=ipdb.set_trace 能把默认调试器换成带补全和高亮的 ipdb——原理是它会调用可替换的 sys.breakpointhook()。另外三个入口各有场合:python -m pdb app.py 从第一行开始调试(配合 -c 预执行命令可以自动化设断点);python -m pdb -c continue app.py 是排查崩溃的最高效姿势——正常跑,一旦抛出未捕获异常就停在出错的那一帧;pytest --pdb 让失败的测试直接把你送进现场。至于容器和线上,本地调试器往往用不了,此时 py-spy dump --pid 能零侵入地打印所有线程的当前调用栈,是排查「卡住了」的首选。

三、命令体系:五个命令覆盖 90% 场景

移动类(决定"下一步执行到哪"):
  n (next)      执行当前行;遇到函数调用★整体执行完★,不进去
  s (step)      执行当前行;遇到函数调用★钻进去★
  r (return)    一直执行到当前函数 return(用来"从误入的函数里出来")
  c (continue)  继续跑,直到下一个断点或程序结束
  until 120     一直跑到行号 ≥ 120(★跳出循环的利器★,不用按 100 次 n)
  j 42 (jump)   ★直接跳到第 42 行执行★(可回退重试,但不能跳进/跳出循环体等)

查看类:
  l  (list)     显示当前行前后 11 行;再按 l 继续往下翻
  ll (longlist) 显示★整个当前函数★的源码(比 l 好用)
  w  (where)    打印调用栈(当前帧用 > 标出)
  a  (args)     打印当前函数的所有参数值
  p 表达式       打印(任意 Python 表达式,如 p len(items)、p obj.__dict__)
  pp 表达式      pretty print(嵌套 dict/list 会格式化换行,★看大结构必用★)
  display x     每次停下自动显示 x(值变了才提示);undisplay 取消

栈帧类(★调试的关键能力★):
  u (up)        上移一帧(去到"调用我的那个函数",看它的局部变量)
  d (down)      下移一帧
  → 异常现场里最有用:崩在第三方库里,u 几次回到自己的代码看是哪个参数不对

断点类:
  b                  列出所有断点
  b 42               在当前文件第 42 行设断点
  b utils.py:88      在指定文件的行设断点
  b mymod.parse      在函数入口设断点
  b 42, x > 100      ★条件断点★:只有 x > 100 时才停
  tbreak 42          临时断点(命中一次后自动删除)
  cl 1               删除 1 号断点;cl 删除全部(会问 y/n)
  disable 1 / enable 1   停用/启用(保留断点定义)
  ignore 1 500       ★忽略前 500 次命中★(等价于"第 501 次才停")

执行任意代码:
  !语句              执行 Python 语句(如 !x = 5 改变量、!import json)
  interact           进入完整 Python 交互环境(Ctrl-D 退回 pdb)

★ 变量名和命令冲突时:变量叫 n/c/l/s/p/b/a 时直接输名字会执行命令
  → 用 p n 打印,用 !n = 5 赋值
★ 直接回车 = 重复上一条命令(连续按 n 时非常顺手)

命令看着多,实际五个就覆盖 90% 的场景:nscpw。要真正提速,关键是掌握三组「省时间」的命令:untilignore——在循环里调试时,与其按一百次 n 或反复 c,不如 until 120 直接跳出循环、或 ignore 1 500 让断点忽略前 500 次命中;u/d 在栈帧间移动——异常往往崩在第三方库深处,w 看栈后 u 几次回到自己的代码,才能看到「是我传错了什么参数」;ppdisplay——pp 把嵌套的 dict/list 格式化输出,display x 让每次停下自动显示某个变量的变化。两个必须记住的细节:变量名和命令冲突时用 p 名字 查看、!名字 = 值 赋值! 表示「这是一条 Python 语句」);直接按回车会重复上一条命令,连续单步时非常顺手。

四、断点技巧:把「调一小时」变成「调一分钟」

场景 1:循环跑 100 万次,只有某一次数据异常
  ✗ 笨办法:breakpoint() 放循环里 → 按 c 按到手废
  ✓ 条件断点:
    (Pdb) b 42, item.get("value") is None
    (Pdb) c            # 直接跳到出问题的那一次
  ✓ 或者代码里写条件:
    if item.get("value") is None:
        breakpoint()

场景 2:知道最终会崩,但不知道在哪一层
  ✓ python -m pdb -c continue app.py
    → 崩溃时自动停在异常帧,w 看栈、u 回到自己的代码、p 查看当时的变量
    ★ 这比看 traceback 强的地方:traceback 只有行号,pdb 能看到★当时所有变量的值★

场景 3:第三方库里报错,想看它拿到了什么参数
  ✓ b requests/models.py:812        # 直接给库文件下断点
  ✓ 或崩溃后 post-mortem,然后 u 逐层上移,a 打印每层的参数

场景 4:想验证"如果这个值不同会怎样"
  ✓ (Pdb) !config["timeout"] = 30
    (Pdb) c                          # 改完继续跑,不用改代码重启

场景 5:只想跳过某段代码(跳过一次昂贵的初始化)
  ✓ (Pdb) j 88                       # jump 到第 88 行
    ★ 限制:不能跳进/跳出 for 循环体、finally 块、以及跨函数

场景 6:想每次停下都看某几个变量
  ✓ (Pdb) display total
    (Pdb) display len(buf)
    → 之后每次停下会自动显示(且只在★值变化时★提示)

进阶:用 .pdbrc 保存常用配置(放家目录或项目根目录)
  # ~/.pdbrc
  alias pi pp {k: v for k, v in %1.__dict__.items() if not k.startswith('_')}
  alias ll longlist
  → 每次进 pdb 自动加载,把高频操作固化成一条命令

真正拉开调试效率差距的不是命令数量,而是会不会用条件断点。「循环跑一百万次、只有某一次出问题」是最典型的场景:按 c 按到手废是新手做法,写 b 42, item.get("value") is None 一次到位才是熟手做法(等价的代码写法是把 breakpoint() 放进 if 异常条件: 里,在需要复杂判断时更灵活)。第二个高价值技巧是事后调试 + 栈帧移动python -m pdb -c continue app.py 让程序正常跑、崩了自动停在异常帧,此时 w 看栈、u 逐层上移、a 打印每层参数——这比看 traceback 强的地方在于 traceback 只有行号和异常信息,而 pdb 能看到崩溃瞬间所有变量的实际值。再加上 !变量 = 新值 现场改数据继续跑(验证假设不用改代码重启)、j 跳过昂贵的初始化、display 自动跟踪变量变化,以及把高频操作写进 .pdbrc,一小时的排查常常能压到几分钟。

五、事后调试:崩溃现场是最有价值的信息

traceback 给你的:出错的文件、行号、异常类型和消息
post-mortem 给你的:★出错瞬间每一层栈帧的全部局部变量★

三种进入方式:
  ① 命令行(推荐,零代码改动):
     python -m pdb -c continue app.py
     Traceback ... KeyError: 'user_id'
     > app.py(42)parse()
     (Pdb) p record            # ← 看到底是哪条记录没有 user_id
     (Pdb) w                   # ← 看是谁传进来的
     (Pdb) u                   # ← 上移到调用方,看它的变量

  ② 代码里包住可疑区域:
     import pdb, sys, traceback
     try:
         risky()
     except Exception:
         traceback.print_exc()
         pdb.post_mortem(sys.exc_info()[2])

  ③ 交互式解释器里刚崩完:
     >>> run_something()
     Traceback ...
     >>> import pdb; pdb.pm()      # pm = post mortem,回到刚才的异常现场

  ④ 全局兜底(脚本调试期用,★别上生产★):
     import sys, pdb
     def hook(exc_type, exc, tb):
         traceback.print_exception(exc_type, exc, tb)
         pdb.post_mortem(tb)
     sys.excepthook = hook

在异常现场最该做的四件事:
  1. w        看完整调用栈(搞清是哪条路径过来的)
  2. u/d      移动到★自己写的那一帧★(异常常发生在库内部)
  3. a / p    打印参数和关键变量(找到"哪个值不对")
  4. pp obj.__dict__ / p type(x) / p len(x)   看清对象的真实形态

★ 一个关键限制:post-mortem 需要异常★没有被捕获★(或你手动传 tb 进去)
  被 except 吞掉的异常不会触发 post-mortem
  → 调试期可以临时把 except 改成 except Exception: raise,或在 except 里 post_mortem

pytest 里的等价物:
  pytest --pdb        # 失败即进入现场(★测试调试最常用★)
  pytest --pdbcls=IPython.terminal.debugger:TerminalPdb --pdb   # 换成 ipdb

事后调试(post-mortem)是 pdb 最被低估的功能。traceback 只告诉你「在 app.py:42 抛了 KeyError: 'user_id'」,而 post-mortem 让你回到崩溃发生的那一瞬间,看到每一层栈帧里所有变量的真实值——「到底是哪条记录缺字段」「上游传进来的参数长什么样」这些问题当场就有答案,不需要加日志重跑。最省事的用法是命令行 python -m pdb -c continue app.py(零代码改动),交互式环境里刚崩完则可以 import pdb; pdb.pm()。进入现场后的标准动作是四步:w 看完整调用栈 → u/d 移动到自己写的那一帧(异常经常发生在第三方库内部)→ a/p 打印参数和关键变量 → pp obj.__dict__ 看清对象真实形态。一个关键限制要记住:post-mortem 依赖异常「没有被捕获」,被 except 吞掉的异常不会触发它——调试期可以临时在 except 里手动调 pdb.post_mortem()

六、pdb 的局限与更好的选择

pdb 不擅长的场景:
  ① 多线程 / 异步
     - 断点只停当前线程,其他线程继续跑 → 状态在你眼皮底下变化
     - 断点会改变时序 → 竞态 bug "一调试就不复现"(海森堡 bug)
     → 用日志 + 线程名,或 py-spy dump 看所有线程的栈
  ② 生产环境
     - 不能停服务;也不该在生产暴露交互式 shell(安全风险)
     → 结构化日志 + py-spy(采样、attach、零侵入)+ APM
  ③ 长循环里"看趋势"
     - 想知道 100 万次循环里值的分布 → 断点没意义
     → 打日志/收集统计再分析
  ④ 界面朴素(无高亮、无补全、看不到上下文)

替代与增强:
  ┌────────────────┬──────────────────────────────────────┐
  │ ipdb           │ pdb + Tab 补全 + 语法高亮(最低成本升级)│
  │ pudb           │ 全屏 TUI,同时显示源码/变量/栈,很直观   │
  │ IDE 调试器      │ VSCode/PyCharm:鼠标点断点、悬停看值    │
  │ debugpy        │ 远程/容器调试(VSCode attach 到进程)    │
  │ py-spy dump    │ ★不停进程★看所有线程当前栈(线上首选)    │
  │ faulthandler   │ 标准库:段错误/卡死时自动打印栈           │
  │ logging        │ 长期可观测性(生产唯一可行方案)          │
  └────────────────┴──────────────────────────────────────┘

用 ipdb 替换默认调试器(一行环境变量,不改代码):
  PYTHONBREAKPOINT=ipdb.set_trace python app.py

上线保险(★重要★):
  - CI 里加检查:禁止提交 breakpoint() / pdb.set_trace()
    (ruff 规则 T100、flake8-debugger 都能检测)
  - 运行时兜底:生产环境设 PYTHONBREAKPOINT=0
  → 双保险,避免"漏删一行断点把服务卡死"这种事故

选择心法:
  当下这个 bug、能本地复现 → pdb / IDE 调试器
  线上、不能停 → 日志 + py-spy
  时序/并发 → 日志(断点会改变时序)
  性能问题 → profiler(不是调试器)

pdb 有明确的边界,硬用会越调越乱。多线程和异步是第一个雷区:断点只停当前线程、其他线程继续跑,而且断点本身改变了时序,导致竞态 bug「一调试就不复现」(所谓海森堡 bug)——这类问题只能靠日志和 py-spy dump生产环境是第二个雷区:既不能停服务,也不该暴露交互式 shell(能执行任意代码,是严重的安全风险),线上定位靠结构化日志加 py-spy(采样、可 attach、零侵入)。工具上有几个低成本升级值得知道:ipdb 给 pdb 加上 Tab 补全和语法高亮(用 PYTHONBREAKPOINT=ipdb.set_trace 一个环境变量就能换,不改代码)、pudb 是全屏 TUI、IDE 调试器适合复杂断点场景、faulthandler(标准库)能在段错误或卡死时自动打印栈。最后是工程纪律:CI 里用 ruff 的 T100 规则禁止提交 breakpoint(),生产环境同时设 PYTHONBREAKPOINT=0 做兜底,双保险避免漏删的断点把服务卡死。

记忆钩子:「调试的入口只需记一个:breakpoint()(3.7+ 内置,取代 import pdb; pdb.set_trace()),它的独门价值是环境变量开关——PYTHONBREAKPOINT=0 一键全局禁用(上线漏删也不怕)、PYTHONBREAKPOINT=ipdb.set_trace 一键换成带补全高亮的 ipdb。★命令五个够用:n(下一行,不进函数)、s(进函数)、c(跑到下个断点)、p/pp(打印表达式)、w(看调用栈)+ u/d(在栈帧间上下移动,异常崩在库里时靠它回到自己的代码)。★真正提速的是三招:条件断点 b 42, x is None(循环百万次只停出问题那次)、until/ignore 1 500(跳出循环、忽略前 N 次命中)、!x = 5 现场改变量继续跑(验证假设不用改代码重启)。★最被低估的是事后调试:python -m pdb -c continue app.py 让程序正常跑、崩了自动停在异常帧——traceback 只有行号,post-mortem 能看到崩溃瞬间每一层栈帧的全部变量值(交互式里刚崩完用 pdb.pm(),测试里用 pytest --pdb)。★两个卡人的小坑:变量名和命令冲突时要用 p n 打印、!n = 5 赋值;直接回车=重复上一条命令。★边界:多线程/异步的时序 bug 一加断点就不复现、生产环境绝不能开(用日志 + py-spy dump),CI 里用 ruff T100 禁止提交断点、生产设 PYTHONBREAKPOINT=0 兜底。」

七、常见误区与追问

  • 误区:breakpoint() 只是 import pdb; pdb.set_trace() 的简写,用哪个都一样。 短只是表象,真正的区别在于 breakpoint() 走的是可替换的 sys.breakpointhook(),因此获得两个老写法没有的能力:① PYTHONBREAKPOINT=0 能一键让所有 breakpoint() 失效——这是重要的生产安全网,万一有人漏删一行断点提交上线,服务也不会卡死在调试器里等一个永远不会来的输入;② PYTHONBREAKPOINT=ipdb.set_trace(或 pudb、debugpy)能整体切换调试器,团队里谁想用带补全高亮的 ipdb,设个环境变量就行,不用改一行代码。而 pdb.set_trace() 是硬编码的,两种能力都没有。
  • 误区:ns 差不多,随便按哪个都能往下走。 差别很关键:n(next)把函数调用当作「一行」整体执行完,不进入函数体;s(step)会钻进被调用的函数内部。不清楚这个区别最常见的后果是手一抖 s 进了标准库或第三方库的源码,然后在几十层陌生代码里迷路。经验法则是默认按 n,只有确认问题出在某个函数内部时才 s;不小心进去了,用 r(跑到当前函数返回)u(上移一帧回到调用方) 出来。另外 until N 能一次跑到指定行(跳出循环神器),c 则一直跑到下一个断点——熟练组合这几个命令,比一路按 n 快得多。
  • 误区:程序崩了只能看 traceback,然后加 print 重跑。 这正是事后调试(post-mortem) 要解决的问题:traceback 只有文件、行号、异常类型,而 post-mortem 让你回到崩溃发生的那一瞬间,查看每一层栈帧的全部局部变量。最省事的方式是 python -m pdb -c continue app.py——程序正常跑,一旦抛出未捕获异常就自动停在异常帧,然后 w 看栈、u 回到自己的代码、p 查看当时的值;交互式解释器里刚崩完可以 import pdb; pdb.pm();测试里用 pytest --pdb。唯一的前提是异常没有被 except 吞掉(被捕获的异常不会触发 post-mortem,需要在 except 里手动调 pdb.post_mortem())。
  • 误区:在循环里下个断点,然后一直按 c 就能调到出问题的那次。 循环一百万次时这是不可行的。正确做法有三种:① 条件断点 b 42, item.get("value") is None——只在条件为真时才停,一次到位;② ignore 1 500——让 1 号断点忽略前 500 次命中(等价于「第 501 次才停」),适合已知「第 N 次出问题」的情况;③ 代码里写 if 异常条件: breakpoint(),在判断逻辑复杂时比条件断点表达式更灵活。此外 until 行号 能直接跑到循环之后的某一行,省去按几百次 n。会不会用这几招,是调试效率相差一个数量级的分水岭。
  • 误区:变量叫 nc 时,在 pdb 里输入名字就能看到它的值。 不能——pdb 会优先把输入当作命令,输入 n 会执行「单步」、输入 c 会「继续运行」,你的程序就这么被推着往前跑了,还找不到原因。解决办法有两个:查看用 p np 后面跟任意 Python 表达式),赋值和执行语句用 ! 前缀(!n = 5!import json),感叹号明确告诉 pdb「这是一条 Python 语句而不是命令」。相关的还有一个常用小技巧:直接按回车会重复上一条命令,连续单步时很顺手,但也意味着你如果刚按过 c,随手一个回车就又继续跑了。
  • 追问:为什么多线程和异步程序不适合用 pdb 调试? 两个原因。① 断点只停住当前线程,其他线程继续运行——你在断点处看到的「状态」可能在你敲下一条命令的过程中已经被别的线程改掉了,看到的是幻觉;协程也类似,事件循环里其他任务的行为会被打乱。② 断点本身改变了时序:竞态条件(race condition)依赖特定的执行交错,一旦某个线程被停住几秒钟,那个交错就不会出现了——bug「一调试就不复现」,这就是所谓的海森堡 bug(观测行为改变了被观测对象)。这类问题的正确工具是带线程名和时间戳的结构化日志(不改变时序)、py-spy dump --pid(零侵入地打印所有线程当前栈,排查死锁和卡死)、以及针对性的压力测试和 threading 的同步原语审查。
  • 追问:线上服务出问题,不能用 pdb,那用什么? 分三种情况。① 卡住/不响应py-spy dump --pid <PID> 打印所有线程的当前调用栈——不需要改代码、不需要重启、开销极小,一眼看出卡在哪个函数(等锁、等网络、还是死循环);标准库的 faulthandlerfaulthandler.enable()-X faulthandler)也能在收到特定信号或段错误时 dump 栈。② py-spy top --pid 实时看 CPU 花在哪些函数上,或 py-spy record 出火焰图。③ 逻辑错误:只能靠结构化日志(带 request_id、关键参数、耗时)和 APM 链路追踪事后还原——这也是为什么日志要在平时就打好,出事时没有日志就只能盲猜。绝对不要在生产暴露交互式调试器:它能执行任意代码,是严重的安全风险,且会挂起服务。
  • 追问:怎么防止 breakpoint() 被误提交到生产? 双保险。第一层是 CI 静态检查ruffT100 规则(对应 flake8-debugger 插件)能检测出代码里的 breakpoint()pdb.set_trace()ipdb.set_trace() 等调试语句,在 lint 阶段直接让流水线失败;也可以配 pre-commit hook 在本地提交时就拦住。第二层是运行时兜底:生产环境统一设置环境变量 PYTHONBREAKPOINT=0,这样即使真有一行漏网的 breakpoint() 上了线,它也只是空操作(sys.breakpointhook 被设成什么都不做),服务不会挂起等待一个永远不会到来的输入。这两层配合,才能既放心地在开发时随手插断点,又不担心它跑到线上——这也正是 breakpoint() 相比 pdb.set_trace() 的核心优势。

八、加强记忆

pdb 的价值是把「改代码加 print → 重跑 → 再猜」的循环压缩成一次断点:停下后所有局部变量、整条调用栈、任意表达式都能随时查看,还能改掉变量继续跑来验证假设。入口只需记 breakpoint()(Python 3.7+ 内置,取代 import pdb; pdb.set_trace()),它的独门价值来自可替换的 sys.breakpointhook()——PYTHONBREAKPOINT=0 一键全局禁用(上线漏删也不会卡死服务)、PYTHONBREAKPOINT=ipdb.set_trace 一键换调试器命令五个够用n(下一行、不进函数)、s进函数,误入库源码就用 ru 出来)、c(跑到下个断点)、p/pp(打印/美化打印表达式)、w(看调用栈),再加 u/d 在栈帧间上下移动(异常崩在第三方库里时,靠它回到自己的代码看是哪个参数不对)。真正拉开效率的是三招条件断点 b 42, x is None(循环百万次只停在出问题那次)、until 行号 / ignore 1 500(跳出循环、忽略前 N 次命中)、!x = 5 现场改变量继续跑(验证假设不用改代码重启)。最被低估的是事后调试python -m pdb -c continue app.py 让程序正常跑、崩了自动停在异常帧——traceback 只有行号,post-mortem 能看到崩溃瞬间每一层栈帧的全部变量值(交互式里刚崩完用 pdb.pm(),测试里用 pytest --pdb);前提是异常没有被 except 吞掉两个卡人的小坑:变量名与命令冲突时用 p n 查看、!n = 5 赋值;直接回车等于重复上一条命令。边界要清楚:多线程/异步的时序 bug 一加断点就不复现(断点只停当前线程且改变时序),生产环境绝不能开交互式调试器——线上排查用结构化日志 + py-spy dump/top;工程上用 ruff 的 T100 规则拦住提交、生产设 PYTHONBREAKPOINT=0 兜底