优化了一圈,跑起来没变快——这件事几乎每个 Python 项目都发生过一次。典型过程是:看到一段循环不顺眼,换成列表推导;看到字符串拼接,改成 join;看到字典访问,改成局部变量。三天后跑基准,端到端耗时几乎没动——因为那几秒里绝大部分时间在等一个外部 HTTP 接口。
问题不在技术,在顺序:先动手,后定位。这篇给的是顺序本身——每个阶段该回答什么问题、用什么工具、什么时候应当停下来承认"这不是本地能优化的"。
1. 动手前必须回答的三个问题
| 问题 | 不合格的回答 | 合格的回答 |
|---|---|---|
| 瓶颈在哪一段 | "感觉在计算" | "占最大头的函数是 X,其内部耗时主要在 json.loads"(要给占比,不是感觉) |
| 目标是多少 | "越快越好" | "P95 降一半"或"单批耗时进到秒级以内",须可测 |
| 可接受的改动范围 | 未想过 | "不改接口契约、不引入新运行时" |
第二个问题尤其容易被跳过。没有目标就没有停止条件,优化会一直做到"看起来没问题"为止,然后在下一次需求变更时被覆盖掉。第三个问题决定方案池:如果"不改接口"是硬约束,那么缓存、批量化、异步化里有一部分从一开始就不在候选里。
2. 测量四层:从粗到细,逐层下钻
| 层 | 工具 | 回答 | 开销 |
|---|---|---|---|
| 端到端 | time、time.perf_counter、hyperfine |
到底慢不慢、波动多大 | 近零 |
| 函数级 | cProfile、py-spy record、scalene |
哪个函数占大头 | 中到高 |
| 行级 | line_profiler |
函数内哪一行重 | 高(逐行计时) |
| 系统级 | py-spy dump、strace、perf |
在等 IO、等锁,还是真在算 | 低 |
$ hyperfine --warmup 3 --runs 10 'python -m app.batch' # 端到端,先确认值得优化
$ python -m cProfile -o prof.out -m app.batch # 函数级,落文件便于统计
$ python -c "import pstats; pstats.Stats('prof.out').sort_stats('cumtime').print_stats(20)"
$ py-spy record -o prof.svg --pid 12345 --duration 30 # 生产可用,无需改代码
$ py-spy dump --pid 12345 # 一次性看栈,判断是否卡在 IO
顺序必须是端到端 → 函数级 →(必要时)行级。跳过第一层直接上 profiler 的典型后果是:花了两小时优化一个只占 3% 的函数。
3. cProfile 的开销会改变结论
cProfile 是确定性的函数级插桩:每次调用与返回都要记账。它的开销不是常数,而与被调用次数成正比——一个执行 10 万次的小函数,在被 profiler 观察时可能从 5% 膨胀成 40%。
| 现象 | 原因 | 应对 |
|---|---|---|
| 小函数突然排第一 | 调用次数放大开销 | 换 py-spy(采样型,不插桩) |
| 总耗时远大于真实值 | 插桩 + 序列化开销 | 只信相对排名,不信绝对秒数 |
| 递归深度失真 | 递归调用计数膨胀 | 用 cumtime 而非 tottime 判断 |
| C 扩展内部不可见 | profiler 只看到 Python 帧 | 结合 perf 或 scalene |
# 采样型测量在生产上更诚实:py-spy 直接读进程内存的栈,不改代码、不加插桩
# $ py-spy top --pid 12345 # 实时看热点
# $ py-spy record -f speedscope -o s.json --pid 12345
判据:要看"谁被调用得最多",用采样;要看"谁调用了谁",用 cProfile。两者混用会把"调用次数多"误读为"耗时长"。
4. IO 密集还是 CPU 密集:算等待占比
| 观察 | IO 密集 | CPU 密集 |
|---|---|---|
| 栈顶常见 | select、read、recv、wait |
业务函数、json、hashlib |
| CPU 使用率 | 低(单核远未饱和) | 接近 100% |
| 加并发是否有收益 | 有(线程/协程都行) | 只在多进程下有 |
| 加机器是否有收益 | 有 | 有 |
# 等待占比的粗算:wall clock 与 CPU time 的比值
$ /usr/bin/time -v python -m app.worker 2>&1 | grep -E 'Elapsed|User time|System time'
# Elapsed 30s / User 3s -> 90% 在等,优化计算毫无意义
比值是最便宜的判据:wall / cpu > 5 就是 IO 密集,此时所有"减少 Python 解释开销"的努力都被等待吞掉。这也是最常见的误判来源——本地跑批时是 CPU 密集(数据在内存),线上跑时是 IO 密集(数据在网络),用本地的结论去优化线上的代码,方向一开始就错。
5. 收益排序:五档,从高到低
| 档 | 手段 | 典型收益 | 风险 |
|---|---|---|---|
| 1 | 算法与数据结构 | 数量级 | 低(但要重构) |
| 2 | 减少调用次数(N+1、循环内查询) | 10 倍以上 | 低 |
| 3 | 批量化(executemany、批量 HTTP) |
数倍 | 中(错误处理变复杂) |
| 4 | 换实现(numpy、C 扩展、orjson) |
数倍 | 中高(依赖与兼容) |
| 5 | 局部微优化(局部变量、推导式) | 百分之几 | 低但收益也低 |
# 第 2 档的典型:循环里查数据库,把 1000 次往返压成 1 次
# 优化前
for oid in order_ids:
order = db.query_one("SELECT * FROM orders WHERE id=%s", oid) # 1000 次 RTT
# 优化后
rows = db.query_all("SELECT * FROM orders WHERE id = ANY(%s)", (order_ids,)) # 1 次
微优化之所以排最后,不是因为它无效,而是因为它的收益上限被前四档决定:把占 2% 的代码优化 30%,端到端提升 0.6%,落在测量噪声里。先做能改数量级的事。
6. "Python 慢"的三种真实原因
| 原因 | 表现 | 解法 | 边界 |
|---|---|---|---|
| 解释执行 | 纯 Python 循环占大头 | 向量化、C 扩展、换算法 | 循环体本身很轻时,向量化收益有限 |
| 对象开销 | 大量小对象、字符串、字典构造 | 复用、__slots__、生成器 |
对象生命周期短时,GC 压力才是主因 |
| GIL 争用 | 多线程 CPU 密集无加速 | 多进程、释放 GIL 的扩展 | IO 密集时 GIL 不是瓶颈 |
三者要分开诊断,因为解法互斥:一个"多线程没提速"的服务,可能瓶颈是解释执行(该向量化),也可能是 GIL(该多进程),还可能是锁(该改共享方式)。用 py-spy dump 看栈就能区分——栈顶集中在业务代码是前者,集中在 acquire 是后者。
7. 明知会炸的写法,与缓存的失效代价
| 写法 | 代价 | 替代 |
|---|---|---|
| 循环内查库/调接口 | 往返次数 × 延迟 | 批量查询、预取 |
循环内字符串 += |
平方级复制 | join / StringIO |
| 循环内编译正则 | 重复解析 | 提到模块级常量 |
循环内 json.loads 同一份数据 |
重复解析 | 解析一次传对象 |
| 无界缓存 | 内存增长、失效混乱 | 有界 + TTL |
import re, functools
PATTERN = re.compile(r"\d{4}-\d{2}-\d{2}") # 模块级,编译一次
@functools.lru_cache(maxsize=1024) # 有界,避免无界增长
def normalize(code: str) -> str:
return code.strip().upper()
缓存的成本不在存储,在失效:只要加了缓存,就必须回答"什么时候它会变脏"。答不出来,就等于用一致性换速度,而且这笔交换是隐式的——半年后某个用户看到旧数据,没人会想到是当初那行 lru_cache。
8. 基准测试与回归防护
| 要素 | 错误做法 | 正确做法 |
|---|---|---|
| 预热 | 直接跑一次 | 先跑几轮让 JIT/缓存/连接池就绪 |
| 重复 | 取单次最快值 | 多次取中位数与分位数 |
| 隔离 | 边跑边刷网页 | 固定机器、固定负载、关闭同机服务 |
| 度量 | 只看平均值 | 中位数 + P95,避免长尾被平均掉 |
| 对比 | 改完凭感觉 | 保留旧实现做 A/B,同进程交替跑 |
# pyperf / timeit:把"测量"写成可复现的脚本,而不是临时敲的命令
import timeit
base = timeit.timeit("impl_a(data)", globals=globals(), number=200, repeat=5)
new = timeit.timeit("impl_b(data)", globals=globals(), number=200, repeat=5)
print(f"median a={sorted([base])[2]:.4f}s b={sorted([new])[2]:.4f}s")
回归防护的成本要明说:把性能基准放进 CI 意味着每次构建多花几分钟,并且要维护阈值。可行的最小做法是只对 2–3 个核心路径设"不得慢于基线 20%"的守门线,而不是给每个函数都设阈值——后者会因为机器噪声不断误报,最终被整体关掉。
9. 排错:五种典型现象
| 现象 | 原因 | 方向 |
|---|---|---|
| 优化后没变快 | 瓶颈在外部服务 | 先看等待占比 |
| 本地快、线上慢 | 数据规模或 IO 形态不同 | 用线上数据采样复现 |
| profiler 结果每次都不同 | 采样噪声 + 未预热 | 多次取中位、固定环境 |
| 加了并发更慢 | 任务粒度过小、GIL 争用 | 增大批量、换进程 |
| 内存随运行时间增长 | 无界缓存、对象泄漏 | 有界缓存 + 对象计数 |
10. 边界:这些情况不该在本地优化
| 场景 | 判断 | 理由 |
|---|---|---|
| 一次性脚本 | 不优化 | 跑完就扔,优化时间超过节省时间 |
| 总耗时在毫秒级 | 不优化 | 噪声大于收益,测不出来 |
| 瓶颈在外部服务/数据库 | 不优化本地 | 本地改 10% 也抵不过一次慢查询 |
| 目标不明确 | 先定目标 | 无停止条件等于无限投入 |
| 代码即将重写 | 不优化 | 优化成果会随重写一起消失 |
一句话:性能优化的第一产出不是更快的代码,而是一个能重复的测量。有了测量,顺序自然清楚;没有测量,所有努力都在赌方向。
11. 自测
- 端到端 8 秒的批处理,你会先测什么再决定优化哪一段?
cProfile为什么可能让你误判"最该优化的函数"?- 用什么最便宜的指标判断任务是 IO 密集还是 CPU 密集?
- 为什么"减少调用次数"排在"局部微优化"前面?
- 加缓存时必须同时回答哪个问题?答不出来会付什么代价?
- 性能基准进 CI,最小可行的守门方式是什么?