第 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 的边界
你该在什么时候掏剖析器?三件事触发它:
- 刚上线发现 P99 飙了,但代码「看起来都不慢」——这是最典型的「凭感觉改不动」场景,必须量。
- 重构前后要比对性能,不能靠手感说「好像快了」。
- 内存只涨不跌,怀疑有泄漏或对象爆量——这时候 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 blobstracemalloc 追踪每次内存分配的「在哪一行、哪个文件」,按行统计占用。它会告诉你「第 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 的优化纪律,几条铁则:
- 先有基线,再谈优化。用
time.perf_counter()或timeit给热点函数建一条基准线,timeit.timeit("slow()", globals=globals(), number=100)能给你可信的微基准,避免「我感觉快了」这种幻觉。对应 Go 的go test -bench。 - 二八原则。20% 的代码吃掉 80% 的时间,别在冷路径上瞎优化。cProfile 排第一的那个函数,才是你该下刀的地方。
- 不要过度优化。可读性也是性能的一部分——把你三个月后看不懂的「位运算优化」换回清晰的写法,往往更划算。当优化让代码变成天书,收益已经抵不过维护成本。
- 生产环境用采样剖析器。cProfile 要改代码、有 overhead;线上想无侵入看火焰图,用
py-spy(pip 装, attach 到运行中的进程,py-spy top实时看、py-spy record -o flame.svg出火焰图)。它等于 Go 的pprof在线采样,不重启、几乎零侵入,是排线上 CPU 问题的神器。 - 架构级瓶颈别用微优化硬扛。如果瓶颈是「每次请求都重连数据库」「没用连接池」「同步串行调了五个下游」,那再怎么抠
lru_cache也救不了。先改架构(连接池、并发、缓存层),微优化是最后一步,不是第一步。
老哥:所以「性能优化」在我脑子里,应该是「度量 → 架构 → 微优化」这个顺序,而不是反过来?
墨叔:完全正确。你 Go 里的经验直接平移:先看 pprof 火焰图定位,再决定是加缓存、改并发模型、还是重写热点函数。Python 只是工具从 pprof 换成了 cProfile/py-spy,方法论一字不差。记住,没有度量就动手的优化,叫「玄学调参」,不叫工程。
【思考题】
- 你用 cProfile 发现某函数
cumtime很高,但tottime(函数自身耗时,不含子调用)很低。这说明瓶颈在它自己还是它的子调用?你下一步该看什么?如果反过来tottime高、cumtime接近,又说明什么? - 你的服务有个函数
get_config(key)被高频调用,内部每次都读一次 Redis。你给它加了@lru_cache。上线后运营改了配置,但服务读到的还是旧值。为什么会这样?两种正确的「让缓存失效」的做法是什么?什么情况下 lru_cache 根本不该用? - 一个实时风控服务,每秒处理上万条事件,每条事件会创建一个
Event对象存进一个不断增长的 list 供下游消费。你担心内存。请列出至少三种降低这里内存/GC 压力的手段,并说明各自的取舍。
【参考答案】
cumtime(累计时间)包含函数自身及其调用的所有子函数的耗时;tottime(自有时间)只算函数体里直接执行的字节码、不含子调用。cumtime高但tottime低,说明这个函数本身没干多少活,时间全花在它调用的子函数上了——比如一个薄封装的handle_request调了一堆慢下游。下一步应该顺着调用链往子函数钻,用 line_profiler 或继续按cumtime排序看它下面谁最胖,而不是优化这个壳。反过来,tottime高且cumtime与之接近,说明这个函数自身就是热点(它基本不调用别的慢函数,或调用都很轻),那才是真正该优化的「计算内核」,可以上算法优化、缓存、或考虑把最热部分用 NumPy/Cython 重写。一句话记忆:cum 高 tot 低看孩子,tot 高看自己。- 原因:
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)。这些情况该用带容量上限+过期策略的缓存,或干脆不缓存。 - 三种手段及取舍:① 用
__slots__定义Event类——消除每个实例的__dict__,百万级对象可省约一半内存,代价是不能动态加属性(风控场景字段固定,可接受);② 用生成器/流式处理替代「全量攒进 list」——事件来一条处理一条(yield 或回调),下游消费完即释放,内存从 O(总事件数) 降到 O(窗口大小),代价是失去「回看全部历史」的能力,需配合如果需要历史就落盘/入 Kafka;③ 用对象池或定长环形缓冲复用Event实例——避免高频创建/销毁触发 GC 抖动(Python GC 对短命小对象其实尚可,但极端 QPS 下仍有压力),代价是代码复杂度上升、要小心状态重置遗漏导致串台。进阶还可考虑用array/struct或第三方dataclass+slots 序列化到紧凑结构,甚至用pyarrow/numpy结构化数组批量存事件。取舍核心:内存 vs 可读性 vs 是否需要随机访问历史,按业务真实需求选,不要一味追求极致省内存。