列表推导式一用就爽?大数据量下它把我服务器内存榨干了,原来生成器才是yyds

那天,服务器报警了

周五下午三点半,我正准备收拾东西下班。

运维同事在群里发了一条消息,配了一张截图:"线上内存使用率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)]

背后发生的是:

  1. Python先创建一个空列表
  2. 开始循环,每次计算 x**2
  3. 把计算结果一个一个塞进列表里
  4. 直到循环结束

这个过程看起来没问题对不对?问题就在于第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

服务器稳了,我也稳了。

一句话总结

列表推导式:爽,但贪婪。一次吃光所有数据。

生成器表达式:克制,但持久。吃一口消化一口。

数据小的时候,随便你用哪个,代码好看更重要。数据大的时候------

用生成器,是程序员最后的体面。

别让你的服务器,为一行代码殉情。

相关推荐
Slice_cy1 小时前
Mint 自研框架设计与实现:从重复开发走向配置驱动(二)
前端·后端·架构
程序员cxuan1 小时前
OpenAI 把 Hugging Face 打穿了,然后 GLM 5.2 当了救火队长?
人工智能·后端·程序员
程序员天天困2 小时前
Arthas Profiler 火焰图实战:CPU 热点在哪一目了然
java·jvm·后端
今夜有雨.2 小时前
C++JSON 解析器
c++·笔记·后端·学习·json
AskHarries2 小时前
日志系统怎么搭
后端
swipe2 小时前
09|(前端转全栈)商品为什么不能随便上下架?后端状态机思维入门
前端·后端·全栈
zhangjw343 小时前
第36篇:Spring Boot进阶:Web开发+参数校验+全局异常处理
前端·spring boot·后端
倒流时光三十年3 小时前
第一阶段 02 · Mapping 与数据类型(text vs keyword 是重点)
后端·python·django
Zane19943 小时前
JMM 与 happens-before:一次搞懂 Java 内存模型
java·后端