第 18 章 性能剖析与优化

点题:别靠猜。先用 cProfile 找到时间花在哪,用 tracemalloc 找到内存去哪了,再用缓存、slots、合适的数据结构把这些热点啃下来。

老哥,你在 Go 里遇到慢接口,第一反应是不是 pprof 挂上、火焰图一看、哪个函数吃 CPU 一目了然?那我问你:你写 Python 的时候,是不是经常凭感觉改代码——「这里我觉着慢,加个缓存试试」「这个循环好像能优化」——然后跑一遍看体感?

老哥:被你说中了……确实经常是 foo 慢就改 foo,至于 foo 到底占了多少时间,心里没谱。

墨叔:这就是初级和 senior 的分水岭。Go 把你训练成了「先量再改」的人,到了 Python 这套本能不能丢。这章教你三件武器:量时间的 cProfile、量内存的 tracemalloc、以及几把真正好使的优化刀(缓存、slots、数据结构权衡)。记住一句话:没有剖析的优化都是玄学。

一、怎么量——cProfile 做时间剖析

cProfile 是标准库自带的确定性剖析器,统计每个函数被调用多少次、总共花了多少「挂钟时间」里的 CPU。最小用法:

import cProfile, pstats
 
def slow_parse(raw: list[str]) -> dict[str, int]:
    result: dict[str, int] = {}
    for line in raw:
        for ch in line:              # 故意写得低效
            result[ch] = result.get(ch, 0) + 1
    return result
 
def build_data(n: int) -> list[str]:
    return ["abc" * 50 for _ in range(n)]
 
if __name__ == "__main__":
    prof = cProfile.Profile()
    prof.enable()
    slow_parse(build_data(20000))
    prof.disable()
    stats = pstats.Stats(prof).sort_stats("cumtime")
    stats.print_stats(10)            # 打印耗时最高的前 10 个函数

sort_stats("cumtime") 按「累计时间」排,一眼能看到是哪个函数把时间吃没了。print_stats 给出每次调用的平均耗时、总调用次数。你在命令行也能直接 python -m cProfile -s cumtime your_script.py,不用改代码。

老哥:这跟 Go 的 go tool pprof 比,弱在哪?

墨叔:cProfile 是确定性的(精确计数,不是采样),小到中等规模很准;但它只记「函数级」耗时,看不出函数内部哪一行最慢。Go 的 pprof 默认是采样,能出火焰图、能看行级,生态更顺手。想看 Python 的行级热点,得请出 line_profiler(配 kernprof 用):在目标函数上打 @profile 装饰器,跑 kernprof -l -v script.py,它会逐行告诉你每行执行了多少次、花了多少时间。这工具不在标准库,要 pip install line_profiler,但思路你完全能套——它就是 cProfile 的「显微镜版」。

找瓶颈的正确姿势:先 cProfile 定位到「慢函数」,再用 line_profiler 看「函数里哪行最慢」,两层下来,热点藏不住。

二、什么场景该剖析,对照 Go 的边界

你该在什么时候掏剖析器?三件事触发它:

  1. 刚上线发现 P99 飙了,但代码「看起来都不慢」——这是最典型的「凭感觉改不动」场景,必须量。
  2. 重构前后要比对性能,不能靠手感说「好像快了」。
  3. 内存只涨不跌,怀疑有泄漏或对象爆量——这时候 cProfile 没用,得上内存剖析。

对照 Go:你的 pprof 既能看 CPU 也能看 heap,一套工具打天下;Python 里时间剖析和内存剖析是两把不同的枪——cProfile 管时间,tracemalloc 管内存,别拿错。这点最容易让 Go 老兵迷糊:在 Python 里「慢」和「占内存」是分开诊断的。

import tracemalloc
 
def build_blobs(n: int) -> list[dict]:
    return [{"id": i, "payload": "x" * 1024} for i in range(n)]
 
if __name__ == "__main__":
    tracemalloc.start()
    blobs = build_blobs(50000)
    snapshot = tracemalloc.take_snapshot()
    top = snapshot.statistics("lineno")[:5]
    for stat in top:
        print(stat)
    del blobs

tracemalloc 追踪每次内存分配的「在哪一行、哪个文件」,按行统计占用。它会告诉你「第 N 行这个 list 推导吃掉了 50MB」——比 objgraph 那种看对象图的工具更直接定位到代码位置。注意它只追踪 Python 层分配,C 扩展(NumPy 数组、PIL 图片)占的内存它看不到,那种情况得用 pympler 或 memory_profiler 辅助。

三、用它的收益与代价,以及 senior 的优化刀

先说剖析本身的代价:cProfile 会让程序慢 2~10 倍、tracemalloc 也有开销。所以永远别在生产常开,只在复现问题时临时挂上,或者用环境变量控制。

定位到热点后,才是真正动手。我给你三把最常用的刀,都是「收益高、代价低」的:

第一把刀:functools.lru_cache 缓存纯函数结果。 你那个「输入相同、算得贼慢」的函数,别每次都重算:

from functools import lru_cache
 
@lru_cache(maxsize=1024)
def fetch_user_tier(user_id: int) -> str:
    # 真实项目里这里是一次 DB/缓存查询,贵
    time.sleep(0.05)
    return "vip" if user_id % 3 == 0 else "normal"
 
def score_orders(orders: list[int]) -> float:
    return sum(len(fetch_user_tier(uid)) for uid in orders)  # 重复 uid 直接命中缓存

lru_cache 把「参数→返回值」记在哈希表里,相同参数直接返回,最近最少用的会被踢出(maxsize 控制容量)。对照 Go,你大概会自己用 sync.Map 加 sync.Once 或 singleflight 手写一套缓存——Python 一行装饰器搞定。代价?它要求参数是可哈希的(dict、list 不行),而且如果底层数据变了,缓存可能返回旧值,所以只用在「纯函数 / 强缓存」场景,必要时 .cache_clear()。

第二把刀:slots 省内存。 你定义一个有很多实例的类(比如解析出的每条记录一个对象),默认每个实例都带一个 __dict__,为了动态加属性,内存开销不小:

class Point:
    __slots__ = ("x", "y")     # 锁死属性,不再有 __dict__
    def __init__(self, x: float, y: float) -> None:
        self.x, self.y = x, y
 
# 对比默认类:百万实例时,__slots__ 能省下约 40%~50% 内存

__slots__ 告诉解释器「这个类只有这些属性」,于是每个实例不再分配 __dict__,改为用更紧凑的定长数组存字段。代价:不能再动态加属性(这符合 Go 结构体「字段固定」的直觉,对你反而是优点),且继承时要小心(子类也得声明 slots 才能延续收益)。当你有「海量小对象」时(日志条目、解析节点),这把刀立竿见影。

第三把刀:dataclass vs dict 的开销权衡。 很多 Go 工程师习惯「数据就用 dict 装着传」,图省事。但字典查找比属性访问慢、而且占内存、而且没有类型提示。来看对照:

from dataclasses import dataclass
 
@dataclass
class Record:
    user_id: int
    amount: float
    tier: str
 
# dict 写法:record["amount"]  —— 字符串哈希查找,慢、易拼错、无类型
# dataclass 写法:record.amount —— 属性访问,快、IDE 补全、mypy 可查

dataclass 的字段访问走的是实例 __dict__(除非配合 __slots__),比 dict 的键查找略快、可读性强、还能被 mypy 检查。但要说内存,裸 dict 和 dataclass 其实半斤八两,真要抠内存还是得 __slots__。所以 senior 的取舍是:对外接口 / JSON 序列化用 dict,内部领域对象用 dataclass,海量对象再上 __slots__——不是非此即彼,而是按密度分层。

四、一个贴近项目的例子

给你一个「慢查询」的真实修复过程。假设有个函数要根据用户算月度账单汇总,里面反复查用户等级:

import time
from functools import lru_cache
from dataclasses import dataclass
 
@dataclass
class Bill:
    user_id: int
    amount: float
 
def _tier_from_db(user_id: int) -> str:
    time.sleep(0.01)                      # 模拟一次 DB 查询
    return "vip" if user_id % 4 == 0 else "normal"
 
def summarize_slow(bills: list[Bill]) -> dict[str, float]:
    totals: dict[str, float] = {}
    for bill in bills:
        tier = _tier_from_db(bill.user_id) # 每个 bill 都查一次,重复极多
        totals[tier] = totals.get(tier, 0.0) + bill.amount
    return totals
 
@lru_cache(maxsize=4096)
def _cached_tier(user_id: int) -> str:
    return _tier_from_db(user_id)
 
def summarize_fast(bills: list[Bill]) -> dict[str, float]:
    totals: dict[str, float] = {}
    for bill in bills:
        tier = _cached_tier(bill.user_id)  # 同 user_id 命中缓存
        totals[tier] = totals.get(tier, 0.0) + bill.amount
    return totals
 
if __name__ == "__main__":
    bills = [Bill(i % 50, float(i)) for i in range(20000)]  # 2万条,仅 50 个用户
    t0 = time.perf_counter(); summarize_slow(bills); print("slow", time.perf_counter() - t0)
    t0 = time.perf_counter(); summarize_fast(bills); print("fast", time.perf_counter() - t0)

一万次里实际只有 50 个不同用户,slow 版本硬查了一万次 DB,fast 版本靠 lru_cache 把 50 个结果记住,后续全命中。真实项目里这一刀能把「N 次查询」砍成「去重后 K 次」,K 远小于 N——这就是典型的「剖析发现热点→缓存消除重复」的闭环。

再补一个内存场景:如果你要解析百万行日志,每行丢进一个 dict,内存会爆;改用语义明确的 dataclass + __slots__,或者干脆用生成器逐行 yield(不一次性攒进 list,详见第 4 章惰性计算),内存立刻从「GB 级」压到「常数级」。

老哥:所以优化的顺序应该是——先剖析定位,再选最便宜的那把刀,而不是一上来就重写?

墨叔:正是。我见过太多人一看慢就把整个实现推倒重写,结果新代码 bug 一堆、性能还没老代码好。senior 的做法永远是:量 → 定位热点 → 用最小代价的手段消除热点 → 再量验证。缓存、slots、生成器、合适的数据结构,这四样覆盖了 Python 项目里 80% 的性能问题,先把这些用熟,别急着上 C 扩展、别急着换语言。

五、常见瓶颈清单与 senior 的优化纪律

光有刀不够,你得知道敌人在哪。我列几个 Python 项目里最高频的「隐形杀手」,对照 Go 你会有同感:

  • N+1 查询。循环里每次查一次 DB/远程,一百条数据就打一百次网络。Go 里你也踩过这坑,解法一样——批量查、或预取、或用缓存(第三节的 lru_cache 就是单进程版「预取」)。网络往返的代价永远比本地计算高几个数量级,先砍这个。
  • JSON 序列化在大对象上。一次 json.dumps 一个十万字段的 dict,CPU 和内存都吃不消。考虑只序列化必要字段、或换 orjson 这种带 C 加速的库(比标准库快数倍)。对照 Go:你用 json.Marshal 前也会想「这结构体是不是太大了」。
  • 正则表达式没编译。在循环里反复写 re.search(r"...") 而不用 re.compile 预编译,每次都重新解析Pattern。小坑,但热路径上积少成多。
  • 在 async 里调同步阻塞函数。这是 Python 独有的大坑:你在 async def 里调了一个会卡 200ms 的同步 DB 驱动,整个事件循环被你一个人拖死——所有并发请求一起卡。对照 Go 没有这问题,因为 goroutine 是真并行。Python 的解法是换异步驱动(httpx/aiohttp/asyncpg),或把阻塞活儿丢进 run_in_executor(走线程池),别让它占着事件循环。
  • 大对象反复拷贝。Python 的赋值只是绑引用,但很多 API(如函数返回新 list、.copy())会真拷贝。处理大数组时,优先考虑原地修改或生成器流式处理,别动不动 new_list = old_list[:]。

再说 senior 的优化纪律,几条铁则:

  1. 先有基线,再谈优化。用 time.perf_counter() 或 timeit 给热点函数建一条基准线,timeit.timeit("slow()", globals=globals(), number=100) 能给你可信的微基准,避免「我感觉快了」这种幻觉。对应 Go 的 go test -bench。
  2. 二八原则。20% 的代码吃掉 80% 的时间,别在冷路径上瞎优化。cProfile 排第一的那个函数,才是你该下刀的地方。
  3. 不要过度优化。可读性也是性能的一部分——把你三个月后看不懂的「位运算优化」换回清晰的写法,往往更划算。当优化让代码变成天书,收益已经抵不过维护成本。
  4. 生产环境用采样剖析器。cProfile 要改代码、有 overhead;线上想无侵入看火焰图,用 py-spy(pip 装, attach 到运行中的进程,py-spy top 实时看、py-spy record -o flame.svg 出火焰图)。它等于 Go 的 pprof 在线采样,不重启、几乎零侵入,是排线上 CPU 问题的神器。
  5. 架构级瓶颈别用微优化硬扛。如果瓶颈是「每次请求都重连数据库」「没用连接池」「同步串行调了五个下游」,那再怎么抠 lru_cache 也救不了。先改架构(连接池、并发、缓存层),微优化是最后一步,不是第一步。

老哥:所以「性能优化」在我脑子里,应该是「度量 → 架构 → 微优化」这个顺序,而不是反过来?

墨叔:完全正确。你 Go 里的经验直接平移:先看 pprof 火焰图定位,再决定是加缓存、改并发模型、还是重写热点函数。Python 只是工具从 pprof 换成了 cProfile/py-spy,方法论一字不差。记住,没有度量就动手的优化,叫「玄学调参」,不叫工程。

【思考题】

  1. 你用 cProfile 发现某函数 cumtime 很高,但 tottime(函数自身耗时,不含子调用)很低。这说明瓶颈在它自己还是它的子调用?你下一步该看什么?如果反过来 tottime 高、cumtime 接近,又说明什么?
  2. 你的服务有个函数 get_config(key) 被高频调用,内部每次都读一次 Redis。你给它加了 @lru_cache。上线后运营改了配置,但服务读到的还是旧值。为什么会这样?两种正确的「让缓存失效」的做法是什么?什么情况下 lru_cache 根本不该用?
  3. 一个实时风控服务,每秒处理上万条事件,每条事件会创建一个 Event 对象存进一个不断增长的 list 供下游消费。你担心内存。请列出至少三种降低这里内存/GC 压力的手段,并说明各自的取舍。

【参考答案】

  1. cumtime(累计时间)包含函数自身及其调用的所有子函数的耗时;tottime(自有时间)只算函数体里直接执行的字节码、不含子调用。cumtime 高但 tottime 低,说明这个函数本身没干多少活,时间全花在它调用的子函数上了——比如一个薄封装的 handle_request 调了一堆慢下游。下一步应该顺着调用链往子函数钻,用 line_profiler 或继续按 cumtime 排序看它下面谁最胖,而不是优化这个壳。反过来,tottime 高且 cumtime 与之接近,说明这个函数自身就是热点(它基本不调用别的慢函数,或调用都很轻),那才是真正该优化的「计算内核」,可以上算法优化、缓存、或考虑把最热部分用 NumPy/Cython 重写。一句话记忆:cum 高 tot 低看孩子,tot 高看自己。
  2. 原因:lru_cache 把「参数→返回值」永久记在装饰器内部的字典里,除非容量满被 LRU 踢出或显式清除,否则永不失效。get_config(key) 一旦被缓存,之后所有相同 key 的调用都直接返回旧值,Redis 里改了也看不见。两种正确做法:① 主动失效——在「配置更新」的入口调用 get_config.cache_clear()(或在更新时按 key 删缓存,用自己维护的带 TTL 的缓存如 cachetools.TTLCache 替代 lru_cache);② 用带过期时间的缓存(如 django.core.cache、Redis 客户端自身 TTL、或 cachetools.TTLCache(maxsize=..., ttl=60)),让缓存按时间自然失效。什么情况下 lru_cache 根本不该用:输入空间巨大且无界(maxsize 撑爆内存,比如缓存「每个用户上传的任意字符串」)、返回值随时间/外部状态变化(价格、库存、配置——即非纯函数)、或参数不可哈希(传了 dict/list)。这些情况该用带容量上限+过期策略的缓存,或干脆不缓存。
  3. 三种手段及取舍:① 用 __slots__ 定义 Event 类——消除每个实例的 __dict__,百万级对象可省约一半内存,代价是不能动态加属性(风控场景字段固定,可接受);② 用生成器/流式处理替代「全量攒进 list」——事件来一条处理一条(yield 或回调),下游消费完即释放,内存从 O(总事件数) 降到 O(窗口大小),代价是失去「回看全部历史」的能力,需配合如果需要历史就落盘/入 Kafka;③ 用对象池或定长环形缓冲复用 Event 实例——避免高频创建/销毁触发 GC 抖动(Python GC 对短命小对象其实尚可,但极端 QPS 下仍有压力),代价是代码复杂度上升、要小心状态重置遗漏导致串台。进阶还可考虑用 array/struct 或第三方 dataclass+slots 序列化到紧凑结构,甚至用 pyarrow/numpy 结构化数组批量存事件。取舍核心:内存 vs 可读性 vs 是否需要随机访问历史,按业务真实需求选,不要一味追求极致省内存。