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图来展示生成器与迭代器的核心逻辑。你可以看到数据是如何“按需流动”的:

生成器与迭代器工作机制 数据源 日志文件 / 数据库 生成器函数 def load_logs(): yield line 迭代器对象 __next__() StopIteration 每次迭代调用 __next__() 消费端 for log in generator: 链式处理 filter → parse → clean 每个环节都是生成器 结果输出 逐条处理 内存始终低占用 核心思想:惰性求值,按需计算,数据流式处理 不生成中间列表,不占用额外内存

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%,就优先考虑生成器。这个习惯帮我避免了好几次线上事故。

记住一句话:别让数据等你,让数据流起来。

💡 课后小练习:找一份你手头最大的日志文件,用列表和生成器两种方式分别读取,对比一下内存占用。你会被结果吓一跳的。

可可精品资料库