那天,服务器报警了
周五下午三点半,我正准备收拾东西下班。
运维同事在群里发了一条消息,配了一张截图:"线上内存使用率95%,你们谁在跑什么任务?"
我后背一凉。今天确实部署了一个新脚本,用来处理上个月的用户行为日志。
文件不大,也就2.8GB。我心想,用列表推导式把数据读进来过滤一下,Python嘛,写起来多优雅:
arduino
logs = [line for line in open('user_logs.txt') if 'error' in line]
就这一行。优雅,简洁,Pythonic。
然后内存就爆了。
我盯着屏幕上那行代码,不敢相信问题出在这里。2.8GB的文件,这行代码直接给我干出了4.2GB的内存占用。服务器开始疯狂swap,磁盘IO飙到100%,整个服务都跟着抖动。
一个小时后,我把这行代码改成了一行生成器:
arduino
logs = (line for line in open('user_logs.txt') if 'error' in line)
内存占用降到了不到50MB。速度反而还快了。
那天我下班的时候,天已经黑了。但有一件事我彻底搞明白了:列表推导式和生成器表达式,看起来就差一个括号,实际上是天壤之别。
列表推导式到底做了什么?
先来看看列表推导式的工作原理。
当Python执行这行代码时:
ini
squares = [x**2 for x in range(10000000)]
背后发生的是:
- Python先创建一个空列表
- 开始循环,每次计算
x**2 - 把计算结果一个一个塞进列表里
- 直到循环结束
这个过程看起来没问题对不对?问题就在于第3步------所有计算结果都被存进了内存。
1000万个整数,每个整数28个字节(Python的int对象开销很大),再加上列表本身的空间------280MB就这样没了。
这就是列表推导式的"原罪":它一次性把所有结果全部生成并存储。
数据量小的时候,这根本不是问题。但数据量一大,它就像一台内存抽水机,把可用内存榨得干干净净。
列表推导式的内存消耗有多夸张?
我做个测试给你看。
读取一个1GB的日志文件,过滤出包含"ERROR"的行:
arduino
# 列表推导式
lines = [line for line in open('log.txt') if 'ERROR' in line]
运行过程中,内存占用峰值是多少?
3.2GB。
为什么会比文件本身还大?因为Python把每一行都拆成了独立的字符串对象,每个字符串都有额外的对象头开销(大约49个字节)。文件里的原始字节变成了Python对象,内存占用直接膨胀了好几倍。
如果用普通for循环加append,结果是一样的:
scss
lines = []
for line in open('log.txt'):
if 'ERROR' in line:
lines.append(line)
内存占用依然是3.2GB。列表推导式只是写法更简洁,底层的本质没变------都是把结果全部装进列表。
生成器到底做了什么?
再看生成器表达式的写法:
arduino
lines = (line for line in open('log.txt') if 'ERROR' in line)
运行这一行,内存占用几乎为0------准确地说,几十KB。
为什么?因为这一行代码什么都没算。
它只是创建了一个生成器对象。这个对象记住了三件事:
- 你要遍历哪个可迭代对象(
open('log.txt')) - 你要执行什么操作(
line for line ... if 'ERROR' in line) - 现在遍历到什么位置了(还没开始)
当你真正遍历它的时候:
arduino
for line in lines:
process(line)
生成器才开始工作:读一行,处理一行,处理完就丢掉,再读下一行。
内存里始终只有一行数据。
这就是生成器的核心思想:惰性求值------需要的时候才计算,算完就扔,绝不囤货。
不是说生成器一定更快
这里要澄清一个常见的误解:生成器不一定比列表推导式快。
事实上,在大多数场景下,列表推导式更快。
为什么?因为列表推导式在C语言层面做了优化,循环速度比Python的for循环要快。而且一次性分配好内存,比不断地申请释放内存效率更高。
我做了一个简单的性能测试:
场景1:计算1000万个数的平方
| 方式 | 耗时 | 内存占用 |
|---|---|---|
| 列表推导式 | 0.47秒 | 280MB |
| 生成器表达式 | 0.52秒 | 几乎为0 |
列表推导式快了大约10%。
但场景2:处理一个2.8GB的日志文件
| 方式 | 耗时 | 内存占用 | 结果 |
|---|---|---|---|
| 列表推导式 | 12.3秒 | 4.2GB | 内存溢出 |
| 生成器表达式 | 11.8秒 | 48MB | 正常完成 |
看到区别没有?当数据量小到内存装得下的时候,列表推导式确实快。但当数据量大到内存装不下的时候------快有什么用?直接崩了。
所以选择的标准很简单:
- 数据量小,内存随便装 → 列表推导式,简洁又快
- 数据量大,内存吃紧 → 生成器,保命要紧
什么时候该用生成器?
判断标准就一条:你是否需要一次性持有所有数据。
不需要,就用生成器。
下面这些场景,强烈建议用生成器:
1. 处理大文件
ini
# 错误写法
lines = [line.strip() for line in open('huge_file.csv')]
# 正确写法
lines = (line.strip() for line in open('huge_file.csv'))
2. 数据流处理
csharp
# 从API分页获取数据
def fetch_all_users():
page = 1
while True:
data = requests.get(f'/users?page={page}')
if not data:
break
for user in data:
yield user
page += 1
# 使用
for user in fetch_all_users():
process(user) # 边取边处理,不会把所有用户都存内存里
3. 无限序列
csharp
# 生成无限斐波那契数列
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# 只取前100个
for num in fibonacci():
if num > 100:
break
print(num)
如果用列表,你根本没法表示无限序列------内存会炸。
生成器不只有这一种写法
除了生成器表达式(圆括号那个),Python还有两种方式创建生成器:
方式一:yield关键字
python
def read_large_file(path):
with open(path) as f:
for line in f:
yield line # 每次返回一行
任何包含 yield 的函数,调用时返回的都是生成器对象。
方式二:生成器表达式
ini
squares = (x**2 for x in range(1000000))
就是前面讲的圆括号写法。
两种方式选哪个?逻辑简单用表达式,逻辑复杂用函数。
进阶:链式生成器
生成器还有一个特别酷的用法------链式组合。
你可以把多个生成器串起来,每个只做一件事,清晰又高效:
python
def read_lines(path):
with open(path) as f:
for line in f:
yield line
def filter_error(lines):
for line in lines:
if 'ERROR' in line:
yield line
def extract_timestamp(lines):
for line in lines:
yield line.split('|')[0] # 假设时间戳在第一列
# 组合使用
lines = read_lines('log.txt')
errors = filter_error(lines)
timestamps = extract_timestamp(errors)
for ts in timestamps:
print(ts) # 每一步都是流式处理,内存占用始终最小
每一步都是惰性的,数据像流水一样经过一个个处理环节,内存里永远只有一条数据。
还有两种推导式也同理
列表推导式有内存问题,那字典推导式和集合推导式呢?
一样的。
ini
# 字典推导式------一次性生成全部
user_map = {u.id: u for u in get_all_users()} # 如果用户有百万级,内存直接爆炸
# 改为生成器配合字典构造
user_map = dict((u.id, u) for u in get_all_users()) # 稍微好一点,但还是存了全部
但注意:字典和集合本质上就是要存全部数据的。如果你需要字典或集合,那就没办法,内存占用是必须的。这时候能优化的是数据源部分------用生成器逐个产出数据,避免数据源本身再占一份内存。
一个实战中的取舍
回到开头那个故事。
2.8GB的日志文件,我后来是怎么处理的?
ini
def process_logs(file_path):
with open(file_path) as f:
# 生成器表达式:逐行读取
error_lines = (line for line in f if 'ERROR' in line)
# 生成器函数:解析每行
parsed = (parse_line(line) for line in error_lines)
# 又一个生成器:只取需要的时间段
filtered = (item for item in parsed if item['timestamp'] > cutoff)
# 最后汇总统计
stats = {}
for item in filtered:
stats[item['type']] = stats.get(item['type'], 0) + 1
return stats
整个流程中,内存里最多同时存在几行数据。2.8GB的文件处理完,内存峰值不到100MB。
服务器稳了,我也稳了。
一句话总结
列表推导式:爽,但贪婪。一次吃光所有数据。
生成器表达式:克制,但持久。吃一口消化一口。
数据小的时候,随便你用哪个,代码好看更重要。数据大的时候------
用生成器,是程序员最后的体面。
别让你的服务器,为一行代码殉情。