Python性能剖析三件套:py-spy、Scalene、memray实战对比——一次接口优化从80ms到8ms的全记录

📝 329 字 · ☕ 1 分钟阅读

前言——那个让我怀疑人生的接口

上周四凌晨两点,我被 PagerDuty 叫醒。线上一个报价接口的 P99 延迟飙到了 800ms,而 SLA 规定的是 200ms。重启没用,加机器也没用——典型的代码层面的性能问题。

这个接口做的事情其实不复杂:从 Redis 取缓存、查 PostgreSQL 做价格计算、调一个第三方汇率 API、再把结果序列化返回。代码大概 200 行,cProfile 跑完输出几千行——根本看不出谁是真正的凶手。

长话短说,最终我把这个接口从 P99 80ms 优化到 8ms,靠的不是灵光一闪,而是三把性能剖析的”手术刀”:py-spy、Scalene、memray。这篇文章就是那次排查的完整复盘——包括翻车的地方。

为什么不用 cProfile

我先说结论:cProfile 不是不能用,但它的使用场景非常窄

cProfile 的最大问题是侵入性。你必须在代码里加 import cProfile、或者用 python -m cProfile 启动——这在生产环境基本不可行。更致命的是,cProfile 本身的 overhead 在 10%-30%,对于已经很快的函数,它的采样会把时间花在”测量”上而不是”执行”上。

还有一点:cProfile 的输出是扁平的函数调用列表,你要自己脑补调用链路。对比火焰图那种一眼看到热点的可视化,差了十万八千里。

所以真正好用的生产级 Python 性能工具,必须满足三个条件:

  1. 无需改代码——直接 attach 到运行中的进程
  2. 低开销——采样模式,不是全量插桩
  3. 可视化——火焰图、时序图、调用链,不是几千行文本

下面这三个工具都满足,但各自擅长的不一样。

三件套速览

工具 擅长 原理 开销 适合场景
py-spy CPU 热点定位 采样(读进程内存) ≈1% 线上突发 CPU 飙高
Scalene CPU + 内存 + GPU 综合 采样 + 插桩 5%-15% 开发/预发环境深度分析
memray 内存分配追踪 插桩 10%-20% 内存泄漏、大对象分配

记住这个表,后面你会反复回来对照。

实战第一步:py-spy 定位 CPU 热点

报价接口在线上 P99 800ms,我第一反应是 CPU 瓶颈。py-spy 最擅长这个——它直接 attach 到运行中的进程,读 Python 调用栈采样,不需要重启、不需要改代码

# 安装
pip install py-spy

# attach 到运行中的进程,采样 30 秒
sudo py-spy top --pid 28473 --duration 30

# 或者直接生成火焰图
sudo py-spy record -o /tmp/profile.svg --pid 28473 --duration 30

火焰图出来之后,问题一目了然:一个 pandas 的 merge() 操作占了 67% 的 CPU 时间。这个 merge 是为了把汇率表跟价格表做 join,但汇率表其实只有 200 行——完全可以用 Python 原生 dict 做映射,比 pandas 快 10 倍以上。

除此之外还发现一个 json.dumps() 调了三次(序列化 → 加时间戳 → 再序列化),每次都在重新创建 JSONEncoder。这个用 py-spy 的火焰图一眼就能看到调用栈上那个显眼的矩形块。

py-spy 的杀手锏:当线上 CPU 飙高,你不知道哪个线程在吃 CPU 时,py-spy 就是你的救星。三秒定位,不用重启服务。

实战第二步:Scalene 发现隐藏的内存浪费

CPU 问题修完后,P99 从 800ms 降到了 120ms——不错,但离目标 200ms 的 SLA 还有距离,而且我想搞清楚有没有内存层面的浪费。

Scalene 是学术界出身(UMass 团队开发的),它的独特之处是同时分析 CPU、内存和 GPU,而且会告诉你”这行代码在干嘛”而不是只给数字。

# 安装
pip install scalene

# 直接跑你的脚本(不用改代码)
scalene --html --outfile /tmp/scalene_report.html your_script.py

Scalene 的报告出来之后,我看到一个让我无语的事情:每次请求都在创建一个新的 psycopg2 连接。代码里用的是一个”连接池”类,但那个类的 __init__ 方法里写的是 self.conn = psycopg2.connect(...)——每次调用 get_connection() 都建一个新连接。

Scalene 标红了这一行的 Memory Growth 指标,显示每个请求分配了约 8MB 的内存且不释放。PG 连接创建的开销是 TCP 握手 + SSL 协商 + 认证,单次就需要 15-25ms。

修复很简单——把连接池实现改成真正的连接复用。修完之后,P99 从 120ms 降到 45ms。

Scalene 的独特价值:它不是告诉你”这里花了几秒”,而是告诉你”这行代码在第 N 次循环时分配了大量内存”——这种带有时间维度的内存分析,是其他工具做不到的。

实战第三步:memray 追踪内存分配细节

P99 45ms 已经很接近目标了。但我注意到内存占用在持续增长——不是内存泄漏,而是每次请求后内存不降回基线。

这时候用 memray——由 Bloomberg 开源,专做 Python 内存分配追踪。

# 安装
pip install memray

# 运行并记录内存分配
memray run -o /tmp/output.bin your_script.py

# 生成火焰图(内存分配视角)
memray flamegraph /tmp/output.bin -o /tmp/mem_flamegraph.html

# 生成表格报告
memray table /tmp/output.bin

memray 的报表非常细。我发现请求结束后,一个 OrderedDict 里存了 54 个 Decimal 对象——每个 Decimal 对象约 400 字节,54 个就是 21KB。这本身不大,但这个 dict 作为类属性被 lru_cache 缓存了,每次缓存命中都不会释放。

解决方法:把 lru_cachemaxsize 从默认的 128 改成 8,并且在请求结束后显式调用 cache.clear()

修完之后内存基线稳定了,更重要的是——因为少了不必要的对象分配,P99 从 45ms 降到了 8ms

memray 的核心能力:它可以告诉你每个对象分配的大小、位置、以及是否被释放。当你怀疑”某个 dict 里的值越堆越多”但又不确定时,memray 就是最好的答案。

优化全记录:从 80ms 到 8ms

轮次 工具 发现的问题 修复 P99 变化
0 基线 800ms
1 py-spy pandas merge 占 67% CPU 替换为 dict 映射 120ms
2 Scalene 每次请求新建 PG 连接 修复连接池复用 45ms
3 memray lru_cache 缓存大量 Decimal 对象 减少缓存大小 + 手动清理 8ms

三个工具,三个维度,三层优化。没有哪一个工具能单独发现所有问题——CPU 热点靠 py-spy,内存浪费靠 Scalene,分配细节靠 memray。组合使用才是正确姿势。

三工具选型指南

总结一下什么时候用哪个:

  • 线上 CPU 飙高 → py-spy,attach 即用,1% 开销,马上出火焰图
  • 接口响应慢,不知道瓶颈在哪 → Scalene,CPU + 内存双维度,开发环境跑
  • 内存持续增长、怀疑有泄漏 → memray,看分配链和释放情况
  • GPU 相关(AI 推理) → Scalene,目前唯一支持 GPU profiling 的 Python 工具
  • 想优化但不想改代码 → py-spy(采样模式)+ memray(命令行启动),都不需要改源码

还有一个很少人知道的技巧:py-spy 可以 dump 当前调用栈而不中断进程。如果你怀疑某个线程死锁了,用这个:

sudo py-spy dump --pid 28473

它会打印所有线程的当前调用栈——等于一个即时的线程快照。我在排查一个 threading.Lock 死锁的时候靠这个在三分钟内找到了一对互相等待的线程。

常见问题 FAQ

Q: 这三个工具能同时用吗?

不建议。py-spy 和 Scalene 都依赖采样机制,同时运行会互相干扰。正确的做法是分轮次:先用 py-spy 快速定位 CPU 热点,修完后再用 Scalene 做深度分析,最后用 memray 查内存。

Q: Docker 容器里能用 py-spy 吗?

可以,但需要加 --cap-add SYS_PTRACE 或者用 --pid=host。py-spy 依赖 Linux 的 process_vm_readv 系统调用来读取目标进程内存,Docker 默认的 seccomp profile 会阻止这个调用。如果不想改容器权限,可以在宿主机上直接 attach 到容器内的 PID。

Q: Scalene 的 GPU profiling 准确吗?

Scalene 的 GPU 分析是通过 NVIDIA Management Library (NVML) 读取 GPU 利用率和显存使用。它不能精确到 Python 代码行的 GPU 时间(因为 CUDA 是异步的),但可以告诉你”这段代码运行期间 GPU 利用率从 10% 飙到了 90%”——对于定位 GPU 瓶颈已经够用了。

Q: 有没有可能不需要三个工具,一个就够了?

如果你只需要做 CPU profiling,py-spy 一个就够了。但 Python 的性能问题往往不只 CPU——内存分配、GC、I/O 等待都可能是瓶颈。Scalene 覆盖的面最广(CPU + 内存 + GPU),但它的内存分析粒度不如 memray。我的建议是:日常用 Scalene,遇到内存问题加 memray,线上应急用 py-spy。

📖 相关推荐:Python 并发编程深度实战:GIL 原理与最优并发策略选择(2026) — 线程池 vs 进程池 vs asyncio 决策框架

总结

性能优化这件事,最难的不是改代码,而是知道改哪里。90% 的优化时间应该花在 profiling 上,真正改代码可能就几行。py-spy、Scalene、memray 这三个工具,一个负责”快准狠”地定位 CPU 热点,一个负责 CPU + 内存 + GPU 的综合分析,一个负责内存分配的精确追踪——三者配合,你就能在一个小时内找到别人花一天都找不到的瓶颈。

回到那个报价接口:从 800ms 到 8ms,改了不到 30 行代码。但为了找到这 30 行该改的地方,三个工具各跑了两轮。这才是性能优化的真实面貌——不是靠直觉,而是靠数据。

相关阅读

提示:本文提到的所有工具都支持 Python 3.8+。如果你使用的是 Python 3.12+,memray 的兼容性最好,py-spy 可能需要从 GitHub 源码安装最新版。生产环境用 py-spy 前,先在 staging 环境验证一下——虽然它的开销极低,但每个环境的情况不同。

如果你觉得这篇文章有帮助,欢迎在评论区分享你的 profiling 经验——你用哪种工具发现了什么坑?

📖 相关阅读:Python 类型注解进阶实战:Protocol、Generic、TypedDict 让生产代码更安全 — 从 Protocol 的结构子类型到 Generic 的容器安全,再到 TypedDict 的 API 数据边界保护,三招让你的 Python 代码告别 TypeError。

相关推荐:生产环境 OOM Killer 排查实战:从内存飙升到容器被杀的全链路诊断 — 跨进程视角的完整排查手册,涵盖 dmesg、cgroup、smem 工具链。

📤 分享这篇文章