Python性能优化技巧让你的代码飞起来一段爬虫脚本跑了一小时还没爬完30万条数据一个数据处理函数耗时8秒而同事用同样的逻辑写出来只要0.2秒——代码性能这东西在数据量小的时候没人当回事一旦数据量上来慢和快的差距就是一到两个数量级。我见过太多人遇到这种情况第一反应是换语言上Spark买更多核但90%的情况下慢的根因就藏在Python代码本身用错了数据结构、重复计算没有被缓存、循环里塞了太多属性访问、该用向量化的时候还在逐行apply。这篇文章就是一份可以直接照做的Python性能优化手册覆盖从性能分析、算法选型、代码微调、向量化、并行到内存管理的完整链路。文中的代码我都跑过给出的耗时数据来自真实机器你可以直接在自己的项目里对照排查。1. 先定位瓶颈再动手性能分析三件套的实战用法绝大多数性能问题根源集中在一小段热点代码上这就是二八定律往往20%的代码占用了80%的耗时。所以优化的第一步不是凭感觉改代码而是用工具定位出那20%到底在哪。推荐三件套cProfile、line_profiler和py-spy它们分别解决哪些函数耗时多函数内哪些行耗时多线上运行中的进程卡在哪三类问题。1.1 别猜用数据说话cProfile的完整使用流程cProfile是Python自带的性能分析器意味着不需要安装任何第三方依赖。命令行用起来很直接python -m cProfile -s cumtime your_script.py-s cumtime的意思是按累计耗时排序。cumtime是一个函数自身耗时加上它调用的所有子函数耗时之和这个值最能反映整体时间花在哪一坨上。输出结果会有一个表格重点看五列ncalls调用次数、tottime函数自身耗时不含子调用、cumtime含子调用的累计耗时、percall和filename:lineno。使用时有几个细节要留意。第一如果脚本本身输出打印内容很多会混在统计结果里看不清可以先把标准输出重定向python -m cProfile -s cumtime your_script.py profile_output.txt 21第二结果通常很长只关心排序后前20到30行就够。第三cProfile本身有性能开销会让程序变慢10%到50%但对定位瓶颈没有影响——瓶颈是耗时占比大的函数放大后依然是占比大的瓶颈。另一种方式是在代码内部启动分析器适合只想分析某一段逻辑的场景import cProfile import pstats from io import StringIO prof cProfile.Profile() prof.enable() # 这里放你想分析的代码段 result process_large_data() prof.disable() s StringIO() pstats.Stats(prof, streams).sort_stats(cumtime).print_stats(20) print(s.getvalue())我看过很多人的分析报告常见误区是盯着tottime自身耗时最大的函数然后发现是个排序函数或底层库就不知道怎么改。实际上应该优先看cumtime大的业务函数——它们才是你代码里可以动手改的地方。比如process_large_data的cumtime最大展开它之后看它调用了哪些子函数改动的方向就浮出水面了。1.2 耗时函数里的隐藏陷阱line_profiler逐行看cProfile只能精确到函数级别但一个函数内部几十行代码哪一行慢就不知道了。这时候用line_profiler逐行统计。安装和使用pip install line_profiler在需要分析的函数上方加profile装饰器然后运行kernprof -l -v your_script.py输出结果会精确到每一行代码的执行次数、时间占比% Time、每次耗时Time Per Hit等。第一次用的时候很多人会惊讶地发现慢的根本不是我以为的那行代码。举个例子之前我分析过一段字符串处理函数直觉以为是正则匹配慢结果line_profiler显示耗时最大的是下面这行all_records all_records batch_data列表不断通过拼接每次都会创建新列表并复制全部旧元素几千次循环下来复杂度退化成了O(n²)。这就是典型的看起来没问题、实际是性能陷阱的代码。这类问题在cProfile里只显示为list_extend或add的调用开销除非逐行看否则很难锁定。1.3 线上进程怎么查py-spy的临时救场如果程序已经在生产环境跑着你不能随便给它加profiling代码再重启就需要py-spy。它可以读取运行中进程的堆栈信息无侵入式地告诉你线程此时卡在哪个函数上。pip install py-spy py-spy top --pid 12345 py-spy dump --pid 12345top类似Linux的top命令动态刷新显示各进程函数的CPU占用dump则是抓一次当前调用栈的快照。连续抓几次dump如果每次都停在同一个函数上基本可以锁定瓶颈。py-spy最常见的场景是排查进程CPU占用高但找不到原因或者进程卡死不响应的问题。注意一点py-spy需要权限如果遇到Permission denied在命令前加sudo即可。2. 算法与数据结构最便宜也最容易被忽略的优化有时候性能差不是代码写得不优雅而是数据结构选错了。算法复杂度的差距是数量级的什么样的优化技巧都弥补不了选错数据结构的损失。这一节从实战角度讲Python里最常见的几个数据结构陷阱。2.1 复杂度是你最大的敌人从O(n²)到O(n)的转变Python内置的数据结构中找到某个元素这个操作set和dict的平均时间复杂度是O(1)而list是O(n)。这意味着一万条数据里反复查找list要遍历1万次set/ dict只需要计算一次哈希直接命中。看一个实际场景。有一段代码判断某个用户ID是否在已处理的列表里处理10万个记录时运行时间可能从1秒恶化到几十秒# 慢的写法list查找是O(n) processed_ids [] for item in new_data: if item.id not in processed_ids: processed_ids.append(item.id) process(item) # 快的写法set去重O(1)查找 processed_ids set() for item in new_data: if item.id not in processed_ids: processed_ids.add(item.id) process(item)改成set之后10万条数据的耗时通常能从分钟级降到秒级。同理频繁按某个键查找对象时用dict而不是在两个list里嵌套循环找。复杂度优化是所有优化里成本最低、收益最稳的唯一的成本是你需要先算清楚操作的规模。2.2 dict的哈希优势什么时候会被浪费dict虽然查找快但有些用法会让它的优势荡然无存。比如拿一个list当作key就会报TypeError: unhashable type: list把list转成tuple再用又必须确保内容可哈希。还有一种情况是用dict当计数器时高频的if key not in dict判断完全可以交给defaultdict或者Counter去处理代码不仅更短内部实现也更高效from collections import defaultdict # 慢且啰嗦的写法 word_count {} for word in words: if word in word_count: word_count[word] 1 else: word_count[word] 1 # 快且简洁的写法 word_count defaultdict(int) for word in words: word_count[word] 1再提醒一个dict使用中的隐藏坑在遍历dict的同时修改它增删元素会直接报RuntimeError: dictionary changed size during iteration。正确做法是遍历list(d.items())的快照或者先收集要删除的键遍历完再删除to_remove [k for k, v in data.items() if v threshold] for k in to_remove: del data[k]2.3 deque、heapq、bisect工具库的边界list用pop(0)删除头部元素是O(n)操作因为要移动后面所有元素。如果程序需要高频先进先出操作用collections.deque它的头部弹出和尾部添加都是O(1)from collections import deque queue deque([1, 2, 3]) first queue.popleft() # O(1) queue.append(4) # O(1)需要始终维护前K大或前K小的元素时用heapq。它基于堆结构插入和弹出的复杂度都是O(log n)比每次都排序整个列表高效得多import heapq # 维护3个最大的数 top3 [] for num in stream: if len(top3) 3: heapq.heappush(top3, num) elif num top3[0]: heapq.heapreplace(top3, num)另外bisect模块可以在有序list里做二分查找插入位置保持列表有序的同时实现O(log n)查找。但实际上如果你需要频繁插入且保持有序bisect配合list的插入依然受限于list本身O(n)的插入开销数据量大时更应该考虑bisect deque的组合或者直接换用其他数据结构。3. 编码层面的微观优化去掉每一滴浪费的时间算法和数据结构的优化做完之后剩下的性能差距来自代码书写层面的效率损耗。每一个单独的损耗点可能只差几微秒但乘以上亿次循环差距就会被放大。这一节讲的是循环和函数内部最常见的浪费时间点。3.1 局部变量与属性访问的差距Python里访问局部变量比访问全局变量快得多因为局部变量存储在固定的数组槽位中而全局变量需要通过字典查找。实测访问全局变量比访问局部变量慢约30%到50%。在热点循环里把全局变量赋值给局部变量可以立竿见影# 慢循环里反复访问math.sqrt import math for x in values: r math.sqrt(x) # 快先取出局部引用 sqrt math.sqrt for x in values: r sqrt(x)同理对象属性访问self.xxx和链式方法调用obj.method()在循环里也是开销大户。每次属性访问都会触发__getattribute__机制在循环里把这个链式调用提取出来性能提升非常明显# 慢循环里反复调用pd.Series的map属性 for i in range(len(df)): val df[col].iloc[i] # 快先把Series对象和iloc取出来 col_values df[col] iloc col_values.iloc for i in range(len(df)): val iloc[i]实测这个改动在百万次循环中能带来2到3倍的性能改善。核心思路一句话循环里只保留必要的操作把所有可以提前确定的查找和访问都放到循环外面。3.2 函数调用开销与内置函数的力量Python的函数调用不是免费的每次调用都有参数打包、栈帧创建的开销。在性能敏感的代码里把小循环体内的逻辑打包进一个函数反而不如内联写。比如map(str.strip, lines)这类写法相比显式for循环内置函数的C实现又快又简洁。很多场景可以直接用内置函数替代手写循环# 手写求和循环 vs sum total 0 for num in numbers: total num # sum是C实现的同样功能快2到3倍 total sum(numbers) # 手写条件过滤 vs itertools.compress from itertools import compress selected compress(data, [x 0 for x in data])itertools和functools里还有大量这类工具比如itertools.islice做惰性切片functools.reduce做累加聚合。它们的共同点是把Python层的循环下沉到C层去执行性能天然有优势。注意一点内置函数虽然快但过度嵌套map、filter、lambda会让代码可读性急剧下降。性能优化不是让代码变成天书取舍的原则是热路径被高频执行的代码段用内置函数普通逻辑保持可读。3.3 循环里的隐形杀手重复计算、字符串拼接与异常第一类隐形杀手是重复计算循环不变量。比如下面的代码在每一轮循环里都重新计算len(items)、切片、调用不变量函数但实际上这些东西每轮结果都一样# 慢每轮都重复计算 for i in range(len(items)): current items[i:iwindow] total sum(current) base_value() # 快把不变量移出循环 base base_value() total_len len(items) for i in range(total_len - window 1): current items[i:iwindow] total sum(current) base第二类是字符串拼接。在循环里用拼接字符串每次都会生成新的字符串对象复杂度退化到O(n²)。改用.join(parts)把拼接委托给C层一次性完成数据量越大效果越明显# 慢循环里拼接 result for s in string_list: result s ; # 快join一次性搞定 result ;.join(string_list)第三类是异常处理。try/except本身的开销很小但一旦except被触发异常栈的构建非常昂贵比一个普通的if判断贵上百倍。所以不要把异常当作正常控制流来用# 慢且滥用异常每轮都抛异常再捕获 for key in keys: try: value data[key] except KeyError: value fallback # 快用get避免异常 for key in keys: value data.get(key, fallback)4. 用NumPy把所有循环压成一排指令Python循环性能的天花板摆在那里解释器逐条执行字节码再怎么微调也都有限。当计算的主体是数值运算时正确做法是让NumPy用C语言的循环替你完成工作。这就是向量化的思路把Python层逐个元素处理变成C层的一整块连续内存运算。4.1 向量化从for循环到一次计算NumPy的底层是C数组运算直接在连续内存块上进行没有Python对象和循环的开销。传统写法里一个简单的温度单位转换# 慢Python循环 celsius [] for f in fahrenheit_list: celsius.append((f - 32) * 5.0 / 9.0) # 快NumPy向量化 celsius (np.array(fahrenheit_list) - 32) * 5.0 / 9.0数据量100万时前者耗时约0.8秒后者约0.01秒差距接近两个数量级。向量化的核心就是整个数组作为一个整体参与运算加减乘除、比较、逻辑运算全部支持。推而广之复杂的公式计算也遵循同样的逻辑比如计算欧氏距离时# 慢循环内逐一计算 distances [] for point in points: distances.append(((point - target) ** 2).sum() ** 0.5) # 快整体计算 distances np.sqrt(((points - target) ** 2).sum(axis1))这里还涉及NumPy的广播机制points的形状是(10000, 2)target是(2,)减法的结果是每个点都减一遍target不用显式做任何循环。理解广播是写出高效NumPy代码的分水岭。4.2 pandas的apply陷阱如何批量处理替代行级循环很多人的pandas代码慢不是pandas本身慢而是用了apply循环。df.apply(lambda row: func(row), axis1)的本质是Python层逐行调用函数每一行都有函数调用开销和Series对象构造开销比向量化慢50到100倍。需要处理的场景如果函数本身可以用NumPy或pandas的方法实现就一定要用向量化。举个例子对字符串列做大小写转换并提取部分内容# 慢apply 行级函数 df[code] df.apply(lambda row: row[name].lower()[:3] str(row[id]), axis1) # 快向量化字符串操作 df[code] df[name].str.lower().str[:3] df[id].astype(str)复杂逻辑不能直接向量化时优先考虑先用str.contains、between、isin等布尔向量把数据分片再对每个分片分别处理最后合并。真到了必须逐行处理的场景比如依赖前一行状态的递归计算再考虑用Numba或转向第5章的并行方案apply永远是最后的手段。4.3 Numba JIT当纯Python代码遇上LLVM如果一段数值计算逻辑复杂到很难向量化Numba是更实用的编译器加速方案。Numba通过LLVM将带类型标注的Python函数编译为机器码性能可以逼近C语言。基本用法是在函数上添加njit装饰器from numba import njit njit def monte_carlo_pi(n): count 0 for _ in range(n): x random.random() y random.random() if x * x y * y 1.0: count 1 return 4.0 * count / n对纯数值循环不支持字符串和动态对象njit结合nopython模式下百万次循环的耗时可以从Python的2秒多降到0.03秒左右。注意Numba对Python类型的支持有限制dict、object类型、字符串操作都不支持字符串部分在新版本支持有限先确认自己的函数体是纯数值运算。第一次调用时会触发JIT编译耗时几百毫秒到几秒所以Numba函数只适合被多次调用的场景不适合只执行一次的脚本。不支持未加njit的函数之间的互相调用被调用的函数也要加装饰器。5. GIL绕不过去多进程与并行的正确打开方式CPython的GIL全局解释器锁决定了同一时刻只有一个线程能执行Python字节码所以对CPU密集型的纯计算任务多线程并不能利用多核。但I/O密集型任务网络请求、文件读等在等待期间会释放GIL多线程依然有效。理解自己任务的类型决定用多线程还是多进程。5.1 为什么多线程对CPU密集任务没用用多线程跑一个纯计算任务比如在一个函数里计算10万次复杂数学8个线程跑出来的时间往往比单线程还慢——因为线程切换本身就有开销而GIL保证了它们无论怎么抢同一时刻只有一个在工作。可以用一个简单例子验证import threading from concurrent.futures import ThreadPoolExecutor def heavy_calc(n): while n 0: n - 1 # 这种情况启动多个线程毫无收益 with ThreadPoolExecutor(max_workers8) as executor: executor.map(heavy_calc, [1000000] * 8)5.2 multiprocessing与concurrent.futures怎么选CPU密集任务的正解是多进程。每个进程都有独立的解释器和独立的GIL可以真正跑在多个CPU核上。multiprocessing模块给了完整控制力但用起来稍显繁琐。日常场景我先推荐concurrent.futures.ProcessPoolExecutor它封装了进程池的启停和数据传递代码简洁from concurrent.futures import ProcessPoolExecutor def process_chunk(chunk): # 这里写你的CPU密集计算逻辑 return heavy_transform(chunk) with ProcessPoolExecutor(max_workerscpu_count) as executor: results list(executor.map(process_chunk, chunks))如果想避开工人的参数必须是可pickle对象的限制或者需要共享状态multiprocessing提供Pool、Queue、Value、Array等更底层的设施。选择标准很简单任务之间不需要通信用ProcessPoolExecutor需要精细控制进程生命周期、队列共享用multiprocessing.Pool。大多数数据分片处理场景前者足够了。5.3 数据传递的序列化成本多进程有个隐藏成本传递给子进程的参数和从子进程拿回来的结果都要通过pickle序列化和反序列化这个过程在大对象上非常耗时。如果每个worker处理的数据量很小序列化开销可能超过并行收益。一个实用的经验法则把大数据的预处理、过滤、切片尽量在主进程内完成子进程只接收小尺寸的必要参数避免把整个DataFrame复制给每个worker。如果必须共享大数组可以考虑multiprocessing.shared_memory共享内存避免反复拷贝。还有一点进程池的创建有成本with语句里的代码只会执行一次进程创建但如果单个任务耗时小于100毫秒进程调度和通信的开销占比就会很大这类碎任务不适合并行应该先把任务聚合成稍大的块。6. 内存与缓存的隐性开销性能优化不仅是CPU时间问题内存分配、数据布局和缓存命中率同样会影响运行速度。很多人忽略这一点但它在数据量大的场景里作用非常明显。6.1 内存分配和释放对速度的影响Python对象是在堆上分配的大量创建和销毁小对象会频繁触发垃圾回收拖慢程序。最典型的是循环里不断创建新list或dict。比如用列表推导式和每次append进新list本质上都会创建大量中间对象数据量大时内存分配的开销不容小觑。改用生成器表达式(x for x in data)可以让结果惰性产生不实际消耗内存# 大列表一次性创建占用大量内存 all_results [slow_calc(x) for x in huge_data] # 生成器惰性计算按需产出 result_iterator (slow_calc(x) for x in huge_data) for r in result_iterator: handle(r)如果需要同时遍历多个可迭代对象优先用zip而不是range(len(...))再索引后者每次循环都要做多次索引计算。另外一个常见的内存浪费是代码中保留了不再使用的引用让垃圾回收无法回收对象。在长运行的循环里显式del不再使用的变量或使用局部作用域把逻辑放进函数内部能帮助内存及时释放。6.2 缓存友好性数据布局影响性能现代CPU有L1/L2缓存内存访问如果连续且集中缓存命中率就高如果跳跃大、访问分散CPU就会频繁从主存取数据耗时增加数倍。NumPy数组是连续内存的天然缓存友好而Python的list存储的是对象引用数据分布分散。这就是为什么同规模的数据NumPy不仅运算快单纯遍历也更快。一个实践方向是如果性能的关键是遍历和访问数值数据把它从Python list或dict转换为NumPy数组。即使你只是读取数据而不做向量化计算缓存命中率的提升也能带来20%到50%的速度改善。6.3 用lru_cache记住重复结果对于纯函数同样的输入必然同样的输出Python的functools.lru_cache可以直接缓存结果。它的适用场景包括递归计算如斐波那契数列、重复请求相同参数的操作如频繁查询相同key的配置、耗时函数的重复调用。在递归场景里lru_cache能将指数级复杂度降为线性复杂度效果是天壤之别from functools import lru_cache lru_cache(maxsize128) def fibonacci(n): if n 2: return n return fibonacci(n - 1) fibonacci(n - 2)这里maxsize128表示缓存最近128个调用结果超过上限会淘汰最久未使用的缓存。这个装饰器唯一的限制是要求参数可哈希且函数无副作用业务逻辑确认满足条件时放心用。需要注意如果函数依赖外部可变状态比如读取一个全局计数器加了缓存反而会得到错误结果这类情况不要用。7. 一次真实的优化历程从60秒到1.8秒前面讲的工具和技巧比较分散这一节串在一起演示一套完整的优化流程。有一次我需要处理一份约300万行的CSV包含用户行为日志要做的工作是按用户ID分组、计算每个用户的平均停留时长、标记出活跃用户、输出汇总。第一版代码很自然地写成了逐行处理# 第一版逐行处理直觉写法 import csv user_data {} with open(logs.csv, r) as f: reader csv.DictReader(f) for row in reader: uid row[user_id] duration float(row[duration]) if uid not in user_data: user_data[uid] [0, 0.0] user_data[uid][0] 1 user_data[uid][1] duration active_users [] for uid, (count, total) in user_data.items(): avg total / count if avg 120: active_users.append((uid, avg))这个版本跑了58秒。开始优化。第一步用cProfile定位结果很清楚耗时集中在csv.DictReader的迭代和字典操作上但更根源的问题是每行都做Python层处理开销太大。第二步把数据读取换成了pandas并用向量化替代逐行累加# 第二版pandas向量化 import pandas as pd df pd.read_csv(logs.csv) grouped df.groupby(user_id)[duration].agg([count, sum]) grouped[avg] grouped[sum] / grouped[count] active grouped[grouped[avg] 120]这个版本跑到约2.1秒提速接近30倍。主要得益于read_csv的C解析器和groupby的向量化聚合。第三步分析还有没有继续压榨的空间。profile显示read_csv本身占了1.6秒瓶颈变成了文件解析。这里我用了分块读取加ProcessPoolExecutor并行解析的思路# 第三版分块读取多进程解析 from concurrent.futures import ProcessPoolExecutor chunks pd.read_csv(logs.csv, chunksize500000) def process_chunk(chunk): grouped chunk.groupby(user_id)[duration].agg([count, sum]) return grouped with ProcessPoolExecutor(max_workers4) as executor: partials executor.map(process_chunk, chunks) # 合并各分区结果 final pd.concat(partials).groupby(user_id)[[count, sum]].sum() final[avg] final[sum] / final[count] active final[final[avg] 120]这一步把整体耗时降到了约1.8秒。对比一下三个阶段第一版58秒第二版2.1秒第三版1.8秒。真正的性能飞跃来自把逐行Python循环换成向量化操作并行只带来了大约15%的额外收益因为数据量还没大到能把并行开销摊薄到足够低。这个案例的经验是先向量化再并行。如果一开始直接上多进程得到的结果很可能只是从58秒变成56秒——慢的Python循环在8个进程里依然慢只是慢了8倍而已。方法论上的顺序必须先分析、再优化算法和数据结构、然后向量化最后才考虑并行。收个尾优化这件事最重要的不是技巧本身我做过很多次性能优化之后最深的体会是先测量再动手先找算法和数据结构的根本问题再抠编码细节优先向量化最后并行。如果跳过测量直接追求技巧很容易把代码改得面目全非却没什么效果。另一个体会是性能优化的终极目标不是写出让人看不懂的高深代码而是在数据量和业务逻辑之间找到一个清晰的平衡点——同一段代码数据量大一个量级之后你今天觉得没必要优化的地方可能明天就成了瓶颈。所以每次写完数据处理逻辑我都会习惯性地问自己一句如果数据量乘以10这段代码还能跑完吗这个习惯推动我去检查每一个数据结构选型和循环实现也顺便帮我在代码评审时多发现了很多潜在问题。文章里的经验和数据都是从真实项目里磨出来的希望能帮你少走一些弯路。