30GB CSV 秒变 3GB Parquet:列式存储与压缩技术实战
2026/9/14 19:55:24 网站建设 项目流程

收到一份 30GB 的 CSV 时,我的第一反应不是“这文件里有多少数据”,而是“打开它得先换个电脑”。当时我把文件丢进 Excel,看它转了五分钟还在转圈,然后就放弃了。接着用 Pandas 去读,等了十几分钟直接抛了个MemoryError,那一刻真的有点想砸机器。

这种超大 CSV 大多出现在这些场景:数据库导出的历史订单、IoT 设备的采集日志、第三方系统提供的批量数据,或者爬虫攒了几年的原始数据。它们的共同点是:行数多、口径杂、谁都不想给你接口,最后给个 CSV 让你自己处理。问题在于,CSV 这个格式本身就是为了“让人能打开看看”设计的,不是给程序高效读取设计的。30GB 只是个开关,真正麻烦的是它背后那串连锁反应。

1. 30GB的CSV,真正让人崩溃的不是“大”

1.1 文件大只是表象,真正的问题是“文本”占满了所有环节

CSV 里存的全是文本。数字是文本,日期是文本,时间也是文本,连一个空值都可能写成四个字符的NULL。我们平时觉得几十 KB 的 CSV 没问题,那是因为文本的可读性确实强,一旦数据量上来,文本存储的劣势就完全暴露了。

举个例子,一个整数 123456 在 CSV 里要占 6 个字节(一个字符一个字节),但在内存里用 int32 表示只要 4 个字节,用 int64 是 8 个字节。看似差距不大,但考虑到 CSV 还要加分隔符、换行符,以及日期时间这种动辄十几个字符的文本,整体占用膨胀到 2 到 5 倍很常见。更关键的是,程序读 CSV 时要把这些文本一个个解析成对应的数字、日期、布尔值,这个过程非常耗 CPU。所以 30GB 的 CSV 加载进内存,实际占用往往超过 60GB,再加上解析时间,普通笔记本根本扛不住。

1.2 加载比同体积二进制慢几十倍,卡死不是偶然

有一次我做个小实验:同样一份数据,一份导出成 CSV,30GB;一份用列式存储导出,压缩后只有 3GB 左右。用脚本读取 CSV 全量加载,耗时约 4 分钟,内存峰值 60 多 GB,最后还差点 OOM;读取列式格式,只花了不到 30 秒,内存峰值不到 5GB。这差距不是“快一点点”,是数量级上的碾压。

原因在解析逻辑上。CSV 是逐行逐字段处理的:先按换行符切块,再按分隔符切列,然后对每个字段做类型推断、字符串转换,遇到引号包裹的字段还得处理转义。这一整套流程下来,CPU 一直在忙,内存一直在涨。二进制格式就简单得多:按固定类型和长度直接读入,几乎不需要解析。

所以我想明白了一件事:面对 30GB 的 CSV,纠结内存不够、电脑不行,方向就错了。该换的不是机器,是存储格式。

2. 换格式省90%空间的底层逻辑:文本变列式

标题里说“换个格式只要 3GB”,有人可能会觉得夸张。实际上只要你选的格式对,压缩到十分之一是很常见的结果,甚至某些数据能压到 1GB 以内。核心原因就一句话:把“给人看的文本”换成“给机器算的列式二进制”。

CSV 是行式存储,一条记录的所有字段物理上挨在一起。Parquet 这类格式是列式存储,一列的数据物理上放在一起。别看只是排布方式变了,它数据结构上多了两个杀手锏:列的类型化存储和列级压缩编码。

2.1 一个具体的例子:1000 万行订单数据怎么省下来的

假设有一张订单表,字段是订单号、用户 ID、金额、状态、创建时间。CSV 里一行大概是这样的:

A100001, U888, 128.50, SUCCESS, 2024-01-15 10:03:21

保存下来一条记录占 40 多个字节,1000 万行就是 400MB 左右。换成 Parquet 之后,“金额”这一列会统一转成 float64 或 decimal,“状态”这种取值只有几种的字段会自动做字典编码,把“SUCCESS”“FAILED”“PENDING”这些重复字符串映射成短整数,“用户 ID”这种重复率高的列也会走压缩。结果就是同样 1000 万行数据,Parquet 只剩 60MB 上下。

数值列还有更进一步的压缩手段,比如行程编码、位打包。一个交易金额字段如果波动不大,zstd 压缩后体积甚至可以再砍一半。30GB 的 CSV 转成 Parquet,压缩后落到 3GB 附近是非常正常的,不用觉得不可思议。

2.2 Parquet、Feather、ORC,怎么选

适合当“终极搬运工”的格式有这么几个:Parquet、Feather(Arrow IPC)、ORC,还有 SQLite 或自定义二进制。我实际对比过,简单结论如下:

格式压缩率读取速度生态支持适用场景
CSV无压缩谁都能读交付给不懂技术的人
Parquet极广(Spark、DuckDB、Pandas、Polars)数据分析、数仓、长期存储
Feather最快Arrow 生态程序内部临时交换
ORC最高Hadoop 生态为主离线数仓

这里首推 Parquet。它几乎成了现代数据生态的通用语言:DuckDB 能直接读,Pandas/Polars 通过 PyArrow 也能读,Spark 就更不用说了,连各种 BI 工具都支持。Feather 虽然读取速度更快,但压缩率不如 Parquet,生态也没那么广。ORC 压缩率最高,但和 Hadoop 绑定太深,非 Hive 环境用起来折腾。相比之下,Parquet 是那个“不会出错”的选择。

有读者可能会问:用什么工具转?如果只是小文件,Pandas 一条to_parquet就完事。但 30GB 的 CSV,Pandas 直接读就会把内存打爆,所以下面我用 DuckDB 和 Polars 两条路线分别讲实际操作。

3. 实操:DuckDB 一条命令完成全量转换

DuckDB 是我这几年处理大 CSV 最顺手的工具,没有之一。它是个嵌入式分析型数据库,单文件运行,不需要装服务端,对超大 CSV 的支持做得非常强,内存不足时还能自动落盘。转换 30GB CSV 到 Parquet,只需要在 DuckDB 里执行一条COPY命令。

3.1 环境准备:装 DuckDB 命令行工具

去 DuckDB 官网下载对应平台的命令行版本,只有一个可执行文件,解压就能用。我这里用的是 Linux 环境,文件名叫duckdb,直接放到/usr/local/bin或者当前目录:

duckdb --version

能输出版本号就说明装好了。DuckDB 不需要额外配置,启动时指定一个数据库文件名,或者用:memory:走内存模式都行。因为我们要做的是一次性转换,用内存模式就够了:

duckdb :memory:

进入交互式命令行后,直接输入 SQL。Parquet 读取和写入功能是 DuckDB 内置的,不用INSTALL任何扩展。新版 DuckDB 里read_csv会自动推断表结构和类型,省掉很多手动步骤。

3.2 一条 COPY 命令完成转换

假设 CSV 文件叫orders.csv,列名在第一行,字段用逗号分隔。在 DuckDB 交互式命令行里执行:

COPY (SELECT * FROM read_csv('orders.csv')) TO 'orders.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);

这条命令的意思很简单:用read_csv自动探测并读取整个 CSV,然后把结果输出成 Parquet 文件,压缩算法用 zstd。read_csv默认会尝试自动识别列名、分隔符和数据类型,对于大多数正常导出的 CSV 都够用。

如果 CSV 文件没有表头,需要指定header=false,手动给列起名字,比如:

COPY ( SELECT column0 AS id, column1 AS user_id, column2 AS amount FROM read_csv('orders.csv', header=false, delim=',') ) TO 'orders.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);

更大的文件我建议不放在交互式命令行里跑,而是写成 SQL 脚本文件,然后后台执行。比如把命令存成convert.sql,再用:

duckdb :memory: < convert.sql

这样一个命令搞定,即使会话断了,任务也已经交给系统进程,不依赖终端窗口。实测这次 30GB 文件转换,前端用了不到 30 秒就把 CSV 扫完建好 schema,后续写入持续了大约 18 分钟,峰值内存也就 3GB 多,比 Pandas 直接读少了一个数量级。

3.3 如果更习惯 Python:用 Polars 流式写 Parquet

有些人可能不想学 DuckDB 的 SQL,更希望在 Python 里一条龙处理。Polars 的scan_csv配合sink_parquet是最理想的方式,它走的是惰性查询引擎,不会把所有数据一次性加载进内存:

import polars as pl pl.scan_csv("orders.csv").sink_parquet("orders.parquet", compression="zstd")

注意,这里是scan_csv,不是read_csvscan_csv只建立查询计划,不真正读数据,sink_parquet会把计划流式执行并直接写出 Parquet 文件,内存占用很低。如果你用read_csv再去to_parquet,那把 30GB 读进内存又有 OOM 风险,等于白折腾。

如果有特殊列类型需要指定,可以在scan_csv里传schema_overrides,它等价于 DuckDB 里手动设置列类型。其余情况,默认推断已经够用。Polars 这条路我也测过,30GB 文件全程内存占用约 6GB,耗时约 22 分钟,比 DuckDB 稍慢但也在可接受范围。

3.4 实测数据:不是玄学,是真的省了 91%

转完之后我看了下结果:输入orders.csv30.2GB,输出orders.parquet2.8GB,压缩率约 90.7%。这是没做任何手工优化、靠默认 zstd 压缩拿到的结果。如果数据里重复字符串多、数值集中在某个区间,压缩率还能更高。

有个细节值得提一下:COMPRESSION ZSTD后面还可以接压缩级别参数,比如ZSTD(LEVEL 19),压得更小但写得更慢。转换是一次性的,如果你对体积特别敏感,可以试着调高等级;如果追求速度,默认级别就行。磁盘空间足够的话,我个人是默认级别拉满速度,后边再考虑分区。

4. 转换完怎么用:这3G数据的读取速度和资源占用

很多人以为转格式就是为了“存起来占地方小”,其实省空间只是副产品。真正的收益在转换之后的使用环节。

4.1 在 DuckDB 里直接对 Parquet 做聚合查询

转成 Parquet 之后,DuckDB 支持直接把它当表查,不用再导回 CSV:

SELECT date_trunc('month', created_at) AS month, count(*) AS cnt, sum(amount) AS total_amount FROM 'orders.parquet' WHERE status = 'SUCCESS' GROUP BY 1 ORDER BY 1;

注意这里FROM后面直接写了一个'orders.parquet'文件路径,DuckDB 原生支持读 Parquet,连加载步骤都省了。查询执行时,DuckDB 只会读取created_atstatusamount这几列,其他列完全不碰。这就是列式存储的好处:列裁剪让扫描量变得极小。

实测用这条 SQL 扫 30GB 数据转换出的 2.8GB Parquet,耗时不到 3 秒。同样的事如果对 CSV 来做,首先得把 30GB 整个读进来,哪怕最后只算一个求和,也逃不过解析所有文本的代价。换了格式后,这一步的时间成本几乎可以忽略。

4.2 用 PyArrow 按需把数据拉进内存做分析

如果你还是想在 Python 里做分析,PyArrow 是最好的桥梁。它把 Parquet 读进内存时支持列裁剪和行过滤,能做到“只读我要的那部分”:

import pyarrow.parquet as pq table = pq.read_table( "orders.parquet", columns=["user_id", "amount", "created_at"], filters=[("amount", ">", 100)] )

这段代码只会从文件里读取user_idamountcreated_at三列,并且只保留金额大于 100 的行。底层原理是 PyArrow 会把下推条件传给 Parquet 文件读取器,直接跳过无关的数据块。如果你的过滤条件落在被分区或排序过的列上,效率还会再高一个量级。

Pandas 用户也不用担心,df = table.to_pandas()就能转成 DataFrame 继续干活,只是这时候内存里已有的数据已经很少了,不会像直接读 30GB CSV 那样爆炸。

4.3 后续能做的事:分区、增量转换、临时导出

Parquet 文件不是只能做成一个。如果 CSV 是按天导出的,你可以转成按月份分区的目录结构,比如:

COPY ( SELECT * FROM read_csv('orders.csv') ) TO 'orders_partitioned/' (FORMAT PARQUET, COMPRESSION ZSTD, PARTITION_BY month);

分区之后,查询某个 3 个月前的数据,DuckDB 可以跳过完全无关的分区目录,速度还能再上一个台阶。增量更新也一样:新来的 CSV 转成新的 Parquet 文件放进同一目录,查询时 DuckDB 会把多个 Parquet 当作一张表去扫,旧文件和你拼出来的数据逻辑上是连续的。

至于临时要看某几条数据,也不用再拖回 Excel,CSV 本身作为“给人看的界面”保留一份小的就行。大文件一律走 Parquet,日常工作里的分析痛点基本就解决了。

5. 转换前的检查清单,这些坑我都踩过

转换看起来就一条命令,但实际操作时坑不少。我按踩坑频率排个序,挨个说一下。

5.1 表头与无表头:读错了整列数据都歪了

如果是系统导出的 CSV,多数带表头,read_csv默认会把它当成列名。但有些导出的 CSV 前面会带几行说明文字,比如“导出时间:2024-01-01”这种,这时候就得用skip=2跳过前两行。最稳的做法是先read_csv后再SELECT * FROM read_csv(...) LIMIT 5看一下长什么样,确认列名和分隔符对不对,再跑转换。

5.2 类型推断失败:身份证号不能按数字存

类型推断是自动的,但自动不代表准确。比如身份证号、订单号这类“看起来是数字”的列,CSV 里没加引号时,DuckDB 和 Polars 会默认推断成 bigint,一转换就把前导零丢了,数据直接废掉。我的做法是:凡是业务上不参与计算的号段字段,一律在schema_overrides或手动CAST里指定成 VARCHAR。

以 DuckDB 为例:

COPY ( SELECT CAST(id_card AS VARCHAR) AS id_card, amount FROM read_csv('orders.csv') ) TO 'orders.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);

另外,极少数列会有混合类型,比如某个字段大部分是数字,但某些行是“N/A”。DuckDB 在这种情况可能会推断成 VARCHAR,导致数值列没法直接聚合。这种情况要么在转换前清洗,要么转到 Parquet 后用TRY_CAST处理,不要指望格式转换自动解决脏数据。

5.3 编码问题:中文 CSV 最容易翻车

中文环境导出的 CSV,经常是 GBK 或 GB18030 编码,而 DuckDB、Polars 默认按 UTF-8 处理。30GB 的 GBK 文件直接读,报错或者乱码都算小的,有时候会导致列数对不上、数据丢失。我这边遇到过几次,最后都是用iconv先转编码再进 DuckDB:

iconv -f GBK -t UTF-8 orders.csv > orders_utf8.csv

这一步需要双倍磁盘空间,转换也要花些时间,但它是必需品。如果你的 CSV 来源不固定,建议写个脚本自动探测编码,别硬猜。也可以在 Python 里用chardet或者charset-normalizer跑一下再决定,虽然对 30GB 的大文件跑全量编码探测不现实,但抽前几千行判断已经足够。

5.4 分隔符和引号:看起来是 CSV,其实是 TSV

CSV 名字里有逗号,但实际导出时可能是分号、Tab,甚至是竖线分隔。尤其某些老系统导出的文件,字段值里还带换行和英文逗号,如果不告诉解析器用引号包裹,转换结果就会错行乱列。这些判断我一般都在转之前用 DuckDB 的read_csv参数显式指定:

SELECT * FROM read_csv('orders.csv', delim=';', quote='"', header=true) LIMIT 10;

顺便说一句,CSV 文件里的字段如果有换行,普通cat工具看会以为行数变多了,不用慌,解析器认引号就行。转格式后这类文件同样没问题,Parquet 是不关心字段内部换行的。

5.5 转换完成后的校验:别急着删原文件

我吃过一次亏:转换完直接删了原 CSV,后来发现某列类型推断错了,数据已经写进 Parquet,想改还得重新导。从那以后,我的习惯是转完后至少抽查几组聚合结果,和原 CSV 做对比,确认没有偏差再清理源文件。

比如算一下行数和金额合计:

SELECT count(*), sum(amount) FROM 'orders.parquet';

再用命令行快速看下 CSV 的行数:

wc -l orders.csv

数字对上了,再考虑删。要是总行数都对不上,那前面某个坑大概率踩中了,趁原文件还在赶紧排查。

转格式这件事,本质是把“给人看的数据”变成“给机器算的数据”。30GB 压缩到 3GB 只是第一步,后面查询从分钟级变成秒级才是真正的收益。如果你现在正被一个大 CSV 卡住,建议直接照上面的步骤跑一遍 DuckDB 的 COPY 命令,先解决工具问题,再解决数据问题。我自己现在收到 CSV 的第一件事永远是转 Parquet,这个习惯帮我省下的不只是磁盘,还有大量等待时间。

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

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

立即咨询