← 返回题目列表

Python 怎么调试?pdb、日志和生产环境排查有哪些手段?

中等 第 26 / 27 题 更新于 2026/08/03
调试pdbpy-spy生产排查性能分析

简化版

调试的手段要按「问题类型」和「环境」分开看本地开发最常用三样: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 密集用采样 profilerpy-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 很小,说明它不是瓶颈。 要分清 tottimecumtimetottime 是「函数自身执行的时间,不包括它调用的子函数」;cumtime 是「累计时间,包括所有子调用」。一个只有三行的 handle_request 函数,tottime 可能只有几毫秒(它自己没干什么),但 cumtime 是 5 秒(因为它调用的东西很慢)——如果只看 tottime 你会完全错过它。实践中通常先按 cumtime 排序找到「耗时的路径」,再按 tottime 找到「真正干活的那个函数」。另外要注意 cProfile确定式 profiler:它给每次函数调用都加了钩子,开销可能是 2~10 倍,而且会改变时间分布(函数调用多的代码被放大得更厉害),所以它的绝对数字不能当真、只能看相对比例。要更真实的画像就用采样式的 py-spypyinstrument,后者的调用树输出可读性尤其好。
  • 误区:异步代码里的异常都会正常抛出来。 有三种情况会被静默吞掉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_taskadd_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")按「哪一行代码分配的内存增长最多」排序,通常前几条就指向了问题;生产环境可以做成一个诊断端点按需触发。③ 找出为什么不被回收——用 objgraphshow_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_PTRACEfaulthandler 值得所有生产服务提前埋上两行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 权限——出事时才想装工具就晚了方法论上最常见的错误是跳过「形成假设 → 验证假设」直接靠试,这样即使改好了也不知道根因,问题会以别的形式回来