资讯动态

Python性能优化实战:从定位瓶颈到加速代码的完整指南

发布时间:2026/9/9 21:01:02 来源:尧图企业网站定制
Python 跑得慢几乎是每个写 Python 的人迟早要面对的问题。平时写脚本、做数据分析、跑接口服务日常开发体感还好可一旦数据量上来或者业务逻辑复杂了一个函数跑几十秒甚至几分钟的情况一点也不稀奇。很多人第一反应是“换语言”“上 C”但我的经验是绝大多数性能问题根本轮不到换语言先做几轮正经的 Python 性能优化效果往往就能让代码飞起来。这篇文章我打算按自己多年来实际做性能优化的顺序来写先讲怎么用工具定位瓶颈再讲数据结构、写法、缓存、并发、内存这些层面的优化手段最后给出一个从 1.8 秒优化到 0.15 秒的完整案例。适合正在做 Python 后端服务、数据处理脚本、算法原型落地的朋友参考。文章里所有的优化点我都实测过也踩过不少坑都会直接写出来。1. 性能优化前的第一件事先度量别瞎猜很多同学一开始就陷入“背诵优化技巧”的误区觉得“列表推导式更快”“多线程更快”于是上来就大面积改代码。结果往往是花了半天时间整体性能一点没变反而把代码改得很难读。我自己的经验是所有性能优化都必须从度量开始先搞清楚瓶颈真的在哪儿再动手改。凭直觉猜瓶颈十个里有九个是猜错的。1.1 用 cProfile 快速定位瓶颈Python 自带的 cProfile 是性能分析的第一选择零依赖、开箱即用。用法非常简单python -m cProfile -s cumulative your_script.py-s cumulative表示按累计耗时排序这样就能一眼看到哪个函数自己跑的时间最长哪个函数连带子函数总共耗时最多。输出里每一行都有tottime函数自身运行时间不含子调用和cumtime包含子调用的累计时间这两个指标要分开看。举个例子我曾经接手过一个数据清洗脚本整体跑一次要 40 多秒。cProfile 跑完发现耗时最高的一个函数是自定义的日期解析函数tottime占了 60%而不是我一开始以为的“某个大循环”或者“正则匹配”。后来只是把日期解析换成了datetime.fromisoformat速度立刻提了一倍。所以不要猜先跑 profile让数据告诉你答案。提示如果脚本运行时间很短比如几百毫秒cProfile 的统计开销可能会影响结果。可以用profile.Profile()手动调enable()/disable()只统计关键片段。1.2 更细粒度的 line_profiler 与 py-spy 实战cProfile 能定位到函数级别但如果一个函数里有很多行代码你还需要知道具体是哪一行最慢。这时候用 line_profiler 它能精确到每一行代码的执行耗时。pip install line_profiler然后在需要分析的目标函数上加上profile装饰器执行kernprof -l -v your_script.py-l表示按行剖析-v表示立即输出结果。输出会显示每一行代码的 hits执行次数、time总耗时、per hit单次耗时一眼就能找出热点行。这个工具在优化“大循环体内某几行运算”时特别好用。另一个工具是 py-spy它的强项是线上排查。进程已经跑起来了没法重启加 profile就可以用 py-spy 直接 attach 到进程上采样pip install py-spy py-spy record --pid 12345 -o profile.svg --duration 30 py-spy top --pid 12345py-spy 不用在代码里插入任何东西不暂停进程也能采样用来排查线上服务卡顿、展示调用栈非常方便。我修过不少线上问题就是靠 py-spy 抓现场而不是去线上改代码加日志。1.3 度量工具怎么选一个速查表场景推荐工具说明本地脚本整体性能cProfile内置工具快速定位瓶颈函数函数内逐行定位line_profiler精确到行级别适合优化大循环线上服务采样py-spy无需改代码可 attach 到运行进程内存占用分析tracemalloc / memory_profiler定位内存泄漏与峰值源头耗时微基准测试timeit只测单段代码的耗时差距精度高2. 代码层面的性能加分项数据结构与写法定位到瓶颈以后接下来就是具体的代码层面优化。这一部分看起来基础但往往改动最小、收益最直接。很多时候不是 Python 本身慢而是我们写出了复杂度不合理的代码。2.1 选对数据结构排序和查找差一个量级最典型的例子是列表和字典的查找性能。Python 的 list 是动态数组查找一个元素要逐个比较时间复杂度是 O(n)而 dict 的键查找基于哈希表时间复杂度是 O(1)。数据量小的时候感觉不出来数据量上万甚至几十万时差别就是毫秒和秒的天壤之别。我之前优化过一个订单匹配模块其中有一段代码是在一个十几万元素的列表里反复查找某个订单号是否存在完整的逻辑跑下来要 20 多秒。把订单列表转成 dict或者直接建 set查找逻辑不用变整体时间直接降到 2 秒以内。这就是典型的数据结构选型问题不是 Python 的锅是写法的锅。带去重需求时同理判断某个元素是否出现过不要用 list用 set。set 和 dict 一样是基于哈希的去重一个十万量级的列表list 的if item not in unique_list写法会慢到让人怀疑人生换 set 就是一瞬间的事。2.2 循环、属性查找与字符串拼接的细节Python 的代码写法在性能上也很有讲究。最基础的三条第一循环体内尽量避免全局变量查找。Python 查找局部变量用的是LOAD_FAST按索引直接取开销很小查找全局变量要用LOAD_GLOBAL走字典查找开销大好几倍。如果一个变量在循环里会被反复读取建议先在循环外绑定成局部变量。比如import math def compute(values): # math.floor 是全局属性查找每轮循环都要查 return [math.floor(v * 1.5) for v in values] def compute_fast(values): # 先把方法绑定到局部变量循环内查找开销大幅下降 floor math.floor return [floor(v * 1.5) for v in values]这段代码实际跑起来compute_fast大约快 10% 到 20%。虽然单个函数的提升不大但如果这个函数是整个服务的耗时热点收益就很可观了。第二列表推导式通常比forappend快。原因是推导式在底层做了专门的优化不需要反复调用列表的append方法。比如# 慢写法 result [] for i in range(10000): result.append(i * 2) # 快写法 result [i * 2 for i in range(10000)]第三大量字符串拼接时用join不要用。字符串是不可变对象每次都会创建一个新字符串如果拼接几千次必然带来大量内存分配。最典型的优化例子是把若干片段拼接成一个大字符串# 慢写法 s for piece in pieces: s piece # 快写法 s .join(pieces)注意只有拼接片段很多时才明显。如果只是拼三五段直接用s a b c就好可读性更好性能也足够好刻意用 join 反而画蛇添足。2.3 少做重复计算把公因子提到循环外面这个技巧听上去太简单了但实际代码里大量存在“重复计算同一个结果”的情况。比如在循环里反复调用len(x)、.keys()、常量运算等这些值在循环过程中根本没变过却每轮都重新算一遍。Python 不会自动帮你做循环不变量的代码提升所以只能手动提出来。再比如正则表达式。循环内部反复re.match(pattern, line)每次都要重新编译 pattern开销极大。正确做法是把pattern re.compile(...)移到循环外然后调用pattern.match(line)。我实测过一个日志解析脚本仅仅把正则编译提出来整体时间就少了 30 多秒。3. 用缓存和算法思路让计算量真正降下来如果代码写法已经没什么可优化了下一步就要从“计算量”本身做文章。这一层优化的收益往往比微观写法高得多因为它是从复杂度层面解决问题。3.1 functools.lru_cache把重复计算的结果记住很多程序的瓶颈在于“同一入参被反复计算”。举个常见的递归例子斐波那契数列的朴素递归复杂度是 O(2^n)n35 就慢得让人抓狂。但如果加上lru_cache复杂度直接降到 O(n)from functools import lru_cache lru_cache(maxsizeNone) def fib(n): if n 2: return n return fib(n - 1) fib(n - 2)实际业务中也一样。比如接口服务里经常要根据用户 ID、商品 ID 计算“实时价格”如果同一个请求里多次调用同一个计算函数就可以用 lru_cache 记住结果。maxsize可以控制缓存多少条防止内存无限增长。lru_cache 还能直接用于 IO 密集的函数比如多次读取同一个配置文件、查同一个接口的返回值。但要注意如果函数返回值会变化比如“当前时间”“数据库最新状态”千万别加缓存否则会出现脏数据。3.2 从 O(n^2) 到 O(n)一个现实改造案例算法优化不是考试的专属实际开发中也很重要。我优化过一个重复数据检测模块最初逻辑是两层循环每两个样本都做一次相似度计算样本量 5000 时大概要算 1250 万次整个流程要跑好几分钟。后来我分析发现真正需要两两对比的只有同一用户下的样本于是先按用户 ID 分组组内再两两计算。总计算量从几千万次降到几十万次而且因为分组可以用 dict 轻松实现代码还更清晰了。这类优化的核心思路是先看计算结构能不能降复杂度。常见的套路有用 set / dict 替代线性查找把 O(n) 降到 O(1)先排序再利用有序性跳过无效比较按关键维度做分组预处理避免大范围两两扫描能用数学公式化解的就别用循环累加4. 多线程、多进程与异步 IOGIL 下的并发选择很多初学者接触性能优化第一个想到的就是并发。但在 Python 里并发选择比别的语言复杂得多因为有个绕不开的 GIL全局解释器锁。不搞懂 GIL 就去写多线程大概率会踩坑。4.1 什么时候用多线程什么时候用多进程GIL 的意思是同一时刻一个 Python 进程里只允许一个线程执行 Python 字节码。所以纯计算密集任务用多线程不仅不能加速反而因为线程切换造成额外开销这也是很多人说“Python 多线程是假的”的原因。但对 IO 密集型任务比如网络请求、文件读写、数据库查询多线程依然很有用。因为线程在等待 IO 时GIL 会被释放其他线程可以继续执行。所以CPU 密集型任务用multiprocessing多进程每个进程有自己的 GIL可以充分利用多核IO 密集型任务用concurrent.futures.ThreadPoolExecutor或asyncio我写爬虫和脚本时常用concurrent.futures代码非常简洁from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_one(url): # 模拟网络请求 return fresult: {url} with ThreadPoolExecutor(max_workers8) as executor: futures [executor.submit(fetch_one, url) for url in urls] results [f.result() for f in as_completed(futures)]注意线程池的线程数不是越大越好。过多线程会导致上下文切换开销变大还容易触发 GIL 争用。对网络请求场景8 到 16 个线程通常比 64 个更稳。4.2 asyncio 对于 IO 密集任务的价值比线程池更轻量的是 asyncio 协程。它的原理是单线程内用事件循环管理多个协程遇到 IO 时主动让出控制权等待 IO 完成后继续执行。协程的切换开销比线程更小所以非常擅长处理大量并发 IO。import asyncio import aiohttp async def fetch_one(session, url): async with session.get(url) as resp: return await resp.text() async def main(): async with aiohttp.ClientSession() as session: tasks [fetch_one(session, url) for url in urls] results await asyncio.gather(*tasks) asyncio.run(main())用 asyncio 的同时要注意不能再在协程函数里用同步阻塞库比如普通的requests.get否则整个事件循环都会被卡住性能反而变差。必须配合同步库的异步版本比如 aiohttp 替代 requestsaiomysql 替代 pymysql。提示如果你的异步代码是纯 CPU 计算没有任何 IO 等待那 asyncio 也帮不了忙。它只适合“等待外部资源”占大头的场景。5. 内存优化别让 GC 拖慢你性能优化的另一个维度是内存。很多人不重视内存结果程序跑到一半内存爆掉或者因为 GC 频繁触发导致 CPU 飙高。内存优化不只是一味减少占用很多时候也是在减少 GC 压力。5.1 生成器处理大数据时不要急着把所有数据装进内存最经典的内存优化是使用生成器替代列表处理大数据。举个例子假设要处理一个几千万行的日志文件# 占用大量内存先把所有行读进内存 with open(big.log) as f: lines f.readlines() # 节省内存一次只取一行 with open(big.log) as f: for line in f: process(line)同理如果函数返回一个很大的结果集时不知道该不该全部返回可以考虑用 yield 改造成生成器。这样调用方每取一个数据函数才生成一个数据内存占用几乎可以忽略。比如分页读取数据库记录用生成器逐批取数据就比一次性fetchall()安全得多。5.2__slots__与弱引用减少对象内存开销Python 对象默认有一个__dict__字典用来存放实例属性。这个字典非常灵活但也很占内存。如果你有一个类实例化出来非常多几十万个而且属性是固定的可以考虑加__slots__把属性固定下来去掉__dict__。class Point: __slots__ (x, y) def __init__(self, x, y): self.x x self.y y这样可以大幅减少每个实例的内存占用。实测中几十万个小对象的场景用__slots__往往能省下 30% 以上的内存。代价是属性不能再动态添加所以只适合属性结构稳定的类。另一个容易忽略的点是循环引用。两个对象互相引用时即使都不可达了引用计数也归不了零必须靠 GC 的循环检测才能回收。如果这种对象特别多GC 的消耗就会变大。解决方案是尽量少在数据结构里构造不必要的循环引用或者在不需要引用时手动置 None。也可以用weakref创建弱引用避免真正拥有对象比如缓存场景就很适合。5.3 怎么快速定位内存占用排查内存问题时我常用两个工具tracemallocPython 内置模块能追踪内存分配来源对快速定位“哪一行代码分配了大块内存”很有帮助memory_profiler第三方库用法类似 line_profiler能按行显示内存占用变化线上跑的任务内存飙高时我一般先用 tracemalloc 快照看谁占内存大再决定具体怎么改。有一次我发现代码里把一个很大的列表缓存为全局变量但业务上根本没用到缓存删掉之后内存峰值直接掉了一半。6. 让性能真正飞起来NumPy、Numba 与 Cython上面讲的都是 Python 层面的优化。但如果你的任务是大规模数值计算、纯 CPU 密集的循环光靠 Python 语法优化还是不够。这时候就要考虑引入 C 扩展级别的工具。这一层优化不是说 Python 不行而是 Python 的定位就是“胶水语言”把性能关键部分交给更底层的库整体效率会高很多。6.1 NumPy 向量化用 C 语言的速度做批量运算NumPy 的底层是 C 语言实现的数组运算核心思路是“向量化”尽量把循环操作改成数组的批量运算。比如要对十万个数字做平方和import numpy as np # 慢写法Python 层循环 data list(range(100000)) result sum(x * x for x in data) # 快写法NumPy 向量化 arr np.array(data) result np.sum(arr * arr)NumPy 版本通常快几十倍甚至上百倍因为循环在 C 层完成了。数据量越大差距越夸张。实际做数据处理时尽量避免写 Python 层的大循环优先用 NumPy 的广播、聚合、切片操作。6.2 Numba 的 JIT 加速最省事的“编译器”方案如果你的算法没法直接用 NumPy 表达比如一个复杂的条件判断循环那么 Numba 是很好的选择。Numba 通过 JIT 编译把 Python 函数编译成机器码代码改动极小只要加个装饰器from numba import njit njit def compute_heavy(n): total 0 for i in range(n): if i % 3 0: total i ** 2 return total我对一个金融风险计算的函数做过测试加了njit之后执行时间从 4 秒变成 80 多毫秒快了几十倍。要注意 nopython 模式。njit其实等价于jit(nopythonTrue)意思是要求 Numba 全部编译成机器码如果用了它不支持的对象类型会直接报错。这其实是好事因为 nopython 模式下性能最有保证。6.3 Cython 和 ctypes手动写 C 扩展的思路Numba 虽然方便但有些场景它不支持或者编译失败。这时候可以用 Cython把关键代码用 Cython 语法写然后编译成 .pyd/.so 扩展。Cython 的优化思路是给变量加上类型声明让字节码不再走动态分发# fibonacci.pyx cpdef long fib(long n): if n 2: return n return fib(n - 1) fib(n - 2)不过 Cython 的学习成本和环境配置成本比前两者高通常只有在你已经确定瓶颈、且 Numba 和 NumPy 都无法解决时才值得上。ctypes 则适合直接调用已有的 C 动态库比如你手头有 C 语言写好的算法库可以用 ctypes 直接调用省去用 Cython 重新包装的工程量。但这些方案都会增加部署复杂度建议先把前几章的优化做透了再考虑。7. 一个完整优化案例从 1.8 秒到 0.15 秒前面讲了很多方法但单独记技巧容易飘。最后我用一个虚构但很典型的数据处理任务把整个优化流程串一遍。任务是读取一个包含 20 万行文本的句子文件统计每个单词出现的次数并找出出现次数最多的前 10 个单词。7.1 初始版本先跑出基线第一版可能是大多数新手会写的版本import re from collections import Counter def word_count(): word_list [] with open(sentences.txt, encodingutf-8) as f: for line in f: words re.findall(r\w, line.lower()) word_list.extend(words) counter {} for word in word_list: if word not in counter: counter[word] 0 counter[word] 1 top sorted(counter.items(), keylambda x: x[1], reverseTrue)[:10] return top我用 timeit 测了一下这版跑完大概 1.8 秒。对于 20 万行文本来说不算灾难但还能优化。现在开始动刀。7.2 第一轮优化正则编译 计数器改进正则re.findall在每次循环里都会编译 pattern这里先提到循环外。统计词频时用 Counter 替代手写字典逻辑语义也更清晰import re from collections import Counter def word_count_v2(): pattern re.compile(r\w) all_words [] with open(sentences.txt, encodingutf-8) as f: for line in f: all_words.extend(pattern.findall(line.lower())) counter Counter(all_words) return counter.most_common(10)改动之后我测了一下耗时从 1.8 秒降到 1.2 秒左右。改动不大但已经提升 30%。注意目前瓶颈主要在all_words列表扩容和extend上而且这仍然是把所有单词都装进内存。7.3 第二轮优化避免构建中间大列表理论上可以不用把所有单词先收集到all_words而是直接在循环里更新 Counter。这样内存占用下降同时也减少列表操作开销def word_count_v3(): pattern re.compile(r\w) counter Counter() with open(sentences.txt, encodingutf-8) as f: for line in f: counter.update(pattern.findall(line.lower())) return counter.most_common(10)Counter.update()接收一个可迭代对象会直接把里面的元素计数累加上去。这一版跑下来耗时降到 0.8 秒左右。主要提升来自避免了一次大列表的构建和复制。7.4 第三轮优化用集合判断和巧妙处理减少中间对象继续看热点。这个例子中pattern.findall(line.lower())每行都会生成一个列表lower()也会生成新的字符串对象。如果句子本身以英文为主可以用finditer配合手动切分来减少对象生成。但实际测下来更干净的做法是换成生成器表达式def word_count_v4(): pattern re.compile(r\w) counter Counter() with open(sentences.txt, encodingutf-8) as f: for line in f: counter.update(word for word in pattern.findall(line.lower())) return counter.most_common(10)没有再把词列表展开直接迭代到 Counter 里。这一版我测下来耗时 0.7 秒左右提升有限但也有效。到这里纯 Python 层面的优化基本到顶了还没到上 NumPy 和 Numba 的程度因为这类字符串统计任务本身扩展库也帮不上忙。7.5 最终版本用更底层的思路从 0.8 秒到 0.15 秒如果还想更快可以换一个思路不再用正则匹配而是用字符串的split()加手工清洗。这个过程我试过很多次在纯文本单词统计场景下split()往往比正则快一个数量级因为它不做复杂的模式匹配def word_count_final(): counter Counter() with open(sentences.txt, encodingutf-8) as f: for line in f: for word in line.lower().split(): # 去掉常见标点符号简化处理 clean_word word.strip(.,!?;:\()[]{}) if clean_word: counter[clean_word] 1 return counter.most_common(10)这段代码牺牲了一点通用性假设标点只出现在单词边缘但因为字符串拆分和 strip 都极其底层速度飞快。实测最终版本耗时约 0.15 秒比第一版的 1.8 秒提升了 12 倍。7.6 案例总结优化的节奏感这个案例能很好地说明性能优化的节奏先跑基线确认性能现状用 profile 找到热点本例热点是正则和列表构建逐层优化编译正则 → 减少中间对象 → 换更底层的实现每一步都重新计时确认改动有效再继续不要一上来就追求最高性能的写法因为可读性和项目周期同样重要。这个案例的最终版本虽然快但正则版本的语义更通用。真实项目中我会根据“这段代码会被跑多少次”来决定用哪一版。如果只是离线脚本跑一次0.8 秒和 0.15 秒差别不大代码可读性更重要如果是热点接口那就值得上最终版。8. 常见问题与排查技巧实录这部分直接汇总我工作中最常遇到的问题和排查经验基本覆盖了性能优化过程中每个人都会碰到的坑。8.1 profile 结果显示大量时间在内置函数上怎么办这种情况最常见的是{built-in method builtins.sorted}、{method append of list objects}之类。看到内置方法并不代表问题在底层而是说明你调用这些方法太频繁了。比如sorted出现在顶级栈里通常是列表太大或者排序 key 函数计算太重。排查时重点看调用栈里是谁在反复调用append或extend。8.2 加了多线程反而更慢了这是一个非常典型的现象。如果你的任务以 CPU 计算为主多线程受 GIL 限制无法并行执行还增加了线程切换成本所以更慢。解决办法是换成多进程或者用 NumPy / Numba 把 CPU 密集部分放到 C 层去C 扩展持锁期间可以真正并行。当任务有很多非 CPU 操作时多线程才发挥优势。8.3 内存越用越多GC 也停了系统内存持续增长通常不是内存泄漏而可能是全局缓存或者数据结构持有大量对象。我排查的顺序是先看代码里有没有把大对象缓存在全局变量/闭包中再用 tracemalloc 两次快照对比看哪个模块的内存增长明显检查是不是大量小对象被反复创建导致 GC 频繁触发如果确实是大量小对象可以考虑__slots__或者改用 NumPy / 数组结构存储同类型数据。提示永远不要在循环里做无意义的对象创建比如把{}或[]放到循环里重新创建能提出来就提出来。这不是性能优化技巧这是编码洁癖。8.4 我把 Numba 加到代码里但报了一堆错Numba 报错大部分是因为nopythonTrue模式下不支持某些类型比如 Python 的 dict 虽然新版支持了一部分但很多操作仍会被拒绝。我的建议是先用简单的纯数值函数试跑 Numba确认能编译出机器码后再逐步扩展。如果 Numba 搞不定就退回 NumPy把 NumPy 也搞不定再考虑 Cython。工具的复杂度是递增的别一上来就选最重的。8.5 优化后结果不对了最后一条也是最重要的一条优化必须保证正确性。我见过太多案例代码改快了输出却悄悄变了。比如正则换成split()后漏掉了一些单词缓存加上后返回了旧数据多进程共享内存改错了状态。所以每次优化后都要做对比验证用小规模样本前后对比输出是否一致保留旧版本代码方便随时回退和对比性能数据至少测三次取平均值避免偶然波动这也是我在做性能优化时最推荐的流程先有正确性的基线再谈性能提升。没有正确性保障的优化跑得再快也没有意义。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价