ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Python生成器实战:用yield流式处理超大文件,内存占用直降90%

Python生成器实战:用yield流式处理超大文件,内存占用直降90%

文章目录

    • 环境信息
    • 前言:800MB的CSV文件,直接 read 出来内存爆了
    • 一、生成器原理:`yield` 到底做了什么
    • 二、生成器表达式:一行写法的陷阱
    • 三、实战场景1:流式读取大CSV
    • 四、实战场景2:流式解析大JSON
    • 五、实战场景3:API分页拉取
    • 六、量化验证:生成器到底省了多少内存
    • 七、三个使用边界
    • 八、总结

环境信息

项目版本/说明
Python3.10+
标准库csv / json / itertools(无需第三方)
内存监控tracemalloc(标准库)
测试场景大CSV文件 / 大JSON文件 / 分页API

前言:800MB的CSV文件,直接 read 出来内存爆了

上个月我处理香港政府开放数据平台(data.gov.hk)的一个交通流量数据集——800MB 的 CSV,几百万行记录。

我的第一版代码是这样的:

importcsv# ❌ 危险写法:一次性把所有行读进内存withopen('traffic_flow_800mb.csv','r',encoding='utf-8')asf:rows=list(csv.reader(f))# 800MB 数据全部加载进内存print(f"加载了{len(rows)}行")# 然后内存就爆了——list 里存了几百万个 list 对象,实际内存占用远超 800MB

跑起来之后,机器直接卡死。因为list(csv.reader(f))会把每一行都转换成一个 Python list 对象,几百万个 list 的开销叠加起来,实际内存占用是文件大小的好几倍。

后来我把代码改成了生成器写法:

importcsv# ✅ 生成器写法:逐行处理,内存占用恒定defread_csv_rows(filepath):withopen(filepath,'r',encoding='utf-8')asf:reader=csv.reader(f)forrowinreader:yieldrow# 每次只返回一行,不积累fori,rowinenumerate(read_csv_rows('traffic_flow_800mb.csv')):# 处理这一行...ifi%100000==0:print(f"已处理{i}行")

同样的功能,内存占用从"文件大小的数倍"降到"恒定的几十KB"。这就是生成器的力量。

收藏提示①:处理大文件的第一原则——永远不要list()一个文件读取器。用yield逐行返回,内存占用恒定。文末有3个开箱即用的生成器实战。

一、生成器原理:yield到底做了什么

要理解为什么生成器省内存,先理解它和普通函数、普通列表的区别。

普通函数:调用后一次性执行完,用return返回结果,然后函数就"结束"了。

生成器函数:函数体里有yield关键字。调用它不会立即执行函数体,而是返回一个"生成器对象"。每次对这个生成器调用next(),函数才执行到下一个yield处,把yield后面的值"吐"出来,然后暂停

defsimple_generator():print("开始")yield1# 第一次 next() 执行到这里,返回1,暂停print("继续")yield2# 第二次 next() 执行到这里,返回2,暂停print("结束")# 第三次 next() 会抛出 StopIterationgen=simple_generator()print(next(gen))# 输出"开始",然后返回 1print(next(gen))# 输出"继续",然后返回 2print(next(gen))# 输出"结束",然后抛出 StopIteration

关键就在于"暂停"这两个字。生成器不会一次性把所有值算出来存着,而是"用到哪个,算哪个"。这就是惰性求值(lazy evaluation)

对比一下:

# 列表:一次性生成100万个数字,全部存内存nums_list=[iforiinrange(1000000)]# 内存占用:约 36MB(每个int对象28字节 × 100万)# 生成器:只是定义了一个"规则",不实际生成nums_gen=(iforiinrange(1000000))# 内存占用:约 100字节(就一个生成器对象)

同样是 100 万个数字,列表占 36MB,生成器只占 100 字节。这就是"内存直降 90%“的真正来源——不是魔法,是"不预先计算、用到才算”。

二、生成器表达式:一行写法的陷阱

注意上面nums_gen = (i for i in range(1000000))用的是圆括号,这是"生成器表达式"。而列表推导式用的是方括号

# 列表推导式(方括号)—— 立即生成,占内存squares_list=[x*xforxinrange(1000000)]# 生成器表达式(圆括号)—— 惰性,不占内存squares_gen=(x*xforxinrange(1000000))

一个常见的坑:生成器表达式只能迭代一次。因为它不是"存着结果",而是"边算边吐",吐完就没了:

gen=(x*xforxinrange(5))print(list(gen))# [0, 1, 4, 9, 16]print(list(gen))# [] —— 已经耗尽,第二次是空的!

如果你需要多次遍历,要么用列表,要么用itertools.tee,要么重新创建生成器。

三、实战场景1:流式读取大CSV

回到开头的大文件场景,把流式读取封装成一个可复用的生成器:

importcsvdefstream_csv(filepath,skip_header=True):""" 流式读取大CSV,逐行yield,不占内存 skip_header: 是否跳过表头行 """withopen(filepath,'r',encoding='utf-8')asf:reader=csv.DictReader(f)# DictReader 返回字典,键是表头forrowinreader:yieldrow# 使用:逐行处理,配合条件过滤forrowinstream_csv('traffic_flow_800mb.csv'):# 只处理屯门区的数据ifrow.get('district')=='屯門':process(row)

如果还要做"过滤 + 转换",可以链式组合生成器:

deffilter_by_district(rows,district):"""过滤生成器:只保留指定区"""forrowinrows:ifrow.get('district')==district:yieldrowdefextract_columns(rows,columns):"""转换生成器:只提取需要的列"""forrowinrows:yield{col:row.get(col)forcolincolumns}# 链式组合:三个生成器串成流水线all_rows=stream_csv('traffic_flow_800mb.csv')tuen_mun_rows=filter_by_district(all_rows,'屯門')cleaned_rows=extract_columns(tuen_mun_rows,['time','flow','station'])forrowincleaned_rows:print(row)

这就是"生成器流水线"——每一步都是惰性的,数据像水流一样从上一个生成器流到下一个,全程不积累。无论文件多大,内存占用恒定。

四、实战场景2:流式解析大JSON

JSON 比 CSV 麻烦,因为标准库的json.load()会把整个文件读进内存。但可以用ijson库(第三方)或者逐条解析 JSON Lines 格式:

importjsondefstream_jsonl(filepath):""" 流式读取 JSON Lines 格式(每行一个JSON对象) 香港政府部分API返回的就是这种格式 """withopen(filepath,'r',encoding='utf-8')asf:forlineinf:line=line.strip()ifline:# 跳过空行yieldjson.loads(line)# 使用forrecordinstream_jsonl('records.jsonl'):ifrecord.get('type')=='transaction':process(record)

收藏提示②:如果遇到的是单个超大 JSON 数组([{...},{...},...]),标准库json.load()会全量加载。要么让数据提供方改 JSON Lines 格式,要么用ijson库做增量解析。JSON Lines 是流式处理的友好格式,设计数据管道时优先选它。

五、实战场景3:API分页拉取

调用分页 API 时,生成器可以优雅地"隐藏分页逻辑":

importrequestsdeffetch_paginated(url,page_size=100,max_pages=None):""" 分页拉取API数据,逐页yield,对外表现为"一个连续的流" """page=1whileTrue:ifmax_pagesandpage>max_pages:breakresp=requests.get(url,params={'page':page,'size':page_size})data=resp.json()records=data.get('records',[])ifnotrecords:# 没有更多数据breakforrecordinrecords:yieldrecord page+=1# 使用:调用方完全不用关心分页逻辑forrecordinfetch_paginated('https://api.data.gov.hk/v1/records'):process(record)

六、量化验证:生成器到底省了多少内存

口说无凭,用tracemalloc实测一下(标准库自带的内存追踪工具):

importtracemallocdefprocess_list(n=1000000):"""列表方式:全量加载"""tracemalloc.start()data=[i*2foriinrange(n)]total=sum(data)current,peak=tracemalloc.get_traced_memory()tracemalloc.stop()returnpeak/1024/1024# 转MBdefprocess_generator(n=1000000):"""生成器方式:惰性处理"""tracemalloc.start()total=sum(i*2foriinrange(n))# 注意:这是生成器表达式current,peak=tracemalloc.get_traced_memory()tracemalloc.stop()returnpeak/1024/1024list_mb=process_list()gen_mb=process_generator()print(f"列表方式峰值内存:{list_mb:.1f}MB")print(f"生成器方式峰值内存:{gen_mb:.1f}MB")print(f"内存节省:{(1-gen_mb/list_mb)*100:.0f}%")

输出示例:

列表方式峰值内存: 36.2 MB 生成器方式峰值内存: 0.1 MB 内存节省: 99%

收藏提示③:生成器不是"更快",而是"更省内存"。在CPU上,生成器因为惰性调用有时反而略慢。它的核心价值是——让你能处理"大到放不进内存"的数据。如果数据量小到能全放内存,列表反而更简单直接。

七、三个使用边界

生成器不是银弹,有三个场景要谨慎:

  1. 需要随机访问:生成器只能顺序迭代,不能data[100]这样随机取。如果需要反复随机访问,还是用列表。

  2. 需要多次遍历:生成器耗尽就没了。如果需要"遍历两遍",要么存成列表,要么用itertools.tee,要么重建生成器。

  3. 数据量小:如果数据只有几百条,生成器和列表没区别,反而增加了代码复杂度。大文件才值得用生成器,小数据直接用列表。

八、总结

生成器的核心就一句话:惰性求值——用到哪个算哪个,不预先全部算出来。

  • 原理yield让函数"暂停"而不是"结束",配合next()逐步取值
  • 写法:生成器函数(yield)和生成器表达式(圆括号)两种
  • 价值:处理大文件/大JSON/分页API时,内存从"文件大小数倍"降到"恒定几十KB"
  • 边界:不能随机访问、只能遍历一次、小数据没必要用

这篇是 Python 进阶系列的第三篇——0814 写了正则和装饰器,这篇写生成器。三个主题的共同点:都是"写了很久 Python 但可能没真正理解"的基础进阶。正则解决"格式校验",装饰器解决"代码复用",生成器解决"内存瓶颈"。


本文为 Python 生成器技术分享。内存对比数据通过 tracemalloc 实测(不同环境数值略有差异,但"生成器省内存"的结论是确定的)。香港政府数据示例为演示场景,实际数据集以 data.gov.hk 为准。

返回列表