☰
大CSV内存爆炸?Pandas分块读取与内存优化实战指南
2026/10/19 2:32:17 网站建设 项目流程

先别急着加内存条。10GB的CSV文件用Pandas一读就内存爆炸,这个问题我切身踩过,而且比你想得更普遍:别说是10GB,有些场景下4~5GB的CSV都能把32GB内存的机器吃得干干净净。今天就把这套东西从头到尾掰开讲清楚,从内存为什么会炸,到哪些方案真的有性价比,再到可以直接抄走的处理流程,一次说透。

处理10GB以上的CSV,很多人第一反应是“换台大内存机器”,但换个角度想:你的数据真的需要全部一次性驻留在内存里吗?大多数场景下,做数据分析、特征提取、统计聚合,根本不需要把整个文件加载成一个超大DataFrame。把“一次性加载”的思路改成“读取一批、处理一批、放下一批”,内存占用立刻就从几十GB变成了几百MB。这个思路说起来简单,落地却有一堆细节,下面一个一个说。

1. 为什么Pandas读10GB文件内存会直接爆掉

1.1 内存爆炸的第一现场:你以为只用了10GB

很多人第一次遇到OOM(Out Of Memory)时都很懵:文件不才10GB吗,机器有32GB内存,怎么读个文件就爆了?

这里要澄清一个核心事实:Pandas把CSV读进内存,占用的内存远不止文件在磁盘上的大小。CSV文件是纯文本,磁盘上记录的是字符串形式的字节;而Pandas加载后,要变成DataFrame里的各种数据类型。这个从“文本字节”到“内存对象”的转换过程,内存开销通常是原始文件大小的3到8倍,具体倍数取决于列的类型。

我实测过一个典型数据:CSV文件6.8GB,3000万行、128列,其中字符串列有47列,用默认参数read_csv一次性读取,Pandas进程峰值内存逼近42GB。为什么?因为字符串列在默认情况下会被推断成object类型,而object类型的背后是Python对象指针数组,每个元素都是一个独立的PyObject,再加上字符串本身的缓存、Pandas内部索引的开销,膨胀幅度远超你的直觉。

1.2 内存开销的计算:为什么10GB文件能吃掉40GB内存

来看一个非常粗略但很实用的估算方式:

  • 整数列(int64):每行8字节,一列1000万行就是80MB。
  • 浮点列(float64):同样每行8字节。
  • 字符串列(object):实际开销不是字符串长度的总和,而是“Python对象”本身的开销。每个Python字符串对象在64位平台上最小都要49字节,再加上Pandas的object数组里存放的8字节指针,以及字符串本身的堆分配,一个平均长度为20字节的字符串,实际内存开销可达到70~100字节,是原始文本长度的4~5倍。
  • 另外,read_csv在解析过程中还会产生临时缓冲区和中间结构,这部分往往又会额外吃掉1~2GB。

我见过一种特别夸张的case:一个只有2GB的CSV,因为里面有一列特别长的JSON字符串,读进来后内存直接飙到26GB。所以千万不要用“文件大小”去判断内存需求。

1.3 最容易被忽略的凶手:dtype推断的过程

read_csv默认会“采样”一部分行来进行类型推断,然后决定每一列的dtype。这个机制的坏处有两层:

第一,推断错误。有些列明明大部分是数字,但中间混了几个空值或字符串,Pandas就把整列推断成object。果你后面做数值运算,又要astype(转换数据类型),转换过程大概率产生副本,内存需求瞬间翻倍。

第二,推断阶段本身就要加载一部分数据到内存里,而且为了推断准确,Pandas可能读入远超你预期的行数。当你手动设置了dtype之后,就等于掐断了这条反复推断的路径,内存和耗时都会好很多。关于这一点,后面第四节会给出具体的写法。

2. 从源头减负:真正让数据在内存里“瘦身”

在考虑换Dask、换Polars之前,先把最基础、成本最低的方案做掉,很多时候做完这些就够用了。这个顺序很重要:不要一上来就换框架,而要先把你手上的Pandas用对。

2.1 列裁剪:只读你需要的那几列

CSV里往往有大量“备用”字段,比如埋点日志里的UA、Cookie、冗长的JSON串。如果你只是分析其中的20列,为什么要读全部128列?

read_csv支持一个参数叫usecols,可以是列名列表,也可以是列索引列表。加上之后,没有被用到的列根本不会被解析,也就不会进入内存。这一个参数,就能把内存占用砍掉一半以上。

import pandas as pd # 只读取需要的列,而不是全部128列 usecols = ["user_id", "action_time", "action_type", "cost", "region"] df = pd.read_csv("huge_log.csv", usecols=usecols)

我第一次用这个参数处理一个日志文件时,内存直接从30GB掉到了9GB。因为那128列里面有大量长文本列,我在前面说的“2GB文件吃了26GB内存”的案例,本质上也是同一类问题。所以不管后面还要做什么优化,usecols永远是你应该第一个动用的武器。

2.2 dtype预指定:把类型决定权掌握在自己手里

不要指望Pandas帮你推断。读10GB文件之前,先花一两分钟看看数据结构,然后手动指定每一列的dtype,直接跳过推断阶段。

import pandas as pd dtype_spec = { "user_id": "int64", "cost": "float32", "action_type": "category", "region": "category", "raw_status": "int8", } df = pd.read_csv("huge_log.csv", usecols=["user_id", "cost", "action_type", "region", "raw_status"], dtype=dtype_spec)

这里为什么敢用float32而不是float64?因为金融计算如果需要高精度,float32确实有精度问题;但如果只是算个均值、分布,float32足以,内存直接减半。类似的还有int8、int16,数据取值范围足够小时可以放心用。比如状态码、星期几、年龄段,这种列取值范围小到爆,int8绰绰有余。

2.3 category类型:把重复字符串变成整数编号

文本列是内存杀手。但很多文本列的重复值往往特别少,比如“省份”“城市”“操作系统类型”“渠道来源”,去重之后可能只有几十个值。这种数据,完全适合category类型。

Pandas的category类型,内部实现是两个数组:一个是分类编码(整数),另一个是类别列表(唯一值)。你用“省份”列,3000万行里其实只有31个省份,那这3000万行的原始字符串全存下来就是一种浪费,转成category后,每个值只是一个小整数,真正省下的内存可以达到百倍级别。

# 读取时直接指定category df = pd.read_csv("huge_log.csv", usecols=["province"], dtype={"province": "category"}) # 如果已经是读进来之后才想到优化,也可以这样转换 df["province"] = df["province"].astype("category")

不过这里有一个反直觉的点:category不是什么时候都快。字符串列做等于比较、分组聚合、排序时,category因为背后是整数编码会非常快;但如果要做字符串切片、正则匹配,反而可能要解压编码,性能还会下降。所以在设计dtype时,要想清楚后续会对这一列做什么操作。

2.4 parse_dates的正确打开方式

很多人喜欢把时间列交给parse_dates参数去自动解析。这个参数确实方便,但你得知道它的开销:解析时间字符串时,Pandas默认会把中间结果构造成Python的datetime对象,然后再统一转成datetime64数组。这个中间过程产生的临时对象数量巨大,峰值内存很容易被推高。

我的建议是:能用整数就不要用字符串表示时间,能用datetime64就不要用object。更实用的做法是,先用字符串类型把时间列读进来,后续需要时再统一转换:

df = pd.read_csv("huge_log.csv", dtype={"action_time": "string"}) df["action_time"] = pd.to_datetime(df["action_time"], format="%Y-%m-%d %H:%M:%S", errors="coerce")

注意,指定format非常重要。Pandas解析日期时如果不给格式,会自己尝试多种格式解析,既慢又吃内存。给了精确格式之后,解析效率会高出一大截。如果日志时间是时间戳(Unix时间戳),那就更简单了,读成int64,需要展示的时候再转,完全不增加复杂度。

3. 分块读取:用“窗口”思路处理超大数据

如果你已经做了列裁剪和dtype优化,但数据行数实在太大,比如一亿行,那么就算每行只要56字节,整体也要5.6GB。这时候就要上分块读取,这是Pandas官方给出的标准解法,也是我实际工作中用得最多的方案。

3.1 chunksize到底怎么设才合理

read_csv有个chunksize参数,设置之后返回的就不是一个DataFrame,而是一个TextFileReader迭代器,每次迭代返回指定行数的DataFrame。这样每一时刻内存里最多只存在一个块的数据。

import pandas as pd chunk_iter = pd.read_csv( "huge_log.csv", usecols=["user_id", "cost", "action_type", "province"], dtype={"user_id": "int64", "cost": "float32", "action_type": "category", "province": "category"}, chunksize=500000, )

chunksize设多大合适?没有固定答案,但有一个安全基准:让每个块的大小控制在总内存的5%~10%以内。如果你的机器有32GB内存,单块控制在1~2GB比较稳妥;如果机器只有8GB,那就把chunksize设到10万~20万行。

另一个更精确的办法是估一列的行内存大小,然后用块行数乘以单行字节数得到块内存。怎么估单行字节数?可以先把一小部分数据读进来,用df.memory_usage(deep=True).sum()除以行数。比如算出单行200字节,那么50万行的块就是100MB,即使乘以解析过程中的临时开销倍数,也完全可以接受。

3.2 分块做统计聚合:一个能直接抄的模板

分块不是简单地把文件拆掉就完事了,难点在于“怎么把分块的结果合起来”。下面以一个按省份统计总花费的例子来说明:

import pandas as pd from collections import defaultdict chunk_iter = pd.read_csv( "huge_log.csv", usecols=["province", "cost"], dtype={"province": "category", "cost": "float32"}, chunksize=500000, ) agg_sum = defaultdict(float) agg_count = defaultdict(int) for chunk in chunk_iter: grouped = chunk.groupby("province", observed=True)["cost"].agg(["sum", "count"]) for province, row in grouped.iterrows(): agg_sum[province] += row["sum"] agg_count[province] += row["count"] result = pd.DataFrame({ "total_cost": pd.Series(agg_sum), "total_count": pd.Series(agg_count), }) result["avg_cost"] = result["total_cost"] / result["total_count"]

这个模板的本质是:每一块先做局部聚合,再把局部结果合并成全局结果。如果你对某个场景直接.append所有的块,那跟一次性读取没有区别,内存照样爆炸。所有分块方案都要遵循“局部处理、部分结果合并”这一原则。

3.3 边读边处理边写出的组合拳

很多时候,大文件处理的终点是“清洗出一个小结果文件”,而不是带一个大DataFrame继续跑分析。这个时候我强烈推荐“边读边写”的模式:分块读入、逐块清洗、逐块追加写出(写出到CSV、Parquet或SQLite),最终文件很小,后续操作什么机器都跑得动。

import pandas as pd chunk_iter = pd.read_csv( "huge_log.csv", usecols=["user_id", "action_type", "cost", "province", "action_time"], dtype={"user_id": "int64", "action_type": "category", "cost": "float32", "province": "category"}, parse_dates=["action_time"], # 这里如果格式固定,推荐用自定义解析 chunksize=500000, ) # 第一次写出表头 first = True for chunk in chunk_iter: # 过滤掉无效数据 chunk = chunk.dropna(subset=["user_id", "cost"]) chunk = chunk[chunk["cost"] > 0] # 新增一个派生字段 chunk["cost_rounded"] = chunk["cost"].round(2) # 追加写出,只保留需要的列 chunk[["user_id", "action_type", "province", "cost_rounded"]].to_csv( "cleaned_output.csv", mode="w" if first else "a", header=first, index=False, ) first = False

有个细节很值得说:多次to_csv追加时,第二次以后一定要header=False,mode用a。我见过不少人在这个位置犯迷糊,跑完之后发现文件里全是表头。如果你觉得CSV的追加不够稳定,也可以直接改用Parquet分区或者SQLite,后面在第4节会展开。

3.4 分块时千万不要做的事

分块方案里最容易踩的坑,是在循环里反复构造结果DataFrame。比如这样做:

result = pd.DataFrame() for chunk in chunk_iter: result = pd.concat([result, chunk], ignore_index=True) # 这样写很危险

pd.concat每次都是全部拷贝,循环次数越多,内存膨胀越厉害。到几十个块之后,性能会断崖式下跌。如果你真的需要把所有数据放到一个DataFrame里,那说明你的场景不适合分块,应该考虑换工具。

4. 换引擎还是换工具:Dask、Polars、DuckDB怎么选

如果优化完dtype、裁剪完列、分完块之后,还是觉得麻烦,或者你需要的操作很难用分块来完成(比如复杂的表连接、窗口函数),那就轮到换工具了。下面这几个工具我都实际用过,说一些主观但真实的评价。

4.1 方案对比速览

方案学习成本内存占用性能特点适用场景
Pandas + chunksize低可控制在MB级单块内快,块间合并麻烦简单清洗、聚合、过滤
Dask DataFrame低到中惰性计算,内存可控任务调度有开销,小数据反而慢超大DataFrame、复杂query、需要和Pandas代码兼容
Polars中低,极致并行Rust内核,速度通常比Pandas快数倍追求速度、内存可控、复杂数据操作
DuckDB + SQL低查询引擎自动管理CSV读取极快,SQL表达复杂逻辑方便超大CSV直接SQL查询、聚合、Join

4.2 Dask:把Pandas变成“懒加载”DataFrame

Dask DataFrame模拟了Pandas的API,但它不把整个DataFrame加载进内存,而是把数据划分成很多分区,只在需要时计算。它的代码写法和Pandas非常接近:

import dask.dataframe as dd ddf = dd.read_csv( "huge_log.csv", usecols=["user_id", "cost", "province"], dtype={"user_id": "int64", "cost": "float32", "province": "category"}, blocksize="256MB", # 每个分区大小,Dask会自动划分 ) # 构建查询逻辑,这一步不会真正执行 result = ddf.groupby("province")["cost"].sum().compute() # compute()才真正执行

Dask的学习曲线比Pandas要陡一点,但也不算多难。最需要注意的是它的“惰性计算”机制:写代码的时候只是搭了一个计算图,调用compute()才会真的执行。如果你忘了这回事,会看不懂为什么代码不报错也不出结果。

我个人的使用体验是:如果只是读个CSV做个聚合,Dask的优势不是特别明显,因为它有分区调度的开销。但如果你要对超大DataFrame做多次过滤、Join、窗口操作,Dask能帮你把这些写成一套逻辑,不需要自己动手拆分合并。

4.3 Polars:性能确实猛,但需要适应另一种思维

Polars是近年很火的选择,Rust写的底层,利用多核并行和列式存储,读取CSV时速度和内存表现都相当能打。它的设计理念和Pandas有明显区别:默认就是懒执行模式(LazyFrame),你只需要描述“要干什么”,引擎自己去优化执行计划。

import polars as pl result = ( pl.scan_csv("huge_log.csv", try_parse_dates=True) .filter(pl.col("cost") > 0) .group_by("province") .agg(pl.col("cost").sum()) .collect() # 触发计算 )

Polars最大的问题是:如果你习惯了Pandas的链式操作和索引思维,刚上手会有点别扭。但如果你处理的数据量级到了“Pandas分块写起来太丑”的程度,Polars的这套表达式API反而更简洁。它的内存效率高,三个原因:列式存储、原生并行、没有Pandas那种对象数组的包袱。

4.4 DuckDB + SQL:被很多人低估的“懒人方案”

最后说一个我私心非常推荐的工具:DuckDB。它是嵌入式OLAP数据库,但你完全可以直接把它当“CSV查询引擎”来用。不需要导入数据,不需要建表,直接SQL查询CSV文件。

import duckdb result = duckdb.query(""" SELECT province, SUM(cost) AS total_cost, COUNT(*) AS cnt FROM 'huge_log.csv' WHERE cost > 0 GROUP BY province """).df()

DuckDB读取CSV是并行+列式处理,内存和速度都甩开Pandas一截。更重要的是,表达复杂逻辑时SQL比手动分块合并要省心得多,甚至可以直接写窗口函数、多表Join、复杂的过滤条件,不需要你操心中间怎么拆。

DuckDB的适用场景非常广,但有一点要注意:它更像“数据库引擎”,而不是DataFrame操作工具。如果你要做的是像Pandas那样一个单元格一个单元格地写自定义函数,或者做机器学习特征工程,那它不如Polars和Dask灵活。但它作为“大CSV快速查询、聚合、仪表盘数据源”的角色,绝对是性价比最高的选择。

5. 我的完整处理流程:10GB CSV实战方案与参数配置

上面工具讲了不少,现在把整个处理流程串成一个连贯的、可以直接参考的实战套路。这个流程我在多个数据集上验证过,也是我个人面对超大CSV时的标准动作。

5.1 第一步:先侦察再动手,先看“敌情”

不要拿到文件就开始写pd.read_csv。先做两件小事:

第一,用系统命令看文件大小和行数估算。

du -h huge_log.csv wc -l huge_log.csv

第二,只读前几千行,把字段列表和大致类型摸清楚。

preview = pd.read_csv("huge_log.csv", nrows=5000) print(preview.shape) print(preview.dtypes) print(preview.nunique()) # 看看每列的唯一值数量,决定哪些可以转category

这一步的目的就是搞清楚:到底有多少列?哪些是长文本?哪些是低基数字段?哪些是纯数值列?这决定了后面的dtype设计和列裁剪策略。

5.2 第二步:按数据形态选择路线

摸完敌情之后,我会按下面这个逻辑选路线:

  • 如果文件在10GB左右,且我要做的操作是聚合、过滤、清洗,优先试pd.read_csv + chunksize。因为写起来最直接,也不引入新依赖。
  • 如果文件更大(50GB+),或者操作非常复杂(多次Join、窗口函数),直接上DuckDB,SQL一把梭。
  • 如果后续要进入机器学习流程,又要频繁做特征工程,我会用Polars,因为它的DataFrame模型比Dask更顺手。
  • Dask更适合在已有Pandas代码基础上做“无痛扩容”,团队已经全是Pandas写法时才会优先选它。

5.3 第三步:套用一份可抄的代码模板

下面这份模板是我处理10GB左右CSV的默认起点,兼顾了内存和控制力:

import pandas as pd # 1) 从preview阶段确定的字段 usecols = ["user_id", "province", "cost", "action_type", "action_time"] # 2) 手动指定dtype,跳过类型推断 dtype_spec = { "user_id": "int64", "province": "category", "action_type": "category", "cost": "float32", } # 3) 高基数时间列parse_dates用format解析,避免自动推断的开销 # 注意:不是所有的列都需要转成datetime,真正需要时才转 # 4) 分块读取 chunk_iter = pd.read_csv( "huge_log.csv", usecols=usecols, dtype=dtype_spec, parse_dates=["action_time"], chunksize=500000, low_memory=False, # 确保全文件解析时使用相同的dtype规则 ) # 5) 逐块处理:过滤 -> 聚合 -> 合并结果 from collections import defaultdict agg = defaultdict(lambda: {"sum": 0.0, "count": 0}) for chunk in chunk_iter: chunk = chunk[chunk["cost"] > 0] grouped = chunk.groupby("province", observed=True)["cost"].agg(["sum", "count"]) for province, row in grouped.iterrows(): agg[province]["sum"] += row["sum"] agg[province]["count"] += row["count"] stat_df = pd.DataFrame(agg).T stat_df["avg_cost"] = stat_df["sum"] / stat_df["count"]

这里解释一下low_memory=False:Pandas默认在读取大文件时会按块推断dtype,导致同一列在不同块的dtype可能不同,混在一起会引起类型混乱或性能问题。显式指定low_memory=False后,Pandas会读完整个文件再统一处理,低版本上会更稳定。不过现在新版Pandas的默认行为已经比较合理,这个参数依然建议写上,至少不产生额外负担。

5.4 处理完之后:避免二次写出时的内存反弹

很多人读完大CSV没爆,结果一个to_csv反而爆了。原因也很简单:to_csv之前你可能做了一堆中间操作,生成了好几个中间DataFrame副本。比如你把df拷贝了一份、又构造了一个新列,内存早已是两份数据。

我的建议是:所有处理都保持在“分块→处理→合并小结果”的模式里,最终的结果如果不大,再一次性写出来。如果你确实需要把中间结果持久化,推荐直接写Parquet而不是CSV:

import pandas as pd chunk_iter = pd.read_csv("huge_log.csv", usecols=usecols, dtype=dtype_spec, chunksize=500000) # 把每块写成Parquet文件(这里用的是pyarrow引擎) for i, chunk in enumerate(chunk_iter): chunk.to_parquet(f"output_part_{i}.parquet", engine="pyarrow")

Parquet是列式压缩存储,后续读取时还能保留dtype信息,比CSV更适合作为中间格式。而且下次用Pandas/Dask/Polars读Parquet时,类型不再需要重新推断,速度和内存都更友好。如果你没装pyarrow,记得先pip install pyarrow。

6. 踩过的几个坑,以及我最后的建议

6.1 分块时表头重复写出

这是我在“边读边写”时最常碰到的问题。上面代码里用first变量控制表头,算是标准答案了。但如果你在手动脚本里忘了这一步,结果文件里会出现很多次表头行,后续再读取时会把这些表头当成真实数据。更为稳妥的办法是:先创建空文件写一次表头,后面循环里只追加数据。

with open("output.csv", "w") as f: f.write("user_id,province,cost\n") for chunk in chunk_iter: chunk.to_csv("output.csv", mode="a", header=False, index=False)

6.2 编码和分隔符的坑

大CSV不一定都是UTF-8。处理不规范的数据(比如从某些业务系统导出的GBK编码文件)时,encoding参数如果不写对,分块读取时错误不会在开头出现,而是会在读到中间某行时突然崩溃。更隐蔽的是,文件里某些字段自带换行符或者逗号,read_csv默认的quotechar处理不了时,会导致列错位。

遇到这种文件,建议第一步就用Python的csv模块配合errors="replace"做一次“脱敏”,把脏字符替换掉,再走Pandas流程。虽然多了一步,但能避免半夜跑批时崩溃。

import csv with open("huge_log.csv", "r", encoding="utf-8", errors="replace") as fin: with open("cleaned_log.csv", "w", encoding="utf-8", newline="") as fout: reader = csv.reader(fin) writer = csv.writer(fout) for row in reader: writer.writerow(row)

6.3 一个反直觉的结论:有时Pandas分块比Dask更好用

用了这么多工具之后,我的整体心得是:不要神化任何工具,也不要妖魔化Pandas。很多号称“处理十亿行数据”的框架,在小数据上反而比不过老老实实的Pandas分块。因为Pandas对单机小批数据的处理性能已经优化到了极致,它的瓶颈从来都是内存而不是速度;分块读取就是把内存瓶颈转化成时间成本。当你把块大小调到合适范围时,Pandas处理10GB CSV的耗时和内存,完全不输给Dask,甚至更稳定。

如果你问我现在处理一个10GB以上的CSV第一选择是什么,我会回答:

  • 先做侦察,看字段、估大小。
  • 再做dtype和usecols优化,能做掉多少是多少。
  • 然后上chunksize,按“局部聚合、全局合并”的方式跑完。
  • 如果中间环节用SQL表达更简单,再请DuckDB出来兜底。
  • 最终结果尽量输出成Parquet,而不是再写回一个更大的CSV。

这套流程我已经重复用过很多次,从几GB到上百GB的文件都扛过来了,内存占用很少超过2GB。问题的关键从来不是“你有多大内存”,而是你有没有把不需要留在内存里的数据及时放下。希望这篇东西能帮你把内存从悬崖边拉回来,下次再遇到超大CSV时,至少能有个清晰的路线图,而不是一上来就加内存条。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询