上周帮同事排查一段处理日志的脚本他先自己“优化”了一周加了缓存、拆了函数、把变量名从a改成data_list这种结果运行时间只降了 8%。我拿cProfile跑了一遍发现 99% 的耗时在一个看似人畜无害的嵌套循环里换了个数据结构改了一行代码直接快掉 95%。这种例子我遇到过太多次。Python 性能优化从来不缺技巧缺的是“知道该在哪儿用技巧”的判断力。网上那些“一行代码提速十倍”“用这个库让你的代码飞起来”的文章不是说不对而是它们默认你已经有了一张正确的性能地图。没有地图就到处乱挖大概率是在给花盆松土。这篇文章我打算换一个思路——不堆砌一百个技巧而是按我实际做优化的顺序把真正经得起推敲的方法拆开讲清楚。适合刚入门但是写出来的程序跑得慢的人也适合写了两三年 Python、隐约觉得代码还能更快但无从下手的人。我不是要教你背结论而是希望你看完能自己动手定位瓶颈、选对方案。1. 动手优化前先花十分钟把性能瓶颈找出来1.1 cProfile最基础也是最可靠的一把尺很多初学者也包括一些工作好几年的朋友优化代码时靠的是“感觉”——感觉这个函数慢感觉这个循环是罪魁祸首然后埋头重构半天一测性能纹丝不动。我自己的经验是人的直觉在性能分析这件事上准确率不会超过三分之一。所以我的第一个建议永远是先测再改。cProfile是 Python 自带的性能分析工具不需要装任何依赖。用法也很直接python -m cProfile -s cumtime my_script.py把这个跑完终端会打印出一张表格长这样简化版ncalls tottime percall cumtime percall filename:lineno(function) 1 0.002 0.002 8.321 8.321 my_script.py:1(module) 100 0.001 0.000 7.954 0.080 my_script.py:45(process_batch) 50000 0.184 0.000 6.211 0.000 my_script.py:78(find_match)这里我最关注的永远是cumtime和ncalls两列。cumtime这个函数本身加上它调用的一切子函数所花的总时间。它告诉你“问题的出口在哪”。tottime纯粹花在这个函数本体里的时间排除子调用。它告诉你“问题的内脏在哪”。ncalls调用次数。一个函数如果被调了 50 万次哪怕单次很快累加起来也值得盯一眼。上面那张表里find_match的tottime有 6.2 秒占了总时间的一大半而且被调了 50000 次。这个信息基本就是在告诉你要么这个函数内部有低效操作要么调用它的上层逻辑在没必要地重复计算。顺着这条线往下挖比盲猜准确太多。1.2 line_profiler逐行定位热点函数cProfile只能定位到函数级别但是一个函数 6 秒钟到底是哪一行吃掉的时间这时候要用line_profiler。安装很简单pip install line_profiler在需要分析的函数上方加一个装饰器profile然后运行kernprof -l -v my_script.py输出会精确到每一行代码的耗时占比。我之前用它排查过一个字符串处理脚本输出显示有一行if x in some_list:占了所在函数 82% 的时间换成set之后整个流程从 3 分钟降到 4 秒。这种粒度是cProfile给不了的。用line_profiler时有个小建议别一次性给所有函数都加profile那样输出会炸成一锅粥。先拿cProfile定位候选函数再挑两三个加装饰器精确分析效率和效果都最好。1.3 py-spy线上环境不用重启进程的采样利器还有一种更棘手的情况程序已经上线跑起来了速度越来越慢但你总不能把生产进程停了加分析代码吧。这时候py-spy就是救命稻草。pip install py-spy抓取正在运行的 Python 进程的调用栈py-spy dump --pid 12345这会把那个进程当前所有线程的 Python 层调用栈打印出来。多抓几次看哪些调用栈反复出现基本就能定位到热点。除此之外py-spy record还能生成火焰图。火焰图这个东西懂的人一眼就能看出性能瓶颈的形状——宽的那一层就是耗时大户。我在排查一个分布式任务进程时就是靠火焰图发现某个数据序列化函数被反复调用而调用它的上层的缓存策略根本没生效。提示py-spy dump不需要改代码、不需要重启进程但部分环境可能需要sudo权限。另外它是采样式的会有一定误差别指望它精确到微秒。2. 数据结构选型同样是 for 循环为什么差出几十倍2.1 list vs tuple固定集合用 tuple不只是“习惯”Python 里list和tuple表面上都能存一组元素很多教程告诉你“tuple 不可变、list 可变”但没告诉你这背后对性能的含义。我用sys.getsizeof验证过import sys l [1, 2, 3, 4, 5] t (1, 2, 3, 4, 5) print(sys.getsizeof(l)) # 通常比 tuple 大不少 print(sys.getsizeof(t))在 CPython 的实现里list底层是动态数组为了支持append和可能的扩展它会预先多分配一些内存tuple则是定长的对象数组创建之后内存大小就是固定的所以更紧凑访问时也少一层额外的间接性。另外在解包、遍历这类高频操作里tuple的迭代通常比list稍快一点。虽然单次差异可能只有几十纳秒但如果在一个百万级循环里累积起来也是能感知的。我的经验准则是如果一个集合在创建之后内容不会变优先用tuple。这既是性能考虑也是代码语义的自我表达——看到tuple的人会知道这个集合是不变的更不容易写出误改数据的代码。2.2 set 还是 list把 O(n) 的查找降成 O(1)这一节是整篇文章里性价比最高的部分没有之一。很多代码里都会有“判断元素在不在集合里”这种操作names [张三, 李四, 王五, ...] # 假设有10万个 def check(name): return name in namesname in list的时间复杂度是 O(n)——Python 得从头到尾一个个比较。而name in set的时间复杂度是 O(1)底层是哈希表直接计算哈希值定位。我做过一个最简单的对比测试10 万条数据里查找一个元素list约需数毫秒set约需零点几微秒。这已经不是“快几倍”的差别而是数量级的差别。类似的问题还有频繁从列表里删除元素。list.remove()也是 O(n) 的因为删掉元素后要搬移后面的数据如果用set的discard()同样是 O(1)。但要注意set也不是万能的。如果你需要保持元素的插入顺序普通set就做不到了Python 3.7 的dict是有序的可以作为手动维护“有序去重集合”的方案。另外set内部是哈希表内存占用通常比同规模的list大几倍。所以查找和去重频繁的场景果断用set如果内存本身就紧张再权衡。2.3 字典默认值setdefault、defaultdict 与普通 if 的取舍字典的默认值处理是个高频场景。我有段时间写统计类代码就是从一堆数据里数每个类别出现的次数最常见的有三种写法# 写法1普通 if counts {} for item in data: if item in counts: counts[item] 1 else: counts[item] 1 # 写法2setdefault counts {} for item in data: counts.setdefault(item, 0) counts[item] 1 # 写法3defaultdict from collections import defaultdict counts defaultdict(int) for item in data: counts[item] 1用一百万条数据测试时间上写法 3 最快写法 2 稍慢写法 1 通常最慢。原因在于写法 1 每次循环要做两次字典查找一次in一次赋值写法 2 的setdefault虽然一次解决但它每次都会创建一个临时的0对象写法 3 把“默认值”的逻辑下沉到 C 层Python 层减到最少。不过在 Python 3.9 之后我反而推荐另一种写法——dict的合并操作符和Counter。如果是纯计数场景from collections import Counter counts Counter(data)Counter在底层用 C 循环批量处理比手写defaultdict循环通常还要快而且自带most_common()这种排序方法。能用标准库解决的事不要自己造轮子。3. 循环与算法从每行代码里抠出性能3.1 局部变量绑定别让名字查找拖慢循环这个是新手最容易忽略、老手也常常忘记的一个细节。Python 在函数内部访问局部变量走的是数组下标索引速度很快但访问全局变量走的是字典查找速度慢很多。虽然单次差异小但在循环里就会被放大。看个例子import math def compute_global(data): result [] for x in data: result.append(math.sin(x) ** 2 math.cos(x) ** 2) return result def compute_local(data): sin math.sin cos math.cos result [] append result.append for x in data: append(sin(x) ** 2 cos(x) ** 2) return result你可能会觉得这只是换个名字有啥区别我用一百万元素测试过compute_local通常比compute_global快 15% 到 30%。原因是math.sin每次循环都要走一次math模块的属性查找再走一次函数调用而绑定成sin之后sin是局部变量直接索引数组就拿到了函数对象。这里有个不熟悉的场景你可以试试如果在循环里反复用到len()、max()、sum()这类内建函数也可以先绑定成局部变量。内建函数走的是全局命名空间之外的__builtins__查找路径更长。但也要说句公道话不要为了这个优化把代码可读性完全牺牲掉。我一般只在大循环超过十万次迭代里做这种绑定小循环里没必要。3.2 列表推导式与生成器推导式不是万能的列表推导式向来是 Python 性能优化文章里的“明星”。确实下面两种写法# 写法1普通 for squares [] for i in range(1000000): squares.append(i * i) # 写法2列表推导式 squares [i * i for i in range(1000000)]列表推导式通常快 20% 到 40%因为它的底层循环在 C 层执行LIST_APPEND而不是在 Python 层反复调用list.append方法。所以“能用推导式就用推导式”这个建议大方向没错。但有一个反直觉的坑如果你的循环里有复杂的if-else分支推导式写出来可能又长又难读而且性能优势会被磨平。比如# 可读性灾难性能也没好到哪去 result [ process(x) if x % 2 0 else other_process(x) for x in data if condition_1(x) or condition_2(x) ]这种我建议直接写普通 for 循环配合函数内联反而更清晰。推导式的本质是“让解释器少做几件事”不是“把代码压缩成一行”。生成器则是另一回事。(i * i for i in range(n))不会一次性创建整个列表而是惰性求值。它的优势主要在内存不在速度——生成器通常比列表推导式慢一点因为每次next()都要恢复生成器状态、走一次 Python 层。所以如果你确定数据的规模不大、过滤后还能全部放进内存直接用列表推导式更快如果数据大到内存吃紧或者只需要前几个结果用生成器。3.3 减少属性访问和函数调用层级还有一类性能杀手不是单个慢操作而是“操作次数太多”的累积效应。假设你有个对象obj在循环里反复做obj.attr.method()。Python 碰到点号.的时候需要去对象的属性字典里查找名字然后做一次函数绑定。如果这一套在循环里执行百万次时间就很可观。解决思路有两种第一种是“提出来”把循环里不变的部分提到循环外。比如# 低效版本 for item in data: result config.parser.parse(item) # config 每次都要查找 # 高效版本 parse config.parser.parse for item in data: result parse(item)第二种是“换接口”如果你在循环里反复obj.method1()、obj.method2()可以考虑在对象内部加一个一次性完成多个操作的方法。这既是性能优化也是接口设计。但我也要提醒一个反向的坑不要为了减少函数调用而把代码搞成一坨巨型函数。函数调用本身虽然有一定开销但是现代 Python 解释器对函数调用的优化已经不错了而且模块化的代码更好维护、更好测试。我一般只对热点路径也就是性能分析后发现的高频调用链做这种“函数调用减层”的动作其他代码保持可读性第一。4. 并发并行选型多线程、多进程还是 asyncio4.1 GIL 的真面目它锁的是“同一时刻只能跑一个 Python 字节码”聊 Python 并发绕不开 GIL。网上关于 GIL 的讨论大都很极端——一种说法是“Python 多线程就是垃圾”另一种说法是“GIL 早就优化过没问题”。真实情况是GIL 锁的是 CPython 解释器级别保证同一时刻只有一个线程在执行 Python 字节码。所以纯 CPU 密集型的多线程代码在 Python 里不但不加速反而可能变慢因为线程切换本身有开销。但是 IO 密集型的场景网络请求、文件读写、数据库查询线程的大部分时间都在等待操作系统和硬件GIL 是释放的。这时候多线程是有效果的。我自己用requests同时抓取 50 个网页做过对比串行约 20 秒多线程约 2 到 3 秒。性能提升接近十倍靠的并不是“并行计算”而是“并行等待”。4.2 IO 密集场景多线程与 asyncio 的对比多线程在 Python 里有个绕不过的坑线程太多内存开销大、上下文切换频繁。所以 IO 密集的高并发场景我更倾向于asyncio。用aiohttp做同样的 50 个网页抓取代码略复杂一点但性能通常比多线程还要好因为它是单线程内的协程切换开销远小于线程切换。import asyncio import aiohttp async def fetch(session, url): async with session.get(url) as resp: return await resp.text() async def main(): urls [...] # 50个URL async with aiohttp.ClientSession() as session: tasks [fetch(session, url) for url in urls] results await asyncio.gather(*tasks) asyncio.run(main())需要提示的是asyncio的学习曲线比多线程陡一些而且它要求整个代码链路都是异步风格不然里面混入一个同步阻塞调用整个事件循环就会被卡住。4.3 CPU 密集场景多进程才是正路如果任务是 CPU 密集型的比如大量计算、大规模文本解析、图像处理多线程基本是负优化。这时候要用多进程利用真正的多核 CPU。标准库的concurrent.futures.ProcessPoolExecutor用起来很顺手from concurrent.futures import ProcessPoolExecutor def heavy_compute(x): # 假设这里有一段CPU密集型计算 return sum(i * i for i in range(x)) with ProcessPoolExecutor(max_workers8) as executor: results list(executor.map(heavy_compute, range(100)))我来说说多进程的代价每个进程都有自己的内存空间数据要传进传出得经过序列化/反序列化。所以如果单个任务太小数据拷贝的开销会盖过并行计算的好处。我一般建议任务粒度至少要有几十毫秒以上开始切分才有意义。另外有个常用的小技巧多进程里每个子进程都会重新导入你的模块所以被调用的函数最好定义在独立模块里不要写在if __name__ __main__:内部否则 Windows 环境下容易踩到递归启动进程的坑。5. 缓存与内存用空间换时间的正确姿势5.1 functools.lru_cache让重复递归“断崖式”提速有些函数的计算结果跟历史无关只跟入参有关而且会被反复用同样的参数调用。这种函数适合做“记忆化”。functools.lru_cache就是干这个的标准库神器。最经典的例子就是斐波那契数列from functools import lru_cache lru_cache(maxsize128) def fib(n): if n 2: return n return fib(n - 1) fib(n - 2)不加缓存时fib(40)会递归计算出上亿次函数调用慢到怀疑人生加了lru_cache每个n只计算一次fib(1000)都能瞬间出结果在递归深度允许范围内。实际项目里缓存的作用远不止递归函数。我经常在下面这些场景里用到数据库查询结果同一查询短时间内反复请求缓存起来直接省掉一轮 IO。配置解析结果配置文件解析一次后缓存其他模块直接拿结果。正则表达式对象re.compile(r...)的结果缓存起来避免重复编译标准库自己也有缓存但自定义场景还是要动手。maxsize的参数值得调一下它是缓存条目的上限。设成None表示不设上限无脑缓存。这有隐患如果入参组合非常多缓存会无限增长变成内存泄漏。我习惯在有真实数据规模感知的情况下先估摸一个上限比如 1024 或 4096不够再往上加。5.2 内存压力排查别让缓存变成内存泄漏说到“慢”它不一定真的只是 CPU 慢。当内存不够时操作系统开始用交换空间程序会瞬间慢得像死机——这种慢用cProfile分析是看不到热点的因为耗时的不是 Python 函数而是系统底层。排查内存问题我常用的工具是tracemalloc它是 Python 的另一个标准库。可以在代码里临时开启跟踪找出内存占用最大的前几行import tracemalloc tracemalloc.start() # ... 跑你的业务代码 ... snapshot tracemalloc.take_snapshot() top_stats snapshot.statistics(lineno) for stat in top_stats[:10]: print(stat)我查过一起“内存缓慢增长、一周后 OOM”的线上问题靠的就是tracemalloc发现某个缓存字典只存数据、不设置淘汰策略最后无脑涨到几个 GB。定位之后加了个简单的时间戳过期机制问题就解决了。内存优化不是越高深越好很多时候就是“把不需要常驻内存的东西及时丢掉”。5.3slots、array 与内存结构精简在需要创建大量非常简单的数据对象时Python 默认的class是很浪费内存的——每个实例都有一个__dict__字典来存属性。一万个实例就有一万个字典内存可想而知。如果这个类没有动态增加属性的需求可以加__slots__class Point: __slots__ (x, y) def __init__(self, x, y): self.x x self.y y这样每个实例不再拥有独立的__dict__内存能省下可观的一部分。在百万级对象场景下这个优化效果非常明显。代价是类会失去动态绑属性的灵活性所以别用在需要obj.new_attr value这种动态扩展的场景。另一个容易被忽略的标准库容器是array。如果你要存一百万个整数list会存一百万个 Python 整数对象每个对象几十字节而array(i, ...)底层是连续的原生 C 数组每个整数 4 字节内存差一个数量级。当数据量巨大且类型统一时array是很好的选择。6. 真实场景实践不同场景的优化侧重点6.1 数据分析与爬虫场景把循环换成向量化操作我身边写数据分析的朋友最常见的问题是拿着一百多万行数据在pandas里写for循环逐行处理。# 低效的做法 for i in range(len(df)): df.loc[i, new_col] df.loc[i, col_a] * df.loc[i, col_b]这种写法在 pandas 里属于典型的死亡操作。pandas的底层是numpy是 C 语言实现的向量化运算性能比 Python 层循环快几十倍。正确做法是利用向量化df[new_col] df[col_a] * df[col_b]如果逻辑复杂到不能用现成函数实现至少用df.apply()配合自定义函数也比for循环快不少。但要注意apply内部其实也是 Python 循环所以如果性能瓶颈严重最后还是得用numpy的向量化逻辑重写。爬虫场景则不一样。爬虫的性能瓶颈几乎永远在网络 IO不在解析。所以爬虫优化的重点顺序是用好连接池requests.Session()或者httpx.Client()、合理设置并发、复用解析器不要每次都重新bs4.BeautifulSoup而是先compile好解析规则。先把这三个做了比什么算法优化都管用。6.2 量化策略回测的优化思路向量化与并行结合热词里挂着“python量化交易策略代码”我就说一个这个领域里很常见的优化思路。写量化回测的时候新手喜欢逐日循环一天一天地判断买卖信号# 这就是最常见的性能地狱 for i in range(1, len(prices)): if prices[i] ma[i] and prices[i-1] ma[i-1]: buy_signal[i] True这种写法在 10 年日线数据大概 2000 多个 K 线下还好但如果你把它放到 5000 只股票的分钟级别数据上几十万甚至上百万次 Python 层循环性能会非常难看。更好的方案是先用numpy/pandas把信号一次性算出来再做向量化的状态转移import numpy as np # 用 numpy 实现“上穿”信号前一天在均线下今天突破到均线上 cross_above (prices ma) (np.roll(prices, 1) np.roll(ma, 1))这样整个信号计算在 C 层完成速度提升几百倍毫不夸张。如果单只股票的信号计算本身没法完全向量化那就把任务拆到多进程里并行跑ProcessPoolExecutor是首选。回测天然适合并行每只股票/每个策略参数组合是独立的数据也不共享正好避开 GIL 的约束。6.3 优化之后必须回归测试性能提升不等于正确性提升最后这条是用来看住前面所有优化措施的一条红线。我见过不少优化翻车的案例数据量一大新代码确实快了但输出的结果和旧代码对不上——可能是set去重后顺序乱了可能是缓存命中了过期数据可能是多进程切分任务时边界条件没处理好。所以我的习惯是任何一次性能优化都要求先固化一份输入输出基线再做改动。具体做法是优化前用一组有代表性的数据跑一遍把输出结果序列化存档或者算一个 hash。优化后跑同一组数据对结果做逐项对比。有差异先别急着上生产逐条排查是“优化改坏了”还是“旧代码本来就是错的”后者我也遇到过结果新代码反而修了个隐藏 bug。这一步不花多少时间但它能让你安心地把优化成果落地而不是晚上十点收到线上告警。我在自己的项目里一直坚持一个习惯每次性能优化留下的不只是一段更快的代码还有一份能说清楚“为什么要这么改”的记录。这份记录在三个月后大概率会救你一命——因为那时候你可能已经忘了当时为什么要把list换成set而下一个接手的人正对着你的set打转。如果你看完这篇文章只记住一件事我希望是优化不是炫技是定位瓶颈之后用最合适的工具拆掉它。先用cProfile找到热点再决定是换数据结构、调并发模型、还是加缓存。顺序反了技巧再多也容易白忙一场。