Python性能优化技巧让你的代码飞起来说个我自己的经历。早几年我维护一个数据处理服务线上任务越跑越慢从最初几秒处理一批涨到几分钟用户都开始抱怨了。当时我第一反应是“Python就是慢要不换成Go重写吧”。后来静下心来用cProfile一测发现90%的时间耗在一个函数里而那个函数里有一段几万次的for循环每次循环都在重复做一次字符串拼接和列表查找。换了个数据结构、改了拼接方式性能直接提升了十几倍。你看很多“Python慢”的锅其实根本轮不到语言来背。这篇文章我想把我的性能优化思路完整拆一遍从定位热点、数据结构选型到循环细节、并发模型、内存分配再到一个完整的实战案例帮你在“让代码跑得更快”这条路上少走弯路。这篇文章适合的读者不是那种还没写明白业务逻辑就想炫技的初学者而是已经写过一段Python、有一天突然发现自己的脚本、服务、批处理开始卡顿想系统地知道瓶颈在哪、怎么对症下药的中级开发者。1. 为什么先别急着优化性能问题的本质与定位1.1 一个反直觉的事实90%的优化需求根本不在热点路径上很多人拿到一段慢代码上来就开始改把for循环改成列表推导、给函数加缓存装饰器、甚至掏出了cython。结果改了半天性能还是那个鬼样子。为什么因为你根本不知道慢在哪里。我见过太多类似的场景一个人花了一整天把某个辅助函数从1毫秒优化到0.1毫秒但主流程里一个数据库查询要花500毫秒。这种优化对整体毫无意义但却是新手最常见的操作。性能优化的第一原则从来只有一个先测量再动手。不信你可以做一个简单的实验随手写一段代码让它在本地跑起来然后猜一下哪一个函数是热点。我敢说你的直觉超过一半概率是错的。我自己做过这个测试猜了三次只对了一次而且猜中的那个还是因为那个函数名字叫compute_core明摆着就是重头戏。性能调优不是玄学也不是经验直觉它是一门需要有数据支撑的工程学科。1.2 用 profile 代替直觉cProfile、py-spy 与 line_profiler 的取舍怎么测量这里我按工具维度讲一遍每个都有它的适用场景你不需要全学但至少要知道什么时候该用哪个。首先是标准库自带的 cProfile。这是你第一个应该用的工具因为它不需要任何外部依赖而且直接给你函数的调用次数和累计耗时。import cProfile import pstats cProfile.run(my_slow_function(), output.prof) with open(result.txt, w) as f: pstats.Stats(output.prof, streamf).sort_stats(cumulative).print_stats(20)这段代码会生成一个result.txt里面列出了所有被调用函数的耗时排序。我通常重点关注两个指标一个是tottime函数本身耗时不包含子调用一个是cumtime包含子调用的累计耗时。如果一个函数tottime很高说明瓶颈就在它内部。如果只有cumtime高说明它调用了别的慢函数。不过 cProfile 也有明显的局限。它本身会引入相当大的性能开销而且它只能告诉你哪些函数耗时多不能告诉你在函数内部的“哪一行”耗时多。这就是 line_profiler 的用武之地。它给每个函数都能精确到每一行代码的执行时间定位效率极高。安装方式是pip install line_profiler然后给目标函数加上profile装饰器运行方式不是python your_script.py而是kernprof -l -v your_script.py。再有一个工具是 py-spy它最牛的地方是完全采样模式不需要对代码做任何改动就能查看运行中程序的调用栈特别适合排查线上卡住的服务。有一次我线上服务器CPU飙到100%但不知道卡在哪直接用py-spy dump --pid 12345拿到了当时的调用栈一眼就看到了一个无限循环。这种体验cProfile 给不了你因为线上代码不能随便加装饰器重启。关于测量最后还想说一句测试用的数据规模一定要接近真实生产环境的数据规模。用100条数据测出来的热点分布和用100万条数据测出来的可能是完全两个世界。这个细节很多人忽略导致在本地测出来的“优化方案”到了生产环境完全不奏效。2. 软件层面最便宜的提速数据结构选型与算法清理2.1 哈希表 vs 线性表dict/set 有多快list 有多慢说到性能很多人第一时间想到的是并发、缓存、JIT这些高大上的东西但真正收益最高、成本最低的优化其实是数据结构选型。这里面的经典案例就是查找操作。Python 的 list 是动态数组查找一个元素是否在里面是 O(n) 的线性扫描。n 小的时候无所谓但 n 一旦涨到十万、百万量级这个线性扫描就会成为灾难。而 dict 和 set 的底层是哈希表平均查找复杂度是 O(1)差距是数量级的。我随便举个例子假设你有一个包含百万个字符串的列表需要反复判断某个字符串是否在其中用 list 的if x in my_list每次要遍历百万次假设每次判断只要0.1毫秒一万次判断就是1秒。而换成 set 之后一万次判断加起来的耗时可能不到 0.01 秒差距是百倍级别的。这个优化有多容易只要把my_list改成set(my_list)就行了。一行代码收益百倍。但为什么很多人不这么做因为只要数据量没上到一定阈值你根本感觉不到慢。这才是问题所在性能优化不能等出了事故再做写代码的时候就要对数据结构的复杂度有感觉。我自己的一个习惯是在写查找类逻辑的时候先问自己三个问题这个集合有多大查找频率有多高集合的构建成本是多少确认之后再用 set 或 dict。有一个需要小心的点set 和 dict 要求元素是可哈希的。list 和 dict 本身不能放进 set但 tuple 可以。如果你遇到 “unhashable type: list” 错误通常是把 list 直接放进了 set 或者当作 dict 的 key改成 tuple 就行。2.2 别让 Python 替你循环内置函数、itertools 与二分查找的降维打击数据结构选对了下一步就是减少 Python 解释器替你执行的字节码。Python 核心哲学之一是用 C 实现的内置函数尽可能替代 Python 层的显式循环。举个最常见的例子求一堆数的总和。你用sum()和自己写个 for 循环累加结果是天壤之别。因为sum()内部是 C 语言实现的循环而且这个 C 循环避开了 Python 对象模型的反复调用开销。同样的道理max()、min()、any()、all()、sorted()都尽量用内置版本。另一个容易忽略的宝藏模块是 itertools。它提供的chain、groupby、product、combinations等函数都是用 C 实现的生成器内存开销极低。我举一个实际案例你有一个嵌套列表想把它拍平最直观的写法是嵌套 for 循环result [] for sublist in nested_list: for item in sublist: result.append(item)这个写法没有问题但更快的写法是用itertools.chain.from_iterablefrom itertools import chain result list(chain.from_iterable(nested_list))第二种写法不仅更快代码也更短。为什么快因为循环是在 C 层展开的Python 解释器执行的代码变少了。还有一个很多人不知道的函数bisect。它实现了针对有序列表的二分查找查找复杂度是 O(log n)。如果你需要一个“可变的、既要频繁插入元素又要频繁查找”的数据结构list bisect.insort 与 list 线性查找相比同样是降维打击。import bisect # 在有序列表里插入元素自动维持有序性 bisect.insort(sorted_list, new_item) # 查找某个值在有序列表里的插入位置 index bisect.bisect_left(sorted_list, target)注意bisect.insort的插入本身是 O(n) 的因为它底层还是列表插入需要移动元素。但它帮你省掉了“找到应该插入哪个位置”的那一次 O(n) 查找。如果你的字典序查找频率远高于插入频率这个优化收益巨大。3. 热点循环里的微操局部变量、列表推导与函数调用成本的平衡3.1 全局变量为什么慢字节码层面的秘密当你把数据结构选对以后下一步就是抠热点循环里的细节了。先说一个几乎所有 Python 开发者都忽略的性能点全局变量查找比局部变量查找慢得多。为什么因为 Python 解释器访问局部变量使用的是LOAD_FAST指令这个指令直接从一个固定位置的数组里按索引取值效率极高。而访问全局变量使用的是LOAD_GLOBAL指令需要先查字典字典查找是哈希操作效率远低于索引访问。看起来这个差异微乎其微但当你在一个千万次循环里反复访问全局变量时这个差异会被无限放大。我在一个自己的项目里做过测试同样的循环逻辑把全局变量改成函数内局部变量性能提升了接近 20%。具体怎么改很简单# 慢版本全局变量在循环里反复访问 GLOBAL_CONSTANT 100 def slow_func(items): result [] for item in items: result.append(item * GLOBAL_CONSTANT) return result # 快版本循环外先把全局变量绑定到局部变量 GLOBAL_CONSTANT 100 def fast_func(items): const GLOBAL_CONSTANT # 局部变量绑定 result [] for item in items: result.append(item * const) return result第二种写法只是增加了一行const GLOBAL_CONSTANT性能就有可感知的提升。再有一个类似的经验反复访问对象的属性也有成本。Python 的属性访问最终会触发描述器协议内部还有字典查找的过程。所以在热点循环里如果反复用到obj.attribute可以先把属性值取出来赋给局部变量。这个操作不影响代码可读性收益在数据量大的时候非常明显。3.2 列表推导不是银弹它快在哪、慢在哪列表推导式常被当作追求性能的标志写法观察很多人一看到for循环就强迫自己改写成推导式理由只有一个“网上说推导式快”。这个理解是大方向正确的但如果只知道结论而不知道底层原因很容易用错。列表推导式为什么比等价的 for 循环 append 快核心原因是它在内部预分配了结果列表的内存空间并且使用了专门优化过的 LIST_APPEND 字节码指令来追加元素而不是每次调用list.append这个方法。调用方法是有开销的——每次都需要去查对象的方法然后创建一个绑定方法对象。积累十万次调用这个开销就非常可观了。但列表推导式也有它的代价。它会把整个结果集一次性构建到内存里。假设你的数据有 1000 万条模型结果也有这么多条那你的内存占用就可能突破几个 GB。这时候如果不关心“完整结果集”而只需要逐个处理数据正确的选择是生成器表达式# 两者产出的结果不同 list_result [process(x) for x in huge_data] # 一次性构建列表 gen_result (process(x) for x in huge_data) # 惰性求值逐个产出生成器表达式的每一条结果是被“拉”出来的不会一次性全部驻留内存。代价是每条结果出来的时候有一定的迭代器开销。所以它的定位不是“更快”而是“更省内存”。在数据量达到千万级时省内存就是保命。至于map和filter我的建议是保留着作为理解函数式编程的工具但日常优化不要一上来就上它们。原因很简单列表推导式的语义更清晰而且在你需要同时做变换和过滤时推导式的[transform(x) for x in data if condition(x)]写作方式是 map filter 无论如何也表达不出来的实际跑起来的性能差异可以忽略不计。4. CPU 密集跑不动、I/O 密集卡成狗多线程、多进程与异步的正确分工4.1 GIL 不是洪水猛兽它锁住的是什么如果要给 Python 性能问题找一个最大的替罪羊非 GIL 莫属。GIL 是 CPython 解释器里的一个全局锁它保证同一个时刻只有一个线程在解释器里执行 Python 字节码。这也是“Python 多线程很废”这个说法的来源。但GIL 的全名是 “Global Interpreter Lock”注意它锁的是 “Interpreter”也就是解释器本身。当一个线程在等待 I/O 操作比如网络请求、文件读写时它会把 GIL 释放掉让其他线程有机会执行。这意味着对 I/O 密集型的任务多线程不仅能用而且效果很好。举个生活化的类比你开了一家奶茶店只有一个服务员这就是 GIL服务员做奶茶很快I/O 完成得快但客人点单很慢、付款很慢、找钱很慢I/O 等待。此时多线程就像是多排了几个人同时排队点单服务员在等第一个客人掏钱的间隙就可以先去给第二个客人做奶茶。整体吞吐量上去了。但对于 CPU 密集型任务比如复杂的数学计算、图像处理、压缩算法每个线程都需要时刻占用 CPU此时 GIL 就成了真正的瓶颈——其他线程会因为没有拿到 GIL 而无法执行多线程就退化成单线程了。所以问题不在 “Python 多线程没用”而在于 “你用多线程跑了 CPU 密集任务”。这是使用场景的错误不是这个语言的锅。4.2 三种并发方式该怎么选一张表看清适用场景Python 里有两种多线程实现方式以及一种多进程方式还有一个 asyncio合理选型的逻辑如下并发方案适用任务类型优点缺点典型应用threading多线程I/O 密集共享内存方便、代码改动小GIL 限制 CPU 密集、线程切换有开销大量网络请求、文件读写multiprocessing多进程CPU 密集绕过 GIL、充分利用多核进程间通信成本高、内存不共享数值计算、图像处理、压缩加密asyncio异步协程高并发 I/O单线程内实现万级并发、开销极低不能有阻塞调用、生态要求高Web 爬虫、API 网关、聊天服务多进程方案里我强烈推荐使用concurrent.futures.ProcessPoolExecutor而不是手动去管理多进程。原因很简单手动管理进程池涉及队列通信、结果收集、异常处理很容易写出僵尸进程和内存泄漏。而 ProcessPoolExecutor 已经把这层封装好了你只需要提交任务、拿结果就行。from concurrent.futures import ProcessPoolExecutor def calculate_one(item): # 这里可以是 CPU 密集型计算 return heavy_compute(item) with ProcessPoolExecutor(max_workers8) as executor: results list(executor.map(calculate_one, data_list))一个常见的坑是当你用多进程时每个子进程都会导入一次主模块。如果你没有把入口代码保护在if __name__ __main__:里面子进程就会无限递归地创建新进程直接把系统资源吃光。这个问题在 Windows 上特别容易触发在 Linux 上表现为 fork 后的资源浪费。所以任何走 multiprocessing 的脚本入口必须写在if __name__ __main__:里。asyncio 是我个人比较偏爱的一种方案。它用事件循环在一个线程内调度多个协程协程间的切换成本远低于线程切换。用 asyncio 写高并发爬虫配合aiohttp几千个并发请求可以稳定运行。代价是写 asyncio 代码时很容易踩到“阻塞陷阱”——比如在协程里调用了time.sleep()这种同步阻塞函数整个事件循环都会被卡住所有协程都停了。正确写法是使用await asyncio.sleep()。5. 字符串拼接、正则与内存分配日常代码里最隐蔽的耗时点5.1 拼接字符串为什么不能随便用如果你写 Python 有一段时间一定见过无数用 拼接字符串的代码。在字符串量级不大的时候这完全没问题。但一旦进入循环场景 会带来严重的性能问题。原因很简单Python 的字符串是不可变对象。s abc并不是在原有字符串后面追加内容而是创建了一个新的字符串对象再把旧字符串复制过去。如果在一个 10 万次的循环里做s chunk每次循环都要复制一次当前已有的全部内容总的时间复杂度是 O(n²)。这个问题有一个教科书级的解决方案把片段收集到一个列表里最后用.join(list)一次性拼接# 慢版本O(n²) result for chunk in chunks: result chunk # 快版本O(n) result .join(chunks)join之所以高效是因为它一次性遍历所有片段预先知道最终长度只分配一次内存来存放完整结果。两段代码逻辑相同性能差距在数据量大时是数量级的。另一个跟字符串相关的优化点是 f-string。在 Python 3.8 里f-string 比%格式化和.format()都要快因为它是在编译期直接转换成了字节码省去了运行时解析格式串的开销。所以能用 f-string 的地方就别用别的东西。还有一个调试技巧Python 3.8 开始 f-string 支持符号可以直接打印表达式和值name Python print(f{name}) # 输出: namePython这个特性虽然不是直接的性能优化但调试时省掉了大量重复的变量名 文本输入提升的是你自己的“开发性能”。5.2 正则不是万能的什么时候该用 str 内置方法正则表达式是处理字符串的强大工具但它的强大是有代价的。正则引擎要做词法分析、语法分组、回溯匹配其开销远大于普通的字符串方法。我自己见过太多性能事故都是因为在一个热点路径里写了正则而那个模式其实用字符串的内置方法就能解决。比如判断字符串是否以某个前缀开头用startswith()不要用re.match()。判断是否包含某个子串用in或find()不要用re.search()。替换固定字符串用replace()不要用re.sub()。当你的正则模式包含嵌套分组和量词组合时还会触发灾难性回溯。最经典的模式是(a)$之类的“指数型回溯”处理恶意构造的输入时程序会直接卡死。著名的 ReDoS 攻击利用的就是这个原理。所以建议每当你准备写一个正则时先停下来问一句“这个东西能用普通的字符串方法实现吗”如果确实需要正则那就用re.compile()预编译模式对象。因为每次调用re.search(pattern, text)时如果不预编译Python 都会重新编译一次模式这个过程有缓存但如果模式很多、调用很频繁预编译带来的性能收益还是很明显的。import re pattern re.compile(r\d{4}-\d{2}-\d{2}) # 在循环内避免反复编译 for record in records: m pattern.search(record) if m: ...关于内存分配还有一个细节是 Python 的小对象内存池。Python 对 512 字节以下的小对象有专门的内存池分配策略并不会频繁向操作系统申请内存。但是如果你创建大量临时列表、字典、集合这些对象的释放会产生大量内存碎片。处理大数据量批任务时一个常用的技巧是分块处理而不是一次全量加载比如每处理 10 万条数据就主动调用一次gc.collect()让内存及时回收。6. 实战把一段“能用但慢”的代码一步步改到起飞6.1 原始版本先写出正确答案讲完这么多理论我整理了一个相对完整的实战案例完整展示从一段慢代码变成快代码的过程。假设有一个任务处理一份用户日志文件约 50 万行每行包含用户ID、操作类型、操作耗时三列用逗号分隔。我们需要把所有时间去重并统计每种操作的总耗时还要找出平均耗时最高的前 10 个用户ID。这是一个典型的文本处理分析任务。第一版代码按照“能跑就行”的直观思路写出来可能是这样import re def analyze_log(log_path): user_dict {} op_dict {} with open(log_path, r) as f: for line in f: # 用正则提取三个字段 parts re.match(r(.),(.),(.), line.strip()) user_id parts.group(1) op_type parts.group(2) elapsed parts.group(3) # 维护用户耗时列表 if user_id not in user_dict: user_dict[user_id] [] user_dict[user_id].append(float(elapsed)) # 维护操作总耗时 if op_type not in op_dict: op_dict[op_type] 0.0 op_dict[op_type] float(elapsed) # 找出平均耗时最高的前 10 个用户 user_avg {} for uid, times in user_dict.items(): user_avg[uid] sum(times) / len(times) top10 sorted(user_avg.items(), keylambda x: x[1], reverseTrue)[:10] return op_dict, top10这段逻辑完全正确但在我的测试环境上跑 50 万行日志用了约 8.3 秒。为什么会这么慢我们逐层拆解。6.2 用 profile 定位瓶颈跑一下 cProfile 看看热点python -m cProfile -s cumulative analyze_log.py结果的高耗时函数分布大致是这样的re.match调用约 3.1 秒占比 37%user_dict的列表追加与求和约 2.4 秒占比 29%float()转换与各类局部属性操作约 1.5 秒占比 18%其他约 1.3 秒看到这个结果我心里就清楚优化方向了第一正则替换成字符串方法第二把记录用户的耗时列表改成累积和加次数避免最终再去遍历求和。6.3 针对性优化与最终效果下面是优化后的版本def analyze_log_fast(log_path): user_sum {} user_count {} op_dict {} with open(log_path, r) as f: # 直接用 split 切分字段降低正则开销 for line in f: user_id, op_type, elapsed_str line.strip().split(,) elapsed float(elapsed_str) # 维护两个累积变量避免每次 append 和最后的 sum if user_id not in user_sum: user_sum[user_id] 0.0 user_count[user_id] 0 user_sum[user_id] elapsed user_count[user_id] 1 # 操作总耗时同样使用累积方式 if op_type not in op_dict: op_dict[op_type] 0.0 op_dict[op_type] elapsed # 直接基于总数与次数算均值 user_avg {} for uid in user_sum: user_avg[uid] user_sum[uid] / user_count[uid] top10 sorted(user_avg.items(), keylambda x: x[1], reverseTrue)[:10] return op_dict, top10有几处值得说明的优化逻辑。split(,)替代整个正则匹配。原来的正则(.),(.),(.)看起来简单实际匹配过程中涉及分组、捕获、模式回溯远比简单切片耗时。换成split之后解析耗时直接降了一个数量级。第二个变化是把“列表记录所有耗时”变成了“维护总和与次数两个变量”。原来的做法是为每个用户创建一个列表每次读取日志追加一个数字最后统计平均耗时的时候再遍历每个列表求和。这意味着在峰值时期内存中同时驻留了 50 万条浮点数。优化之后每个用户只存一个总和和一个计数内存占用从 O(日志行数) 降到了 O(用户数)。这一步不仅降低了内存峰值也让最终算平均值的遍历成本从 O(日志行数) 降到 O(用户数)。还剩下一个细节是if user_id not in user_sum每次都要查一次字典然后下一次赋值的时候又要再查一次。如果用dict.setdefault或者defaultdict可以让代码更简洁但要注意在性能敏感的场景下if not in加赋值这两个操作反而比defaultdict更直接因为defaultdict内部每次还要做一次默认值构造的尝试而且会引入额外函数调用。没有绝对的好与坏只有适不适合当前场景。优化后的代码在我的测试环境上跑同一份 50 万行日志耗时降到了 0.42 秒大约是原来的 20 分之一。整个优化过程没有引入任何第三方库没有换语言没有加缓存只是做了三件事换了数据解析方式、换了数据累积方式、避免了重复的属性访问和全局查找。这个案例再次验证了我开头的观点Python 本身的性能足以应对绝大多数业务场景瓶颈往往出在我们写出来的代码结构和数据组织方式上。把这个思维建立起来远比记住几条优化技巧更重要。最后再分享一个我一直保留的工作习惯每次做性能优化时都会先写一个小的基准测试脚本把优化前后的耗时对比记录下来。不需要很复杂就是记一下优化前耗时、优化后耗时、压测数据量、机器配置。时间长了你对哪些操作该用哪种写法的“直觉”会越来越准而这种直觉才是性能优化的真正核心竞争力。