Python 怎么调试?pdb、日志和生产环境排查有哪些手段?
简化版
调试的手段要按「问题类型」和「环境」分开看。本地开发最常用三样:breakpoint()(Python 3.7+ 的内置函数,比 import pdb; pdb.set_trace() 简洁,还能通过 PYTHONBREAKPOINT 环境变量全局关闭或切换成 ipdb)、IDE 断点(条件断点、表达式求值最好用)、以及打印/日志(别看不起它——在异步、多线程、循环几万次的场景下,日志往往比单步调试更有效)。测试失败时用 pytest --pdb(失败自动进调试器,现场还在)、--trace(每个测试开头就断下)、-l(显示局部变量)。关键的一点认知:pdb 是「事后/断点式」调试,而生产环境的问题往往「不可复现、不能停」——这时要用非侵入式的观测手段:py-spy(不用改代码、不用重启,直接 attach 到运行中的进程 dump 所有线程的调用栈,是排查「卡住了」「CPU 100%」的第一选择)、faulthandler(进程崩溃或收到信号时自动打印栈)、以及结构化日志 + request_id(把一次请求的所有日志串起来)。性能问题分两类:CPU 密集用采样 profiler(py-spy record 生成火焰图,开销极低可以在生产用;cProfile 精确但有开销),内存问题用 tracemalloc(标准库,能对比两个时刻的内存快照找出增长点)。异步代码的调试有额外难点——调用栈被 await 打断、asyncio.run 里的异常可能被吞——要用 asyncio debug 模式和 asyncio.all_tasks() 看任务状态。核心记忆:breakpoint() + IDE 断点做本地调试;pytest --pdb 保留失败现场;生产用 py-spy 非侵入式 dump 栈;性能分 CPU(采样)和内存(tracemalloc)。
详细版
按场景选工具:
| 场景 | 首选工具 |
|---|---|
| 本地逻辑错误 | IDE 断点 / breakpoint() |
| 测试失败 | pytest --pdb -l |
| 生产「卡住了」 | py-spy dump(不用重启) |
| CPU 100% | py-spy top / record 火焰图 |
| 内存泄漏 | tracemalloc / memray |
| 崩溃/段错误 | faulthandler |
| 异步问题 | asyncio debug 模式 |
# ① ★★breakpoint():现代写法★★
def process(data):
result = transform(data)
★breakpoint()★ # ★★Python 3.7+ 内置★★
return validate(result)
# ★环境变量控制★
# ★PYTHONBREAKPOINT=0★ → ★全局禁用(生产保险)★
# ★PYTHONBREAKPOINT=ipdb.set_trace★ → ★换成 ipdb(更好用)★
# ★PYTHONBREAKPOINT=IPython.terminal.debugger.set_trace★
# ② ★★pdb 常用命令(背下来)★★
# ★l(ist)★ 看当前代码 ★ll★ 看整个函数
# ★n(ext)★ 下一行(不进函数)
# ★s(tep)★ 进入函数
# ★r(eturn)★ 执行到当前函数返回
# ★c(ontinue)★ 继续运行
# ★u(p) / d(own)★ ★★在调用栈上下移动(最有用)★★
# ★w(here)★ 打印调用栈
# ★p / pp★ 打印 / 美化打印
# ★a(rgs)★ ★打印当前函数的所有参数★
# ★b 文件:行号★ 设断点 ★b 行号, 条件★ ★条件断点★
# ★tbreak★ 临时断点(命中一次后删除)
# ★display 表达式★ ★★每次停下自动显示★★
# ★interact★ ★★进入交互式 Python(能跑任意代码)★★
# ★q(uit)★
# ③ ★★pytest 的调试选项★★
# pytest ★--pdb★ ★★失败时自动进调试器(现场还在)★★
# pytest ★--pdb --maxfail=1★ 第一个失败就停下调试
# pytest ★--trace★ ★每个测试开头就断下★
# pytest ★-l★ / ★--showlocals★ ★★失败时显示局部变量(不用改代码)★★
# pytest ★-x★ 第一个失败就停
# pytest ★--tb=long/short/line/native★
# pytest ★-s★ ★不捕获输出(print 能看到)★
# pytest ★--lf --pdb★ ★★只重跑上次失败的并调试★★
# ④ ★★py-spy:生产排查神器(不用改代码、不用重启)★★
# pip install py-spy
★py-spy dump --pid 12345★ # ★★立刻打印所有线程的调用栈★★
★py-spy top --pid 12345★ # ★★实时的 top 视图★★
★py-spy record -o profile.svg --pid 12345 --duration 30★ # ★火焰图★
★py-spy record -o out.svg -- python app.py★ # 直接启动
# ★★采样式,开销 <1%,可以在生产用★★
# ★需要权限:docker 里加 --cap-add SYS_PTRACE★
# ⑤ ★faulthandler:崩溃时打印栈★
import faulthandler
★faulthandler.enable()★ # ★段错误/崩溃时打印 Python 栈★
★faulthandler.register(signal.SIGUSR1)★ # ★★收到信号时 dump(不用重启)★★
# ★命令行:python -X faulthandler app.py★
# ★用法:kill -USR1 <pid> → 立刻在日志里看到所有线程的栈★
# ⑥ ★★性能分析★★
# ★CPU:采样(生产可用)★
py-spy record -o flame.svg --pid X --duration 60
# ★CPU:精确(开发用,有开销)★
python -m ★cProfile -s cumtime★ app.py
python -m cProfile ★-o prof.out★ app.py && ★snakeviz prof.out★
# ★行级★
pip install line_profiler
★@profile★ # 装饰要分析的函数
kernprof -l -v script.py
# ★内存★
import ★tracemalloc★
tracemalloc.start()
snap1 = ★tracemalloc.take_snapshot()★
do_work()
snap2 = tracemalloc.take_snapshot()
for stat in ★snap2.compare_to(snap1, "lineno")★[:10]:
print(stat) # ★★哪一行增长最多★★
# ★更强的工具★
pip install ★memray★
★memray run -o out.bin script.py && memray flamegraph out.bin★
# ⑦ ★★异步调试★★
import asyncio
asyncio.run(main(), ★debug=True★) # ★或 PYTHONASYNCIODEBUG=1★
# ★→ 慢回调警告、未 await 的协程警告、更详细的异常★
loop.★slow_callback_duration★ = 0.1 # ★★超过 100ms 的回调告警★★
# ★看所有任务★
for t in ★asyncio.all_tasks()★:
print(t.get_name(), t.get_coro(), ★t.get_stack()★)
⚠️ 三个必须记住的点:①
breakpoint()是 Python 3.7+ 的内置函数,应该完全取代import pdb; pdb.set_trace()。它的优势不只是简洁:通过PYTHONBREAKPOINT环境变量可以全局控制——设成0就禁用所有断点(万一有断点被误提交到生产,这是一道保险),设成ipdb.set_trace就全局切换成 ipdb(有语法高亮和补全),不用改一行代码。而pdb.set_trace()是硬编码的。② 生产环境排查的第一原则是「不侵入」——你不能改代码、不能重启(重启就丢现场)、不能让服务停下来。py-spy正是为此设计的:它通过读取目标进程的内存来提取 Python 调用栈,目标进程完全不知情、不需要任何改动、开销低于 1%。py-spy dump --pid X能立刻告诉你「所有线程当前卡在哪一行」——这对「请求全部超时」「CPU 跑满」「进程假死」这类问题几乎是唯一有效的手段。在容器里用需要SYS_PTRACE权限(docker run --cap-add SYS_PTRACE)。③pytest --pdb保留的是「失败那一刻的完整现场」——所有局部变量、调用栈、fixture 创建的对象都还在,你可以用u/d在栈帧间移动、用p打印任何变量、甚至用interact进入交互式 Python 执行任意代码来验证猜想。这比「加一堆 print 然后重跑」高效得多。配合--lf(只跑上次失败的) 和-l(自动显示局部变量) 效果更好。
完整版教学
一、本地调试
★ ★★三种手段的定位★★:
┌────────────────────────────────────────────────────┐
│ ★print / 日志★ :★循环多、异步、多线程时最有效★ │
│ ★不打断执行流,能看到"全过程"★ │
│ ★断点调试★ :★需要"探索"当前状态时最有效★ │
│ ★能改变量、执行任意代码★ │
│ ★事后分析★ :★崩溃/性能问题(栈、profile)★ │
└────────────────────────────────────────────────────┘
★ ★别有"用 print 就是水平低"的偏见★
★ ★在一个跑 10 万次的循环里找规律,print 远好过单步★
★ ★★breakpoint() 的用法★★:
breakpoint() # ★进入 pdb★
# ★环境变量控制(★很实用★)★
★PYTHONBREAKPOINT=0 python app.py★ # ★★全部禁用★★
★PYTHONBREAKPOINT=ipdb.set_trace★ # 换成 ipdb
★PYTHONBREAKPOINT=pudb.set_trace★ # 全屏 TUI 调试器
★ ✓ ★生产环境设 PYTHONBREAKPOINT=0 作为保险★
★ ★★pdb 里最有用的几个命令★★:
★u / d★ ★★在调用栈上移/下移——找"是谁传了这个错误的参数"★★
★a★ ★打印当前函数的所有参数★
★display x★ ★★每次停下自动显示 x(观察变量变化)★★
★interact★ ★★进入完整的 Python 交互环境★★
→ ★能 import 模块、能跑任意代码验证猜想★
★b file:42, x > 100★ ★★条件断点★★
★pp★ ★美化打印(嵌套结构可读)★
★retval★ 看当前函数的返回值
★ ★ipdb 额外提供:语法高亮、Tab 补全、更好的 list★
★ ★★条件断点:循环里定位问题的关键★★:
# ✗ 在循环里放 breakpoint() → ★要按 c 一万次★
for i, item in enumerate(items):
process(item)
# ✓ ★条件触发★
for i, item in enumerate(items):
★if item.id == 12345: breakpoint()★
process(item)
# ✓ ★或在 pdb 里设条件断点★
★b myapp/service.py:42, item.status == "error"★
# ✓ ★或异常触发★
try:
process(item)
except Exception:
★breakpoint()★ # ★★只在出错时停★★
raise
★ ★★事后调试(post-mortem)★★:
# ★程序已经崩了,想看当时的现场★
import pdb, sys
try:
main()
except Exception:
★pdb.post_mortem(sys.exc_info()[2])★ # ★★进入崩溃时的栈★★
# ★命令行★
★python -m pdb -c continue app.py★ # ★崩溃时自动进 pdb★
# ★IPython★
★%debug★ # ★★出错后直接输入,进入现场★★
★ ★IDE 调试的独特优势★:
✓ ★可视化的调用栈和变量树★
✓ ★条件断点、命中次数断点、日志断点(不暂停只打印)★
✓ ★表达式求值(watch)★
✓ ★远程调试(debugpy)★
✓ ★异常断点(抛出时自动停)★
★ ★复杂问题用 IDE,快速验证用 breakpoint()★
★ ★远程调试(容器/服务器)★:
# pip install debugpy
import debugpy
★debugpy.listen(("0.0.0.0", 5678))★
★debugpy.wait_for_client()★ # ★阻塞等 IDE 连上★
# ★VS Code 里 attach 到 5678★
★ ✗ ★生产环境慎用(会暂停服务)★
★ ✓ 适合本地容器、测试环境
三种调试手段各有定位:print/日志在「循环多、异步、多线程」时最有效(不打断执行流、能看到全过程),断点在「需要探索当前状态」时最有效(能改变量、执行任意代码),事后分析用于崩溃和性能问题——别有「用 print 就是水平低」的偏见。breakpoint() 配合 PYTHONBREAKPOINT 环境变量很实用(设 0 全局禁用是生产保险,设 ipdb.set_trace 全局切换调试器)。pdb 里最有用的命令是 u/d(在调用栈上下移动,找「是谁传了这个错误的参数」)、display(自动显示变量变化)、interact(进入完整的 Python 环境验证猜想)。循环里定位问题的关键是条件断点(if item.id == 12345: breakpoint() 或在 pdb 里 b file:42, cond)。事后调试用 pdb.post_mortem() 或 IPython 的 %debug——程序崩了之后还能回到现场。
二、测试中的调试
★ ★★pytest 的调试武器★★:
pytest ★--pdb★ ★★失败时自动进 pdb(现场完整保留)★★
pytest ★--trace★ 每个测试开头就断下
pytest ★-l / --showlocals★ ★★失败时打印局部变量(零改动)★★
pytest ★-x★ 第一个失败就停
pytest ★--maxfail=3★
pytest ★-s★ ★不捕获 stdout(能看到 print)★
pytest ★--tb=short★ 简短的 traceback
pytest ★--tb=native★ ★标准 Python 格式★
pytest ★--lf --pdb★ ★★只重跑上次失败的,并在失败时调试★★
pytest ★--setup-show★ ★显示 fixture 的执行顺序★
★ ★★-l 的价值(★最容易被忽略★)★★:
# ★不加 -l★
E assert result == expected
E AssertionError
# ★加 -l★
E assert result == expected
★user = <User id=3 name='bob'>★
★result = {'total': 100}★
★expected = {'total': 90}★
★ ✓ ★很多时候看一眼局部变量就知道原因了,不用进 pdb★
✓ ★写进 addopts 常开★
★ ★★断言的可读性(pytest 的 assert 重写)★★:
assert a == b
# ★pytest 会自动展示差异★:
★E assert {'x': 1, 'y': 2} == {'x': 1, 'y': 3}★
★E Differing items:★
★E {'y': 2} != {'y': 3}★
★ ✓ ★所以不要写 assert a == b, "失败了"★
★自定义消息会覆盖掉自动的差异展示★
✓ ★需要补充信息就追加而不是替换★:
assert a == b, ★f"上下文: user={user.id}"★
✓ ★pytest-clarity 让 diff 更清晰★
★ ★★fixture 的调试★★:
pytest ★--setup-show★ # ★★看 fixture 的创建和销毁顺序★★
# ★输出★
★SETUP S engine★
★SETUP F db (fixtures used: engine)★
★ tests/test_x.py::test_a (fixtures used: db, engine)★
★TEARDOWN F db★
pytest ★--fixtures★ # ★列出所有可用 fixture 及来源★
pytest ★--fixtures-per-test★
★ ★调试参数化的某一组★:
pytest ★"test_x[空名字]" --pdb★ # ★只跑那一组★
pytest ★-k "空名字"★
★ ★调试 flaky 测试★:
pytest ★--count=50 -x --pdb★ # ★★反复跑直到失败并停下★★
pytest ★-p no:randomly★ # 固定顺序
pytest ★--randomly-seed=12345★ # 复现特定顺序
★ ★★捕获输出的坑★★:
★ pytest 默认捕获 stdout/stderr → ★print 看不到★
✓ ★-s★ 关闭捕获
✓ ★或用 capsys/caplog fixture 显式断言★
✓ ★失败时 pytest 会自动显示被捕获的输出★(所以通常不用 -s)
★ ✗ ★-s 会让 pdb 的输出混乱★(并行时尤其)
★ ★调试 CI 上才失败的测试★:
① ★用同一个容器镜像本地复现★
② ★对齐环境变量★(TZ、LANG、PYTHONHASHSEED)
③ ★CI 上加 -l --tb=long 拿更多信息★
④ ★上传失败时的现场★(截图、日志、数据库 dump)
⑤ ★实在不行:在 CI 里开 SSH/tmate(GitHub Actions 有 action)★
pytest 的调试武器里 -l(--showlocals)最容易被忽略但价值很高——失败时自动打印所有局部变量,很多时候看一眼就知道原因,根本不用进 pdb,建议写进 addopts 常开。要注意 pytest 的 assert 重写:assert a == b 会自动展示详细差异,但写了自定义消息 assert a == b, "失败了" 会覆盖掉这个展示——需要补充信息应该追加上下文而不是替换。fixture 问题用 --setup-show(看创建和销毁顺序)和 --fixtures(看来源)。调试 flaky 用 --count=50 -x --pdb(反复跑直到失败并停下)。CI 上才失败的问题要用同一个镜像本地复现、对齐 TZ/LANG/PYTHONHASHSEED,实在不行可以在 CI 里开 SSH。
三、生产环境排查
★ ★★生产排查的三条约束★★:
① ★不能改代码★(改了要发版,问题可能已经过去)
② ★不能重启★(重启就丢现场)
③ ★不能明显影响性能★
★ → ★★所以 pdb 在生产基本用不了★★
★ ★★py-spy:最重要的生产工具★★:
# ★① 进程卡住了/请求都超时 → dump 栈★
★py-spy dump --pid 12345★
# ★输出:每个线程当前在哪一行★
Thread 0x7F... (active): "MainThread"
★recv (socket.py:707)★ ← ★★卡在网络读取★★
_read_status (client.py:280)
request (api_client.py:45)
handle (views.py:88)
★ ★一眼看出:卡在调用外部 API 上(而且没设超时)★
# ★② CPU 100% → 实时看热点★
★py-spy top --pid 12345★
# ★类似 top,但显示的是 Python 函数★
# ★③ 需要完整分析 → 火焰图★
★py-spy record -o flame.svg --pid 12345 --duration 60★
★ --subprocesses★ # ★包含子进程(gunicorn 多 worker)★
★ --native★ # ★包含 C 扩展的栈★
★ ★关键优势:★
✓ ★不用改代码、不用重启、目标进程无感知★
✓ ★采样式,开销 <1%★
✓ ★能看到所有线程★
★ ★权限:容器里要 --cap-add SYS_PTRACE★
或 k8s 的 securityContext.capabilities.add: ["SYS_PTRACE"]
★ ★★faulthandler:信号触发的栈 dump★★:
# ★应用启动时★
import faulthandler, signal
★faulthandler.enable()★ # 崩溃时打印栈
★faulthandler.register(signal.SIGUSR1)★ # ★★收到 SIGUSR1 就 dump★★
# ★需要时★
★kill -USR1 <pid>★ # ★★栈立刻出现在 stderr/日志里★★
★ ✓ ★零依赖(标准库)、零开销★
★ ✓ ★提前埋好,出问题时不用装任何东西★
★ ★强烈建议所有生产服务都加上这两行★
★ ★其他生产手段★:
┌────────────────────────┬──────────────────────────┐
│ ★结构化日志 + request_id★│ ★★串起一次请求的全过程★★ │
│ ★APM(Sentry/OTel)★ │ ★分布式追踪、异常聚合★ │
│ ★/metrics 端点★ │ 实时指标 │
│ ★管理端点★ │ ★暴露内部状态(要鉴权)★ │
│ ★gdb + python 扩展★ │ ★C 层面的问题★ │
│ ★strace / ltrace★ │ 系统调用 │
└────────────────────────┴──────────────────────────┘
★ ★★自建"诊断端点"(很实用)★★:
@app.get("/_debug/threads", ★dependencies=[Depends(require_admin)]★)
def dump_threads():
import threading, traceback
return {
t.name: ★traceback.format_stack(sys._current_frames()[t.ident])★
for t in threading.enumerate()
}
@app.get("/_debug/tasks") # ★asyncio 任务★
async def dump_tasks():
return [{"name": t.get_name(), "coro": str(t.get_coro())}
for t in ★asyncio.all_tasks()★]
★ ✓ ★不用 attach 进程就能看内部状态★
★ ✗ ★★必须鉴权 + 不出现在文档里★★
★ ★★排查"服务卡住"的标准流程★★:
① ★py-spy dump★ → 看所有线程卡在哪
② ★如果卡在网络/数据库 → 查超时配置和下游状态★
③ ★如果卡在锁 → 找死锁★
④ ★如果 CPU 100% → py-spy record 火焰图★
⑤ ★如果内存涨 → tracemalloc / memray★
⑥ ★配合日志的 request_id 定位具体请求★
生产排查有三条约束:不能改代码、不能重启(重启就丢现场)、不能明显影响性能——所以 pdb 在生产基本用不了。py-spy 是最重要的生产工具:dump 立刻打印所有线程当前在哪一行(「卡在 recv 上」就一眼看出是调用外部 API 没设超时)、top 实时看 Python 函数级的 CPU 热点、record 生成火焰图;关键优势是不用改代码、不用重启、开销低于 1%,容器里需要 SYS_PTRACE 权限。faulthandler 值得所有生产服务提前埋上两行——enable() 让崩溃时打印栈、register(SIGUSR1) 让你随时 kill -USR1 就能 dump 栈,零依赖零开销。还可以自建诊断端点暴露线程栈和 asyncio 任务(必须鉴权且不出现在文档里)。
四、性能与内存分析
★ ★★CPU:两类 profiler★★:
┌──────────────┬────────────────────────────────────┐
│ ★采样式★ │ ★py-spy / pyinstrument★ │
│ (sampling) │ ✓ ★开销极低(<1%),生产可用★ │
│ │ ✓ ★不用改代码★ │
│ │ ✗ 短函数可能采样不到 │
│ ★确定式★ │ ★cProfile / line_profiler★ │
│ (tracing) │ ✓ ★精确到每次调用★ │
│ │ ✗ ★开销大(2~10 倍),改变时间分布★ │
└──────────────┴────────────────────────────────────┘
★ ★cProfile 用法★:
python -m ★cProfile -s cumtime★ script.py | head -30
# ★关键列:★
# ★ncalls★ 调用次数
# ★tottime★ ★★函数自身耗时(不含子调用)★★
# ★cumtime★ ★★累计耗时(含子调用)——通常先看这个★★
# ★保存后可视化★
python -m cProfile ★-o prof.out★ script.py
★snakeviz prof.out★ # ★浏览器里的交互式图★
★gprof2dot -f pstats prof.out | dot -Tpng -o call.png★
# ★代码内★
import cProfile, pstats
with cProfile.Profile() as pr:
do_work()
★pstats.Stats(pr).sort_stats("cumtime").print_stats(20)★
★ ★pyinstrument(★可读性最好★)★:
pip install pyinstrument
★python -m pyinstrument script.py★
# ★输出是调用树,一眼看出耗时路径★
★2.5s main★
★└─ 2.4s process_all★
★ └─ 2.3s fetch_user ← 90% 在这★
# ★代码内★
from pyinstrument import Profiler
p = Profiler(); p.start(); work(); p.stop()
★print(p.output_text(unicode=True, color=True))★
★ ★line_profiler:行级★:
pip install line_profiler
★@profile★ # ★不用 import,kernprof 注入★
def slow_function(): ...
★kernprof -l -v script.py★
# ★输出每一行的耗时和占比★
★ ★★内存分析★★:
# ★① tracemalloc(标准库,零依赖)★
import tracemalloc
★tracemalloc.start(10)★ # ★保留 10 层栈★
snap1 = tracemalloc.take_snapshot()
# ...运行一段时间...
snap2 = tracemalloc.take_snapshot()
for s in ★snap2.compare_to(snap1, "lineno")[:10]★:
★print(s)★ # ★★哪一行分配增长最多★★
# ★典型输出:myapp/cache.py:42: size=50.3 MiB (+48.1 MiB), count=100000★
# ★② memray(★最强,Bloomberg 出品)★
pip install memray
★memray run -o out.bin script.py★
★memray flamegraph out.bin★ # ★内存火焰图★
★memray tree out.bin★
★memray run --live script.py★ # ★★实时 TUI★★
# ★③ objgraph:找引用链(内存泄漏)★
import objgraph
★objgraph.show_growth()★ # ★★哪类对象在增长★★
★objgraph.show_backrefs([obj], filename="refs.png")★ # ★谁引用了它★
★ ★★内存泄漏的常见原因★★:
① ★全局的缓存/列表只增不减★
② ★循环引用 + __del__★(老版本 Python 无法回收)
③ ★闭包意外持有大对象★
④ ★未关闭的资源★(文件、连接、生成器)
⑤ ★logging 的 handler 重复添加★
⑥ ★C 扩展的泄漏(tracemalloc 看不到)★
✓ ★定位:show_growth() 找增长的类型 → show_backrefs 找引用链★
★ ★gc 模块的调试★:
import gc
★gc.set_debug(gc.DEBUG_LEAK)★
★gc.collect()★; print(★gc.garbage★) # ★无法回收的对象★
★len(gc.get_objects())★ # 对象总数
CPU profiler 分两类:采样式(py-spy、pyinstrument)开销极低、生产可用、不用改代码;确定式(cProfile、line_profiler)精确但开销 2~10 倍、会改变时间分布。看 cProfile 的输出要分清 tottime(函数自身耗时)和 cumtime(含子调用,通常先看这个)。pyinstrument 的可读性最好——输出是调用树,一眼看出「90% 的时间在哪个路径上」。内存分析首选 tracemalloc(标准库、能对比两个快照找出增长最多的行),memray 最强(内存火焰图、实时 TUI),objgraph 用于找引用链(show_growth 看哪类对象在增长、show_backrefs 看谁引用了它)。内存泄漏的六个常见原因里,全局缓存只增不减和未关闭的资源最高频。
五、异步与并发的调试
★ ★★异步调试的三个难点★★:
① ★调用栈被 await 打断★——看不到"是谁调用的"完整链路
② ★任务可能被吞掉★(未 await 的协程、未取回的异常)
③ ★pdb 在协程里体验不好★(单步会跳到事件循环内部)
★ ★★asyncio debug 模式(第一件该做的事)★★:
★asyncio.run(main(), debug=True)★
# ★或环境变量:PYTHONASYNCIODEBUG=1★
# ★或 loop.set_debug(True)★
★ ★它会报告:★
✓ ★"Executing <Task...> took 0.523 seconds"★ ← ★★阻塞了事件循环★★
✓ ★"coroutine was never awaited"★ ← ★★忘了 await★★
✓ ★"Task exception was never retrieved"★ ← ★★异常被吞★★
✓ ★未关闭的 transport/连接★
★ 调阈值:
★loop.slow_callback_duration = 0.1★ # 默认 0.1 秒
★ ★★查看所有任务(排查"卡住")★★:
for t in ★asyncio.all_tasks()★:
print(t.get_name(), t.done(), t.cancelled())
★t.print_stack()★ # ★★这个任务卡在哪★★
# ★配合诊断端点★
@app.get("/_debug/tasks")
async def tasks():
return [{"name": t.get_name(),
"stack": [str(f) for f in ★t.get_stack()★]}
for t in asyncio.all_tasks()]
★ ★★阻塞事件循环的检测(最常见的异步问题)★★:
# ★方法一:debug 模式的慢回调警告(上面)★
# ★方法二:blockbuster★
pip install blockbuster
★with BlockBuster():★ # ★★检测到阻塞调用直接抛异常★★
await main()
# ★方法三:py-spy dump 看栈★
★→ 如果栈停在 requests/time.sleep/bcrypt 上,就是它★
★ ★异常被吞的三种情况★:
① ★create_task 的异常没人取★
task = asyncio.create_task(work()) # ★★异常存在 task 里★★
# 没有 await task → ★进程退出时才警告★
✓ ★加回调★:task.add_done_callback(lambda t: t.result())
✓ ★或用 TaskGroup(3.11+,异常会传播)★
② ★gather(return_exceptions=True) 后没检查结果★
③ ★后台任务的引用被 GC★
✓ ★保存引用:background_tasks.add(task)★
★ ★多线程调试★:
# ★看所有线程的栈★
import sys, threading, traceback
for tid, frame in ★sys._current_frames()★.items():
print(f"--- Thread {tid} ---")
★traceback.print_stack(frame)★
# ★或 faulthandler.dump_traceback()★
# ★或 py-spy dump(最方便)★
★ ★死锁排查★:
① ★py-spy dump★ → 看各线程卡在哪个 acquire
② ★threading.settrace★ 记录锁的获取顺序
③ ★用 timeout 版的 acquire 暴露问题★:
★if not lock.acquire(timeout=5): raise RuntimeError("疑似死锁")★
④ ★统一加锁顺序★(预防)
★ ★多进程调试★:
✗ ★子进程里的 pdb 抢不到 stdin★
✓ ★远程 pdb★:
from remote_pdb import RemotePdb
★RemotePdb("127.0.0.1", 4444).set_trace()★
# ★telnet 127.0.0.1 4444★
✓ ★或者:先单进程复现问题★
✓ ★py-spy record --subprocesses★
异步调试的三个难点:调用栈被 await 打断、任务可能被吞掉、pdb 在协程里体验差。第一件该做的事是开 asyncio debug 模式——它会报告「执行某个 Task 花了 0.523 秒」(阻塞了事件循环)、「coroutine was never awaited」(忘了 await)、「Task exception was never retrieved」(异常被吞)。排查「卡住」用 asyncio.all_tasks() 加 t.print_stack()。阻塞事件循环是最常见的异步问题——三种检测方式是 debug 模式的慢回调警告、blockbuster 库(检测到阻塞调用直接抛异常)、以及 py-spy dump 看栈是不是停在 requests/time.sleep/bcrypt 上。多线程和死锁排查最方便的也是 py-spy dump。多进程里子进程的 pdb 抢不到 stdin,要用 remote_pdb 或先单进程复现。
六、实践清单
★ ★★按症状选工具★★:
┌──────────────────────────┬────────────────────────┐
│ ★测试失败,想看现场★ │ ★pytest --pdb -l★ │
│ ★逻辑不对,要探索状态★ │ ★breakpoint() / IDE★ │
│ ★循环里某次出错★ │ ★条件断点★ │
│ ★程序崩了想回到现场★ │ ★post_mortem / %debug★ │
│ ★★生产卡住/超时★★ │ ★★py-spy dump★★ │
│ ★★CPU 100%★★ │ ★★py-spy top/record★★ │
│ ★内存持续增长★ │ ★tracemalloc / memray★ │
│ ★想知道慢在哪★ │ ★pyinstrument★ │
│ ★异步任务卡住★ │ ★all_tasks + print_stack★│
│ ★怀疑阻塞事件循环★ │ ★asyncio debug 模式★ │
│ ★死锁★ │ ★py-spy dump★ │
└──────────────────────────┴────────────────────────┘
★ ★★提前埋点("出事前"就该做的)★★:
□ ★faulthandler.enable() + register(SIGUSR1)★
□ ★结构化日志 + request_id★
□ ★关键路径的耗时日志★
□ ★/health 和 /metrics 端点★
□ ★Sentry / APM 接入★
□ ★诊断端点(鉴权):线程栈、asyncio 任务、连接池状态★
□ ★容器加 SYS_PTRACE(让 py-spy 能用)★
★ ★★出事时才想装工具就晚了★★
★ ★调试的方法论★:
① ★先复现★(不能复现就先加观测)
② ★缩小范围★(二分、注释、最小复现)
③ ★形成假设★("我认为是 X 导致的")
④ ★验证假设★(不是随便乱试)
⑤ ★修复后确认根因★(不是"改了就好了")
★ ★最常见的错误:跳过 ③④,靠试出来★
★ → ★问题会以别的形式回来★
★ ★配置建议★:
# pyproject.toml
[tool.pytest.ini_options]
addopts = ★"-ra --showlocals --tb=short"★
# ★环境变量★
★PYTHONBREAKPOINT=0★ # ★生产禁用断点★
★PYTHONFAULTHANDLER=1★ # ★崩溃时打印栈★
★PYTHONASYNCIODEBUG=1★ # ★开发环境★
★PYTHONDEVMODE=1★ # ★★开发模式:更多警告★★
★ ★★常见"查不出来"的原因★★:
┌────────────────────────────────────┬──────────────────┐
│ 日志里什么都没有 │ ★级别不对/被吞了★ │
│ 异常被 except 吞了 │ ★except: pass★ │
│ 异步任务的异常没人看 │ ★没 await/没回调★ │
│ 只在生产出现 │ ★环境/数据/并发★ │
│ 加了日志就不复现 │ ★★竞态条件★★ │
│ py-spy 权限不够 │ ★SYS_PTRACE★ │
└────────────────────────────────────┴──────────────────┘
★ 一句话总结:
★"调试要按场景选工具:本地用 breakpoint() 和 IDE 断点、
测试失败用 pytest --pdb -l 保留现场、
生产用 py-spy 非侵入式 dump 栈(不用改代码不用重启);
性能问题分 CPU(采样 profiler + 火焰图)和内存(tracemalloc/memray);
异步先开 debug 模式看慢回调和被吞的异常;
最重要的是提前埋点——faulthandler、request_id、诊断端点,
出事时才想装工具就晚了。"★
「按症状选工具」那张表是这道题的实用核心。另一个高价值的是**「提前埋点」清单**——faulthandler.enable() 加 register(SIGUSR1) 只要两行、零开销,却能在出事时让你随时 dump 栈;容器要提前加 SYS_PTRACE 权限,否则 py-spy 用不了。调试方法论最常见的错误是跳过「形成假设 → 验证假设」直接靠试——这样即使「改好了」也不知道根因,问题会以别的形式回来。
记忆钩子:「调试要★按『问题类型』和『环境』分开选工具★。★本地★:★breakpoint()(3.7+ 内置,取代 import pdb; pdb.set_trace())★——它的真正优势是★可以用 PYTHONBREAKPOINT 环境变量全局控制★(设 0 ★全局禁用(生产保险)★、设 ipdb.set_trace ★全局换调试器★);pdb 里★最有用的是 u/d(在调用栈上下移动,找『是谁传了错误的参数』)、a(打印所有参数)、display(自动显示变量变化)、interact(进入完整 Python 环境验证猜想)★;★循环里定位问题的关键是条件断点★。★别有『用 print 就是水平低』的偏见——循环多、异步、多线程时日志远好过单步★。★测试★:★pytest —pdb 保留失败那一刻的完整现场★(局部变量、调用栈、fixture 对象都在),配 ★—lf 只重跑上次失败的★;★-l/—showlocals 最容易被忽略但价值极高(失败时自动打印局部变量,很多时候看一眼就知道原因)★,建议写进 addopts;★注意 assert a == b, ‘消息’ 的自定义消息会覆盖掉 pytest 自动的差异展示★。★★生产:三条约束是不能改代码、不能重启(重启丢现场)、不能影响性能——所以 pdb 用不了★★ → ★py-spy 是最重要的工具★:★dump 立刻打印所有线程当前在哪一行(栈停在 recv 上就一眼看出是外部 API 没设超时)★、top 实时看 Python 函数级热点、record 出火焰图;★关键优势是不用改代码不用重启、开销 <1%★,★容器里要 —cap-add SYS_PTRACE★。★faulthandler 值得所有生产服务提前埋两行★:enable() 崩溃时打印栈 + ★register(SIGUSR1) 让你随时 kill -USR1 就能 dump★,零依赖零开销。★性能分两类★:★CPU 用采样式(py-spy/pyinstrument,生产可用)vs 确定式(cProfile/line_profiler,精确但开销 2~10 倍会改变时间分布)★,看 cProfile 要★分清 tottime(自身耗时)和 cumtime(含子调用,通常先看它)★;★内存用 tracemalloc(标准库,compare_to 对比两个快照找出增长最多的行)、memray(最强,火焰图+实时 TUI)、objgraph(show_growth 找增长的类型、show_backrefs 找引用链)★。★异步★:★第一件事是开 asyncio debug 模式★(报告『Executing Task took 0.523 seconds』= ★阻塞了事件循环★、『coroutine was never awaited』、『Task exception was never retrieved』= ★异常被吞★),★排查卡住用 asyncio.all_tasks() + t.print_stack()★。★最重要的是提前埋点:faulthandler、request_id、诊断端点、SYS_PTRACE 权限——出事时才想装工具就晚了★。★方法论上最常见的错误是跳过『形成假设→验证假设』直接靠试,这样即使改好了也不知道根因,问题会以别的形式回来★。」
七、常见误区与追问
- 误区:用 print 调试是水平低的表现,应该都用调试器。 两者适用的场景不同,print/日志在好几类问题上是更优解。① 循环次数多——在一个跑十万次的循环里找「哪一次开始不对」,单步调试根本不现实,而打印关键变量再分析输出能直接看出规律。② 异步和多线程——断点会改变时序(暂停一个协程时其他任务仍在跑,或者反之),很多并发 bug 在断点下根本不复现,而日志不打断执行流。③ 需要看「全过程」而不是「某一刻」——比如状态机的流转、重试的次数、缓存的命中情况,这些是「时间序列」信息,断点只能看到孤立的快照。④ 生产环境——根本没法用断点。断点的独特优势在于「探索」:你可以在暂停时执行任意代码、修改变量、沿调用栈上下移动去看「是谁传了这个参数」——这在「不知道该打印什么」的时候不可替代。经验做法是:先用日志缩小范围,再用断点深入某个具体位置。
- 误区:生产出问题了,加点日志重新部署看看。 重新部署会丢掉现场,而且问题可能已经过去了。生产排查的核心约束是「不能改代码、不能重启」——你需要的是非侵入式的观测手段。首选
py-spy dump --pid X:它通过读取目标进程的内存来提取 Python 调用栈,目标进程完全无感知、不需要任何改动、也不用重启。一条命令就能告诉你「所有线程当前卡在哪一行」——如果栈停在socket.recv上,那就是在等某个外部服务(顺便暴露了「没设超时」这个问题);如果停在某个循环里,那就是 CPU 打满的原因;如果多个线程都停在lock.acquire,那就是死锁。第二个手段是提前埋好faulthandler.register(signal.SIGUSR1)——之后随时kill -USR1 <pid>就能让进程把所有线程的栈打进日志,同样不需要重启。这些都属于「出事前就该准备好的」:容器要提前加SYS_PTRACE权限,否则临时想用 py-spy 会发现权限不够。 - 误区:
pytest --pdb和在代码里加breakpoint()效果一样。--pdb保留的是「失败那一刻的现场」,而手动加断点是「猜测失败位置后提前停下」。两者的差别在实际排查中很大:用--pdb时,你不需要知道问题出在哪——测试失败后 pytest 会自动在抛出异常的那一帧进入调试器,所有局部变量、调用栈、fixture 创建的对象都保持在出错时的状态;你可以用u/d沿栈向上找到真正的源头、用p打印任何中间变量、用interact进入完整的 Python 环境执行任意代码来验证猜想。而手动breakpoint()需要你先猜对位置,猜错了就要改代码重跑。搭配使用效果更好:pytest --lf --pdb(只重跑上次失败的并在失败时调试)、pytest -x --pdb(第一个失败就停下)。另外别忘了-l(--showlocals)——它在不进调试器的情况下就打印所有局部变量,很多时候看一眼就够了。 - 误区:
cProfile的结果显示某个函数tottime很小,说明它不是瓶颈。 要分清tottime和cumtime。tottime是「函数自身执行的时间,不包括它调用的子函数」;cumtime是「累计时间,包括所有子调用」。一个只有三行的handle_request函数,tottime可能只有几毫秒(它自己没干什么),但cumtime是 5 秒(因为它调用的东西很慢)——如果只看tottime你会完全错过它。实践中通常先按cumtime排序找到「耗时的路径」,再按tottime找到「真正干活的那个函数」。另外要注意cProfile是确定式 profiler:它给每次函数调用都加了钩子,开销可能是 2~10 倍,而且会改变时间分布(函数调用多的代码被放大得更厉害),所以它的绝对数字不能当真、只能看相对比例。要更真实的画像就用采样式的py-spy或pyinstrument,后者的调用树输出可读性尤其好。 - 误区:异步代码里的异常都会正常抛出来。 有三种情况会被静默吞掉。①
create_task创建的任务,异常存在 Task 对象里——如果你从不await它、也不加回调,那个异常要等到 Task 被垃圾回收时才会以「Task exception was never retrieved」的形式警告一次,而且这条警告很容易淹没在日志里;如果进程一直不退出,你可能永远看不到。②asyncio.gather(..., return_exceptions=True)会把异常作为结果返回——如果你没有遍历检查返回值,异常就被无声地吃掉了。③ 后台任务的引用被 GC——asyncio.create_task(work())不保存返回值时,任务可能在完成前就被回收。应对方式:开 asyncio debug 模式(它会明确报告这些情况)、用 Python 3.11+ 的TaskGroup(组内任何任务失败都会传播出来)、给create_task加add_done_callback检查结果、以及把后台任务的引用存进一个集合防止被 GC。 - 追问:
py-spy具体怎么用来排查线上问题? 三个最常用的子命令对应三类问题。① 「服务卡住了、请求全部超时」→py-spy dump --pid X:它会立刻输出每个线程当前的完整调用栈,你能一眼看出是卡在网络读取(socket.recv)、数据库查询、锁等待(lock.acquire)还是某个死循环里。这是排查「假死」最快的手段——通常十秒内就能定位方向。② 「CPU 跑满」→py-spy top --pid X:类似系统的top命令,但显示的是 Python 函数级别的 CPU 占用,实时刷新,能看出是哪个函数在烧 CPU。③ 「想完整分析性能」→py-spy record -o flame.svg --pid X --duration 60:采样 60 秒生成火焰图,宽度代表占用的时间比例,能看到完整的调用关系。三个实用参数:--subprocesses(gunicorn 多 worker 时要加,否则只看到 master)、--native(包含 C 扩展的栈,排查 numpy/pandas 相关问题时有用)、--nonblocking(不暂停目标进程,代价是采样精度略降)。部署前提是容器要有SYS_PTRACE权限。 - 追问:怎么定位内存泄漏? 分三步。① 确认是不是真的泄漏——先用监控看内存曲线:持续单调增长才是泄漏,如果是「涨到某个水位后平稳」那可能只是缓存或内存池的正常行为;另外注意 Python 释放的内存不一定还给操作系统(小对象由 pymalloc 管理),所以 RSS 不降不代表泄漏。② 定位增长点——用
tracemalloc(标准库,无需额外依赖):在服务启动后和运行一段时间后各取一次快照,然后snap2.compare_to(snap1, "lineno")会按「哪一行代码分配的内存增长最多」排序,通常前几条就指向了问题;生产环境可以做成一个诊断端点按需触发。③ 找出为什么不被回收——用objgraph:show_growth()显示哪类对象的实例数在增长,show_backrefs([obj])画出「谁引用了这个对象」的引用链图——泄漏的本质总是「有东西还引用着它」。常见元凶:全局的缓存字典或列表只增不减、未关闭的资源(文件、连接、生成器)、闭包意外持有大对象、logging handler 重复添加。如果tracemalloc什么都看不到,很可能是 C 扩展的泄漏(它只追踪 Python 层的分配),这时要用memray --native或系统级工具。 - 追问:调试有没有方法论,还是只能靠经验? 有,而且方法论比工具更重要。五个步骤:① 稳定复现——不能复现的问题几乎无法调试,这一步要投入足够时间(找到触发条件、准备最小数据集、必要时先加观测手段把信息收集起来)。② 缩小范围——二分法是最有效的:注释掉一半代码、回退到某个 commit(
git bisect能自动化)、简化输入直到问题消失,目标是得到一个最小复现。③ 形成假设——明确地说出「我认为是 X 导致的,因为 Y」,这一步是最容易被跳过的。④ 验证假设——设计一个能区分「假设成立」和「假设不成立」的实验(打印某个变量、临时改一行代码),而不是随便试。⑤ 修复后确认根因——问自己「为什么这个改动能修好」,如果答不上来,说明你只是碰巧绕过了症状。最常见的错误是跳过 ③④ 直接靠「试」——改一行跑一下、不行再改一行,这样即使最终「好了」也不知道原因,同一个根因会以别的形式再次出现。另外一个好习惯是:修复后补一个能复现原问题的测试,这样它不会回归。
八、加强记忆
调试要按「问题类型」和「环境」分开选工具。本地:breakpoint()(Python 3.7+ 内置,取代 import pdb; pdb.set_trace())——它的真正优势是可以用 PYTHONBREAKPOINT 环境变量全局控制(设 0 全局禁用(生产保险)、设 ipdb.set_trace 全局切换调试器);pdb 里最有用的是 u/d(在调用栈上下移动,找「是谁传了错误的参数」)、a(打印所有参数)、display(自动显示变量变化)、interact(进入完整 Python 环境验证猜想);循环里定位问题的关键是条件断点。别有「用 print 就是水平低」的偏见——循环多、异步、多线程时日志远好过单步。测试:pytest --pdb 保留失败那一刻的完整现场(局部变量、调用栈、fixture 对象都还在),配 --lf 只重跑上次失败的;-l/--showlocals 最容易被忽略但价值极高(失败时自动打印局部变量,很多时候看一眼就知道原因),建议写进 addopts;注意 assert a == b, "消息" 的自定义消息会覆盖掉 pytest 自动的差异展示。生产:三条约束是不能改代码、不能重启(重启就丢现场)、不能影响性能——所以 pdb 用不了 → py-spy 是最重要的工具:dump 立刻打印所有线程当前在哪一行(栈停在 recv 上就一眼看出是外部 API 没设超时)、top 实时看 Python 函数级热点、record 生成火焰图;关键优势是不用改代码、不用重启、开销低于 1%,容器里要 --cap-add SYS_PTRACE。faulthandler 值得所有生产服务提前埋上两行:enable() 让崩溃时打印栈 + register(SIGUSR1) 让你随时 kill -USR1 就能 dump,零依赖零开销。性能分两类:CPU 用采样式(py-spy、pyinstrument,生产可用)vs 确定式(cProfile、line_profiler,精确但开销 2~10 倍且会改变时间分布),看 cProfile 要分清 tottime(自身耗时)和 cumtime(含子调用,通常先看它);内存用 tracemalloc(标准库,compare_to 对比两个快照找出增长最多的行)、memray(最强,火焰图 + 实时 TUI)、objgraph(show_growth 找增长的类型、show_backrefs 找引用链)。异步:第一件事是开 asyncio debug 模式(它会报告「Executing Task took 0.523 seconds」= 阻塞了事件循环、「coroutine was never awaited」、「Task exception was never retrieved」= 异常被吞),排查卡住用 asyncio.all_tasks() 加 t.print_stack()。最重要的是提前埋点:faulthandler、request_id、诊断端点、SYS_PTRACE 权限——出事时才想装工具就晚了。方法论上最常见的错误是跳过「形成假设 → 验证假设」直接靠试,这样即使改好了也不知道根因,问题会以别的形式回来。