3. 生成器与迭代器:用yield替代列表存储,处理海量日志时的内存节省实战
3.1 一个让我头疼的日志处理场景
先说说我自己的经历吧。
去年我接手过一个日志分析系统。每天要处理几十GB的Nginx访问日志。原来的代码长什么样呢?简单说就是——先把所有日志行读进一个列表,然后再逐行分析。
你想想看,一个列表里塞了几千万条字符串。每条字符串平均200字节。算下来光存储这些日志行,内存就占了几个GB。更可怕的是,这只是中间数据。后面还要做解析、聚合、统计……内存直接爆了。
我当时的第一反应是:这代码肯定不能这么写。
嗯,这就是我们今天要聊的核心问题——如何用生成器替代列表,大幅降低内存占用。
3.2 问题本质:列表存储 vs 惰性求值
先看一段典型的“反面教材”:
def load_logs(file_path):
logs = []
with open(file_path, 'r') as f:
for line in f:
logs.append(line.strip())
return logs
# 使用时
all_logs = load_logs('access.log')
for log in all_logs:
process(log)
这段代码有什么问题?
- 一次性加载:所有日志行都塞进内存
- 内存爆炸:文件越大,内存占用越高
- 响应慢:必须等全部加载完才能开始处理
说白了,这就是典型的“急不可耐”模式。数据还没用上,内存先扛不住了。
3.3 用yield改写:生成器登场
我当时的做法很简单——把return改成yield:
def load_logs_generator(file_path):
with open(file_path, 'r') as f:
for line in f:
yield line.strip()
# 使用时
for log in load_logs_generator('access.log'):
process(log)
改动就一行。但效果天差地别。
为什么?
因为生成器是惰性求值的。它不会一次性生成所有数据。而是每次迭代时,才计算下一个值。说白了,就是“用多少,算多少”。
我习惯把生成器想象成一条流水线。数据像零件一样,一个一个往下传。而不是先把所有零件堆在仓库里,再统一搬运。
3.4 内存对比:数据会说话
光说理论不够。我们直接看数据。
假设一个日志文件有1000万行,每行平均200字节:
| 方案 | 内存占用 | 启动时间 | 处理完释放 |
|---|---|---|---|
| 列表存储 | ~2 GB | 慢(需全部加载) | 处理完才释放 |
| 生成器 | ~几 KB | 瞬间 | 每行用完即释放 |
看到了吗?内存占用从GB级降到了KB级。这不是优化,这是质变。
我曾经在一个生产环境里做过实测。同样的日志处理任务,用列表方案跑了不到10分钟,内存就飙到3.8GB,然后OOM了。改成生成器后,内存稳定在50MB以内,跑完整个任务只用了12分钟。
嗯,那次之后,我团队里再没人敢用列表存海量数据了。
3.5 进阶:生成器链式处理
生成器还有一个特别实用的玩法——链式组合。
比如我们要处理日志,需要经过:过滤 → 解析 → 清洗 → 统计。每个步骤都可以写成一个生成器函数:
def filter_errors(logs):
for log in logs:
if 'ERROR' in log:
yield log
def parse_log(logs):
for log in logs:
yield {
'level': log.split()[0],
'time': log.split()[1],
'msg': ' '.join(log.split()[2:])
}
def clean_data(parsed_logs):
for item in parsed_logs:
if item['msg']:
yield item
# 链式调用
raw_logs = load_logs_generator('app.log')
error_logs = filter_errors(raw_logs)
parsed_logs = parse_log(error_logs)
clean_logs = clean_data(parsed_logs)
for log in clean_logs:
print(log)
你看,每个环节都是独立的生成器。数据像水流一样,从源头流经每个处理节点。整个过程零中间列表,内存占用始终极低。
我个人习惯把这种模式叫做“数据管道”。它特别适合处理流式数据、海量日志、实时计算等场景。
3.6 避坑指南:我曾经踩过的坑
生成器虽好,但也不是银弹。我分享几个自己踩过的坑:
⚠️ 坑1:生成器只能迭代一次
我曾经写过一个bug:把一个生成器传给两个函数处理。结果第二个函数拿到的数据是空的。因为生成器已经被消费完了。
解决办法:如果需要多次使用,要么用list()转成列表(但会失去内存优势),要么重新创建生成器。
⚠️ 坑2:不要在生成器内部做耗时操作
生成器是惰性的。如果你在生成器内部做了数据库查询或网络请求,那每次迭代都会触发一次。我曾经因此把API调用次数从1次变成了100万次……
解决办法:把耗时操作移到生成器外部,或者用缓存机制。
⚠️ 坑3:小心递归生成器
如果你在生成器里递归调用自身,很容易造成栈溢出。因为生成器的递归深度是有限制的。
解决办法:改用迭代方式实现,或者用yield from语法。
3.7 核心逻辑图:生成器与迭代器的工作机制
下面我用一张SVG图来展示生成器与迭代器的核心逻辑。你可以看到数据是如何“按需流动”的:
3.8 什么时候该用生成器?
我总结了一个简单的判断标准:
✅ 适合用生成器的场景
- 数据量很大,无法一次性加载到内存
- 数据是流式产生的(如日志、传感器数据)
- 只需要遍历一次,不需要随机访问
- 处理逻辑可以拆分成多个独立步骤
❌ 不适合用生成器的场景
- 需要多次遍历同一份数据
- 需要随机访问(如索引取值)
- 数据量很小,列表完全够用
- 需要频繁修改数据(生成器不可变)
3.9 一个小技巧:用itertools组合生成器
Python标准库里的itertools模块,是生成器的最佳拍档。我经常用它来组合各种生成器操作。
比如,你想从日志里取前100条错误日志:
from itertools import islice
def error_logs(logs):
for log in logs:
if 'ERROR' in log:
yield log
# 只取前100条
first_100_errors = islice(error_logs(all_logs), 100)
for log in first_100_errors:
print(log)
再比如,你想把多个日志文件合并成一个流:
from itertools import chain
def multi_file_generator(files):
return chain.from_iterable(load_logs_generator(f) for f in files)
# 使用
all_logs = multi_file_generator(['log1.txt', 'log2.txt', 'log3.txt'])
这些工具函数,说白了就是帮你省去写重复代码的功夫。我建议你花半小时看看itertools的文档,绝对物超所值。
3.10 总结
生成器和迭代器,是Python里处理海量数据的利器。它们不炫技,但很实用。
我个人的经验是:只要数据量可能超过内存的10%,就优先考虑生成器。这个习惯帮我避免了好几次线上事故。
记住一句话:别让数据等你,让数据流起来。
💡 课后小练习:找一份你手头最大的日志文件,用列表和生成器两种方式分别读取,对比一下内存占用。你会被结果吓一跳的。
可可精品资料库