Python 调试工具链实战——pdb、ipdb、py-spy、memray 组合使用
Python 调试工具链实战——pdb、ipdb、py-spy、memray 组合使用
适读人群:Python 工程师、遇到生产问题不知从何下手的开发者 | 阅读时长:约13分钟 | 核心价值:掌握四个调试工具的核心用法,让 debug 不再靠 print
我有个习惯:每次遇到一个新 bug,先想想如果有更好的工具,能不能更快找到它。
用了几年 Python,我的调试工具箱里积累了四个主力工具:
- pdb/ipdb:交互式断点调试
- py-spy:生产环境 CPU profile,不停服务
- memray:内存 profile,找内存泄漏
这四个工具覆盖了我遇到的 95% 的调试场景。今天把每个工具的核心用法和我实际用到的场景都写出来。
pdb:标准库断点调试
pdb 是 Python 标准库自带的调试器,不需要安装,随时可用。
最简单的用法:
def complex_function(data):
result = preprocess(data)
import pdb; pdb.set_trace() # ← 在这里暂停,进入交互式调试
processed = process(result)
return processed运行到 pdb.set_trace() 时,程序暂停,你进入一个交互式命令行。
pdb 核心命令:
n (next) - 执行下一行(不进入函数)
s (step) - 执行下一行(进入函数内部)
c (continue) - 继续运行到下一个断点
b 行号 - 在某行设置断点,如 b 42
b 函数名 - 在函数入口设置断点,如 b my_func
l (list) - 显示当前附近的代码
p 变量名 - 打印变量值,如 p user.name
pp 变量名 - 格式化打印(pretty print)
w (where) - 显示调用栈
u/d - 在调用栈里上移/下移
q (quit) - 退出调试器Python 3.7+ 的新写法:
def complex_function(data):
result = preprocess(data)
breakpoint() # 等价于 pdb.set_trace(),更简洁
processed = process(result)
return processed设置 PYTHONBREAKPOINT=0 环境变量可以全局禁用所有 breakpoint() 调用,方便在生产环境里把断点代码留着但不触发。
ipdb:pdb 的增强版
ipdb 是 pdb 的增强版,加了语法高亮和 tab 补全,用起来顺手很多。
pip install ipdbimport ipdb; ipdb.set_trace()
# 或者
breakpoint() # 如果设置了 PYTHONBREAKPOINT=ipdb.set_trace设置环境变量让 breakpoint() 默认使用 ipdb:
export PYTHONBREAKPOINT=ipdb.set_trace踩坑实录一:pdb 调试异步代码
现象: 在 async 函数里加 breakpoint(),断点触发了但无法正常单步调试,输出乱掉。
原因: pdb 是为同步代码设计的,在 asyncio 事件循环里使用会有问题。
解法: 用 asyncio 专用的调试模式,或者用 aiodebug:
import asyncio
# 方法1:在事件循环里用 asyncio.run_coroutine_threadsafe
async def debug_async():
# 这样可以在异步函数里打断点
import pdb
debugger = pdb.Pdb()
debugger.set_trace()
await some_async_operation()
# 方法2:开启 asyncio debug 模式,会打印更多信息
asyncio.run(main(), debug=True)
# 方法3:用 pytest-asyncio 的方式调试测试
# 在 pytest 里,pytest --pdb 对 async 测试支持更好更实用的方法是:在关键位置加 logger.debug,然后在出问题时把日志级别改为 DEBUG,往往比断点调试更高效。
py-spy:生产环境 CPU profiling
py-spy 是我用得最多的性能调试工具。它的特点:不需要修改代码,直接 attach 到运行中的进程,零停机 profiling。
pip install py-spy三种主要用法:
1. 查看实时火焰图(最直观):
# 找到进程 PID
ps aux | grep python
# 采样30秒,生成 SVG 火焰图
py-spy record -o profile.svg --duration 30 --pid <PID>用浏览器打开 profile.svg,横轴是时间占比,越宽的函数越耗时,一眼看出瓶颈在哪。
2. 实时 top 视图(类似 htop):
py-spy top --pid <PID>实时显示各函数的 CPU 占用,类似 Linux 的 top 命令。
3. 转储当前调用栈(快速诊断"卡住"的进程):
py-spy dump --pid <PID>如果服务卡住了(CPU 100% 或者无响应),用这个命令把所有线程的当前调用栈打印出来,立刻知道卡在哪里。
踩坑实录二:找到一个藏了半年的 CPU 热点
现象: 一个数据处理服务,CPU 始终维持在 85% 左右,即使没有用户请求时也是。我们一直以为是"正常处理"。
排查过程:
# 1. 找到进程 PID
ps aux | grep data_processor
# 假设是 12345
# 2. 采样60秒
py-spy record -o profile.svg --duration 60 --pid 12345
# 3. 打开火焰图
open profile.svg火焰图显示,有一个函数 calculate_moving_average 占了 67% 的 CPU 时间,而且它在后台任务里每5秒跑一次。
# 有问题的代码(简化)
def calculate_moving_average(data: list, window: int = 100):
result = []
for i in range(len(data)):
# 每个元素都切片,切片是 O(n) 的
window_data = data[max(0, i-window):i+1]
result.append(sum(window_data) / len(window_data))
return result
# 总体复杂度:O(n²)# 优化后:O(n)
from collections import deque
def calculate_moving_average_fast(data: list, window: int = 100):
result = []
window_sum = 0
window_deque = deque()
for val in data:
window_deque.append(val)
window_sum += val
if len(window_deque) > window:
window_sum -= window_deque.popleft()
result.append(window_sum / len(window_deque))
return result优化后,CPU 从 85% 降到了 12%。这个问题藏了半年,直到我用 py-spy 才找到。
memray:内存 profiling
memray 是 Bloomberg 开源的 Python 内存 profiler,比 tracemalloc 好用很多。
pip install memray基本用法:
# 运行程序同时 profile 内存
python -m memray run -o output.bin my_script.py
# 生成火焰图(内存分配版)
python -m memray flamegraph output.bin
# 生成实时 TUI
python -m memray run --live my_script.py在代码里使用:
import memray
with memray.Tracker("output.bin"):
# 这里面的所有内存分配都会被记录
process_large_dataset()踩坑实录三:用 memray 找到内存泄漏
现象: 一个 FastAPI 服务,每天内存涨约 200MB,跑5天后 OOM。重启后正常,但过几天又开始涨。
排查过程:
在测试环境,用 memray 的 live 模式跑了一段时间:
python -m memray run --live app.py看到某个模块的内存一直在涨,定位到这段代码:
# 有问题的代码(简化)
class DataProcessor:
_cache = {} # 类变量,所有实例共享
def process(self, data_id: str, data: dict):
result = expensive_computation(data)
self._cache[data_id] = result # 每次处理都往里加,从不清理
return result问题很明显:_cache 是类变量,永远不会被 GC,每次处理都往里塞数据,永远不清理。
# 修复:用 LRU 缓存,限制大小
from functools import lru_cache
from cachetools import TTLCache
class DataProcessor:
_cache = TTLCache(maxsize=1000, ttl=3600) # 最多1000条,1小时过期
def process(self, data_id: str, data: dict):
if data_id in self._cache:
return self._cache[data_id]
result = expensive_computation(data)
self._cache[data_id] = result
return result组合使用策略
不同工具适合不同阶段:
服务无响应 / 卡死
→ py-spy dump --pid <PID>:立刻看到所有线程在干嘛
CPU 高,不知道热点在哪
→ py-spy record:采样生成火焰图,找最宽的函数
内存持续增长
→ memray:找到分配内存最多的代码路径
逻辑 bug,需要单步追踪
→ ipdb:设断点,检查变量状态
测试环境复现的 bug
→ pdb/ipdb + pytest --pdb:测试失败时自动进断点一个实用的调试启动配置
# debug_utils.py
import os
import signal
import logging
import sys
logger = logging.getLogger(__name__)
def setup_debug_signal():
"""
注册 SIGUSR1 信号,收到信号时打印所有线程的调用栈
生产环境可以用 kill -USR1 <pid> 触发,不需要重启服务
"""
def dump_stacks(signum, frame):
import traceback
import threading
print("=== Thread dump ===", file=sys.stderr)
for thread_id, frame in sys._current_frames().items():
thread_name = threading._active.get(thread_id, threading.Thread()).name
print(f"\nThread: {thread_name} (id={thread_id})", file=sys.stderr)
traceback.print_stack(frame, file=sys.stderr)
signal.signal(signal.SIGUSR1, dump_stacks)
logger.info("Debug signal handler registered. Use 'kill -USR1 <pid>' to dump stacks.")
# 在 main.py 里调用
if __name__ == "__main__":
setup_debug_signal()
# ...启动服务这个信号处理器非常实用:服务卡住时,不用 py-spy,直接 kill -USR1 <pid> 就能打印当前所有线程的调用栈,快速定位问题。
好的调试工具是放大镜,不是魔法棒。工具能帮你看见问题,但理解为什么是问题,还是要靠你自己。
