Python的列表推导式差点搞垮我的服务器

张开发
2026/6/8 16:22:35 15 分钟阅读

分享文章

Python的列表推导式差点搞垮我的服务器
一个凌晨三点的报警电话事情发生在某个周六的凌晨三点。我被一通电话吵醒。手机屏幕上显示的是公司的监控告警系统——CPU使用率飙升到98%内存快爆了服务器快要撑不住了。我迷迷糊糊地打开电脑看到日志里有一行代码正在疯狂运行result [process_item(item) for item in huge_list]一行列表推导式。就这一行代码把我8核16G的服务器拖到了崩溃的边缘。你可能觉得我在夸张。但那天晚上这行看似优雅、简洁的列表推导式差点让我整个周末都泡汤。今天我就把这个故事从头到尾讲一遍。从“为什么我觉得列表推导式很酷”到“为什么它差点搞垮我的服务器”再到“我怎么把它救回来的”。列表推导式我曾经的“心头好”说实话在出事之前我是列表推导式的铁杆粉丝。你看这种代码# 传统写法 squares [] for i in range(10): squares.append(i ** 2) # 列表推导式 squares [i ** 2 for i in range(10)]三行变一行干净利落。谁不爱呢再复杂一点# 带条件的 even_squares [i ** 2 for i in range(20) if i % 2 0] # 两层循环 pairs [(x, y) for x in range(5) for y in range(5)] # 嵌套推导式 matrix [[j for j in range(5)] for i in range(5)]写起来顺手读起来也直观。我当时觉得这就是Python优雅的典范。直到那个凌晨。事故现场到底发生了什么让我还原一下当时的场景。我的任务是从数据库里读取100万条用户记录对每条记录做一些处理格式化、校验、补充信息然后生成一个报表。数据量大概是这样用户表100万条记录每条记录处理后会变成一个字典包含大约50个字段最终结果是一个列表里面装了100万个字典我写的代码大致是这样的def process_user(user_data): # 模拟一些处理逻辑 return { id: user_data[id], name: user_data[name].strip().title(), email: user_data[email].lower(), score: calculate_score(user_data), tags: parse_tags(user_data.get(tags, )), # ... 还有40多个字段 } def get_report(): users db.fetch_all_users() # 返回100万条记录 result [process_user(user) for user in users] return result在测试环境数据量只有1000条这段代码跑得飞快不到0.1秒就完成了。但到了生产环境100万条数据情况完全不一样了。问题出在哪里两个地方。问题一内存爆炸列表推导式会一次性把所有结果都放在内存里。100万个字典每个字典大约占用500字节实际只多不少那就是1,000,000 × 500 ≈ 500,000,000 字节 ≈ 500 MB这只是结果本身。别忘了原始数据users也还在内存里还有中间过程中产生的各种临时对象。实际内存占用大概在1.5GB到2GB之间。我的服务器只有16GB内存看起来好像够用但问题是这个服务同时要处理多个请求。如果三个报表同时跑内存直接炸。问题二CPU排队列表推导式是单线程的。处理100万个用户就是一个接一个地处理处理完第一个才处理第二个。每个用户处理需要多少时间假设是0.5毫秒其实业务逻辑往往更慢那么总时间1,000,000 × 0.0005 500 秒 ≈ 8.3 分钟一个报表要跑8分钟。用户早就等不及关页面了。而在这8分钟里CPU一直满负荷运转其他请求都被堵在后面排队。为什么列表推导式会这样列表推导式本质上是一个语法糖。它做的事情和你写一个for循环然后append是一样的。# 这两种写法内存和时间的消耗是一样的 result [process(x) for x in data] # 列表推导式 result [] # 等价写法 for x in data: result.append(process(x))两者都是创建一个空列表遍历数据每次处理一个元素把结果追加到列表末尾最后返回整个列表所以当数据量大的时候列表推导式的问题就很明显内存一次性存储所有结果速度单线程串行处理这不是列表推导式本身的问题而是“一次性把所有数据装进列表”这个模式的问题。那晚我是怎么救回来的凌晨三点我喝了杯凉水开始改代码。第一板斧用生成器代替列表生成器和列表推导式长得几乎一样只是把方括号换成圆括号# 列表推导式一次性生成所有结果 result_list [process(x) for x in data] # 占用大量内存 # 生成器表达式按需生成结果 result_gen (process(x) for x in data) # 几乎不占内存生成器不会一次性把所有结果算出来而是“需要用的时候才算”。内存占用从几百MB降到了几乎可以忽略不计。但是生成器只能遍历一次。如果你要反复使用这些数据生成器就不合适了。对于我的报表场景数据只输出一次用生成器完美。第二板斧分批处理有时候你必须得到一个完整的列表比如需要反复使用、需要取长度、需要排序。这时候怎么办分批处理。def process_in_batches(data, batch_size10000): 分批处理数据避免一次性占用太多内存 results [] for i in range(0, len(data), batch_size): batch data[i:ibatch_size] batch_results [process(item) for item in batch] results.extend(batch_results) # 可选每批处理后打印进度 print(f已处理 {min(ibatch_size, len(data))}/{len(data)} 条) return results这样做的好处内存里最多同时存在batch_size个处理结果而不是全部。第三板斧并行处理处理速度慢的问题需要用并行来解决。Python的concurrent.futures模块提供了简单的并行方案from concurrent.futures import ProcessPoolExecutor, as_completed def process_in_parallel(data, process_func, max_workers8): 多进程并行处理数据 results [] with ProcessPoolExecutor(max_workersmax_workers) as executor: # 提交所有任务 futures {executor.submit(process_func, item): item for item in data} # 按完成顺序收集结果 for future in as_completed(futures): try: result future.result() results.append(result) except Exception as e: print(f处理出错{e}) return results用8个进程并行处理原本8分钟的工作理论上可以缩短到1分钟左右。但要注意多进程有额外的开销进程创建、数据序列化、结果反序列化。如果每个任务的处理时间很短比如几毫秒那开多进程反而更慢。一般来说单任务处理时间超过0.1秒才值得用多进程。更优雅的方案看看你的数据流经过那一晚我重新思考了一个问题我真的需要那个列表吗很多时候我们需要的是一个可迭代的对象而不是一个具体的列表。比如你要把数据写入文件# 不要这样做 results [process(x) for x in data] for result in results: f.write(str(result) \n) # 这样做 for x in data: f.write(str(process(x)) \n)再比如你要把数据发送给API# 不要这样做 results [process(x) for x in data] api.send_batch(results) # 这样做如果API支持流式发送 for x in data: api.send_one(process(x))再比如你要计算统计值# 不要这样做 results [process(x) for x in data] average sum(results) / len(results) # 这样做边计算边求和 total 0 count 0 for x in data: total process(x) count 1 average total / count这些例子的共同点你根本不需要同时保留所有结果。列表推导式什么时候该用什么时候不该用经过这次教训我给自己定了几条规则。该用列表推导式的场景数据量小比如少于1万条一眼能看出上限结果列表确实需要反复使用代码可读性的收益大于性能损耗临时脚本、一次性数据处理不该用列表推导式的场景数据量未知或可能很大从数据库、文件、API读取内存受限的环境如云函数、容器每个元素处理成本高IO密集、计算密集结果只需要使用一次可以改用生成器表达式的场景数据量大但只需要遍历一次需要链式处理多个转换步骤不想在内存里囤积所有数据生成器的写法# 列表推导式 result [process(x) for x in data] # 生成器表达式语法几乎一样 result (process(x) for x in data)一个快速判断的工具函数有时候你写代码时不确定数据量有多大。这时候可以写一个智能版本from collections.abc import Iterable def smart_map(func, data, threshold10000): 智能处理小数据用列表推导式大数据用生成器 if not isinstance(data, Iterable): raise TypeError(data must be iterable) # 如果数据有长度且小于阈值返回列表 if hasattr(data, __len__) and len(data) threshold: return [func(x) for x in data] # 否则返回生成器 return (func(x) for x in data) # 使用 result smart_map(process_user, users) # 如果 users 有长度且小于10000result 是列表 # 否则 result 是生成器这个函数帮你做自动判断代码写起来不用纠结。事故后的复盘第二天上班我做了几件事第一给监控加了内存告警。之前只看CPU内存问题完全被忽略了。第二给代码加了自动降级。当数据量超过阈值时自动切换到流式处理模式。第三也是最重要的——重新理解了“优雅”的含义。我以前觉得代码越短越优雅。现在我觉得能在正确的场景下正确运行的代码才是真正的优雅。一行列表推导式看起来很酷。但如果它让你的服务器崩溃那就不叫优雅叫灾难。最后的总结列表推导式不是恶魔。它很好用但你需要知道它的边界。记住三句话列表推导式会一次性把所有结果塞进内存——数据量大时别用它生成器表达式是它的替代品——把方括号换成圆括号就行如果必须用列表考虑分批处理或并行处理那次事故之后我再看到列表推导式都会下意识问自己三个问题这个数据有多大我真的需要同时保留所有结果吗换成生成器会不会更好这三个问题也送给正在看文章的你。你在代码里有没有被列表推导式坑过或者有什么更奇葩的经历欢迎在评论区聊聊。

更多文章