1. 30GB的CSV,浪费到底在哪
手里有个30GB的CSV,这玩意儿在我的工作里太常见了。往往是某个业务方的数据导出、某个爬虫抓下来的原始日志、或者一个研究机构放的公开数据集,长得都差不多:第一行是列名,往下几百上千万行全是逗号分隔的值,看一眼就头大。
先说结论:CSV这种格式,天生就不适合做大文件存储。30GB看着唬人,实际上里面一大半都是原样存下来的冗余信息,你随便换个存储格式,压到5GB以内很正常,压得好甚至能到3GB。这不是什么黑科技,纯粹是CSV在设计上就没考虑过“效率”这两个字。
1.1 文本存储的天然浪费
CSV的本质是纯文本文件,一切数据都以字符形式保存。数字123456789.123456在CSV里就是明晃晃的16个字节,一个不多一个不少。但你仔细想想,一个双精度浮点数在计算机里的二进制表示,其实只需要8个字节。光是数字这一项,CSV就多浪费了整整一倍的空间。
文本里到处都是不可见的浪费。一个空字段,CSV里也得用两个逗号占位,比如1,,3,中间那个空值就用掉了2个字节。字符串更夸张,如果你导出的数据里带几个制表符、换行符,因为CSV规范要求这些字符必须用引号包起来,原本1个字节的换行符在CSV里就变成了\n这种2个字符的转义,再套上引号,直接翻倍。处理过脏数据的人都知道,一个字段里如果混进了逗号、引号、换行,导出CSV时那个转义逻辑有多恶心,而这些额外的转义字符在存储上全是白花花的空间浪费。
我实测过一个典型的金融行情数据,单表7000多万行,60多个字段。原始CSV大约28GB,其中纯数字类型的字段占了总数据量的65%以上。光是把这些数字从文本改成二进制存储,理论上就能砍掉接近一半的空间,压缩之后直接掉到个位数GB。
1.2 类型信息完全丢失
CSV除了浪费空间,还有个更致命的缺陷:它不保存任何类型元数据。你打开CSV看到2024-03-15,这到底是个字符串还是一个日期类型?CSV本身完全不知道。读到1234567,这到底是个整数、长整型还是字符串?CSV也不知道。
这就导致两个连锁问题。一是数据读取方每次都要做类型推断,大批量读取时非常耗时,而且推断还容易出错——比如某个字段大部分是数字,但有个别行是空值或者异常文本,推理引擎就会把这个字段当成字符串,后续做聚合计算的时候还得先cast,麻烦事一堆。
二是压缩效率上不去。因为所有类型在CSV里都是字符,压缩算法面对2024-03-15这种日期字符串,虽然能查出点模式来,但和二进制日期类型(比如DuckDB/Parquet里的DATE类型只占4字节)的压缩空间根本不是一个量级。这里多说一句,日期2024-03-15在Parquet里存成DATE类型就是4个字节,而CSV里的相同信息用了10个字节,这是什么概念?单这一个字段就省了60%的开销。
所以说白了,CSV把一份数据以最笨拙的方式摊开铺平,30GB的原始数据里,真正有价值的“信息”也许只有5GB,剩下25GB都是在用文本形式硬扛。换个格式能缩到3GB,道理就在这。
2. 为什么是列式存储:Parquet的降维打击
换格式不是随便换,你得懂自己要的是什么。市面上能选的格式不少:JSON、JSONL、Avro、ORC、Parquet、HDF5、SQLite……但真要拿来做大数据集的长期存储和高效查询,Parquet基本是当下最稳妥的选择。
2.1 列式存储的真正优势
Parquet是一种列式存储格式,这一点是它区别于CSV的根本所在。CSV是行式存储:一行数据完整地写在一起,读文件的时候必须从头扫到尾。Parquet则把同一列的数据连续存放,读某个字段的时候只读取对应的列块,其他列可以直接跳过。
列式存储对压缩有天然优势,因为同列的数据类型一致,数据分布也更集中。比如一个“用户等级”字段,可能只有1到6六个值,列式存储会把这一整段重复值放在一起,压缩算法轻松就能把这段数据压到极小。但CSV是行式存储,用户等级这个字段分散在文件的各个角落,每遇到一次就要重新编码一次,压缩率自然天差地别。
我见过太多团队处理大数据时还在用CSV做存储,其实只要换成Parquet,很多时候连“上数仓”“搭集群”这些重活都省了。因为Parquet自带统计信息,比如列的最大最小值、空值数量,查询引擎可以靠这些统计信息直接跳过不需要的数据块,这比傻扫CSV不知道快了多少倍。
2.2 压缩算法怎么选:Zstd是综合最优解
Parquet本身只是个容器,真正决定压缩比的是它内部的压缩算法。常见的有Snappy、Gzip、Zstd、LZ4几种,各有优劣。
我的实际经验是:Snappy适合追求极速的场景,Gzip压缩比最高但慢,Zstd是日常使用最平衡的选择。以一批真实业务数据为例,同样一份CSV转Parquet:Snappy压缩后大约5.2GB,耗时45秒;Gzip压缩后大约3.1GB,耗时4分半;Zstd(压缩级别3)压缩后大约3.3GB,耗时1分12秒。Zstd比Gzip只多了不到10%的空间,速度却快了将近三倍,妥妥的性价比之王。
注意:如果你准备把Parquet文件丢到Hive/Spark里跑SQL,压缩算法的选择还要考虑集群的支持情况,有的老集群只默认支持Snappy。不过单机分析场景就无所谓,Zstd闭眼选。
2.3 生态兼容性:为什么Parquet能通吃一切
Parquet另一个让我放心用的理由是生态太成熟了。Pandas、Polars、DuckDB、ClickHouse、Spark、Flink、Trino……只要你能叫得上名字的现代数据处理工具,原生支持Parquet是标配。它背后是Apache社区的标准,列式存储、谓词下推、向量化执行,这些性能优化手段在你换到Parquet之后全部自动生效。
还有个很多人忽视的点:Parquet自带schema信息。你在转换时如果指定了字段类型,以后任何程序读取这个文件,拿到的都是明确的类型定义,不会再出现“读CSV猜类型猜错”这种低级事故。对于要长期维护的数据集来说,这一点比压缩省空间重要得多。
3. 实操:30GB的CSV怎么变成3GB的Parquet
理论说再多,不如直接动手。下面是我实际转换一个30GB CSV文件的完整过程,用的工具是DuckDB。
3.1 为什么用DuckDB而不是Pandas
先把结论放这:除非你的机器是512GB内存的主机,否则别用Pandas读30GB的CSV。
原因很简单,Pandas执行pd.read_csv("file.csv")时,会先把整个文件加载进内存,再用一整套Python对象来表示DataFrame。30GB的CSV,加载到内存里轻松占用80GB甚至更多,普通人的电脑根本扛不住,内存一爆就OOM,进程直接被杀。
DuckDB是走OLAP路线的分析型数据库,它的理念就是“不需要把全部数据载入内存”。DuckDB读取CSV时采用流式处理,内部有向量化执行引擎,能够一边读一边压缩写出,峰值内存占用可能只有几百MB。实测下来,30GB的CSV转Parquet,DuckDB跑完整个流程峰值内存连800MB都不到,这是Pandas完全做不到的。
如果你机器上还没装DuckDB,可以用pip一行搞定:
pip install duckdb3.2 一条SQL完成核心转换
DuckDB最爽的地方在于:转换数据不需要写任何Python代码,一条SQL就完事了。
-- 从CSV读取数据,直接写入Parquet COPY (SELECT * FROM read_csv_auto('data.csv')) TO 'data.parquet' (FORMAT PARQUET, COMPRESSION ZSTD, ROW_GROUP_SIZE 100000);就这么一句话,30GB的CSV就变成了3GB出头的Parquet。但这里有几个参数值得展开说说:
read_csv_auto:DuckDB的自动类型推断函数,它会扫描CSV的样本来自动判断每个字段的类型。如果不放心,你也可以用read_csv并手动指定类型结构,但绝大多数场景下auto模式已经足够靠谱。COMPRESSION ZSTD:指定压缩算法,我上面说过,Zstd性能和压缩比最均衡。ROW_GROUP_SIZE 100000:Parquet内部的行组大小,默认也是100万左右。行组越大,压缩率越好,但随机读取某个小范围内的数据会慢一些。如果你的文件后续主要用于批量扫描,保持大行组没问题;如果经常要按条件过滤小部分数据,建议适当调小,比如10万。
我在实际项目中,数据量更大一点(40GB+),也是用这条SQL跑的,全程大概3分多钟,出来的文件从40GB降到4.6GB,压缩比接近9比1。这个效率,Pandas跑同样的活可能要表崩溃好几轮。
3.3 Pandas方案:小数据量时怎么处理
如果文件本身不大,就1-2GB,用Pandas转换其实也不是不行,而且代码更直观。常见做法是分段读取:
import pandas as pd # 分段读取CSV,每50万行一个chunk,逐个追加写入Parquet CHUNK_SIZE = 500000 writer = None for chunk in pd.read_csv('data.csv', chunksize=CHUNK_SIZE): if writer is None: writer = pd.io.parquet.ParquetWriter( 'data.parquet', engine='pyarrow', compression='zstd', schema=pyarrow.Schema.from_pandas(chunk) ) writer.write_table(pa.Table.from_pandas(chunk)) if writer: writer.close()这段代码的核心思路是不把全部数据塞进内存,而是分块处理。每一块读进来,马上转成Arrow表写入Parquet,然后释放内存。理论上不管CSV多大都能跑,但实际速度比DuckDB慢不少,因为Pandas的chunk读取是按行切割,内部还有大量Python对象转换开销。
所以我的排兵布阵建议是:
| 文件大小 | 推荐工具 | 速度 | 内存占用 |
|---|---|---|---|
| 小于2GB | Pandas分段读取 | 中等 | 低 |
| 2GB-20GB | DuckDB | 快 | 低 |
| 大于20GB | DuckDB + 分区写出 | 快 | 极低 |
3.4 转换后的数据校验
别以为转换完就万事大吉了,写完Parquet之后必须做数据校验,这一步不能省。
我的校验思路有三条:行数对齐、数值抽样对比、类型检查。
-- 校验行数是否一致 SELECT COUNT(*) FROM read_parquet('data.parquet'); -- 查看最终Parquet的schema DESCRIBE SELECT * FROM read_parquet('data.parquet'); -- 抽样对比若干条数据,确认内容没丢没变 SELECT * FROM read_parquet('data.parquet') USING SAMPLE 100;行数对齐是最基本的,CSV有多少行,Parquet里必须也有多少行,差一行都说明转换中途出了问题。数值抽样我一般用USING SAMPLE 100,随机抽100行人工抽查,看看关键字段有没有错位、乱码、精度丢失。类型检查主要是确认DuckDB推断的数据类型是否符合预期,比如日期字段到底有没有被推断成DATE,而不是VARCHAR。
大坑提醒:如果CSV文件里的某个日期字段有
2024-02-30这种脏值,DuckDB的auto推断可能会把整个字段推断成VARCHAR,你的Parquet里那一列就变成字符串了,查起来麻烦不少。遇到这种情况,要么先清洗原始数据,要么手动在read_csv里指定字段类型。
3.5 更进一步:多文件Parquet分区分区写入
如果你有多个CSV文件要合并,或者单个Parquet太大影响后续查询效率,可以考虑分区写入。DuckDB也支持直接对分区字段做转换:
COPY (SELECT * FROM read_csv_auto('data_*.csv')) TO 'output_dir' (FORMAT PARQUET, COMPRESSION ZSTD, PARTITION_BY (date_col));这样会在output_dir下按date_col的值生成多个子目录,每个子目录里是对应分区数据的Parquet文件。查询时如果带了分区字段过滤,DuckDB/Spark这类引擎可以直接跳过无关目录,速度还能再上一个台阶。不过要注意,分区字段本身不会重复存放在每个Parquet里,你读出来的数据里会多一个隐藏的date_col列,使用时别搞混。
4. 踩坑实录与排查技巧
转换文件这种事,看起来就是一条SQL的事,但实际跑起来,各种各样的妖魔鬼怪全出来了。我把这几年遇到过的问题整理一下,希望能帮你少走弯路。
4.1 内存不够:OOM到底怎么破
用DuckDB都OOM?这听着有点反直觉,但确实遇到过。原因是这样:DuckDB的read_csv_auto会先扫描文件的一部分样本来推断类型,如果CSV文件行宽特别大(比如上万个字段),或者是某些特殊分隔符没识别对,推断过程会消耗大量内存。
另外,如果CSV文件非常大,而且你写的SQL里有排序、聚合这类需要全量数据才能算的操作,DuckDB也会把中间结果往内存里塞。解决办法有几招:
- 加
sample_size参数,限制类型推断的扫描行数,比如read_csv_auto('data.csv', sample_size=10000)。 - 在DuckDB里设置
SET memory_limit='4GB',主动限制内存使用,让多余的走外存,避免整机卡死。 - 如果单文件实在太大,先按行拆分成几个小文件,逐个转换,最后再合并Parquet。
拆分CSV其实没你想的那么复杂,直接用Linux的split命令就行。但要注意,CSV文件头文件不能被重复添加上去,如果你用split按行数拆分,第一个文件保留表头,其余文件的表头得删掉,否则转换时会出现大量类型推断错误。
# 按100万行一个文件拆分,先不保留表头 tail -n +2 data.csv | split -l 1000000 - part_ # 给拆分后的第一个文件补表头 head -n 1 data.csv > part_aa cat part_aa.tmp >> part_aa # 这里建议用临时文件操作,避免覆盖原文件实际操作时小心点,别把原文件搞坏了,我建议先复制一份再做拆分试验。
4.2 类型推断失败:脏数据引发的连环事故
类型推断是CSV转Parquet里最容易被低估的坑,而脏数据是罪魁祸首。最典型的情况是:某个字段99.9%都是数字,按理说应该推断成DOUBLE或者BIGINT,但偏偏有几行的值是空字符串或"unknown",DuckDB一看有非数字内容,直接把整列推断成VARCHAR。等你读完Parquet想算平均年龄、平均金额的时候,发现全是字符串,聚合函数都下不了手。
处理方式我建议分两步走:
第一,先做数据探查,再决定怎么转。用DuckDB的SUMMARIZE函数可以快速看到每一列的类型分布和空值数量:
SUMMARIZE SELECT * FROM read_csv_auto('data.csv');第二,对有问题的列,手动指定类型。DuckDB的read_csv_auto支持类型覆盖:
COPY ( SELECT CAST(age AS INTEGER) AS age, CAST(amount AS DOUBLE) AS amount, date_col FROM read_csv_auto('data.csv') ) TO 'data.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);这样做有个前提,你得先确认脏数据只是极少数,并且能在cast时被DuckDB合理处理。如果脏值太多,最好在源头清洗,别指望转换的时候顺手搞定。
4.3 编码问题:CSV读出来全是乱码
这个问题在中文环境里尤其常见。很多业务系统导出的CSV默认是GBK/GB2312编码,而DuckDB和Pandas默认按UTF-8解析。一遇到GBK编码的CSV,读出来的中文全是豆腐块和乱码。
DuckDB本身不支持直接在read_csv_auto里指定编码,所以面对GBK文件,我的做法是先用iconv转编码,再交给DuckDB处理:
# GBK转UTF-8,输出临时文件 iconv -f GBK -t UTF-8 data.csv > data_utf8.csv如果文件太大,iconv直接整文件转码可能会有压力。可以先用head抽几行测试转码效果,再决定是分块转还是全量转。文件一旦转成UTF-8,后边的流程就顺畅了。
4.4 输出Parquet的常见配置参数速查
很多第一次用的朋友问我,Parquet转换到底怎么调参数,这里直接列一张速查表,你照着抄就行。
| 参数 | 默认值 | 推荐值 | 适用场景 |
|---|---|---|---|
COMPRESSION | Snappy | ZSTD | 追求压缩比与速度平衡 |
ROW_GROUP_SIZE | 122880 | 100000-1000000 | 批量扫描用大值,点查用小值 |
PARTITION_BY | 无 | 常用过滤字段 | 需要按时间/地区等字段过滤时 |
OVERWRITE_OR_IGNORE | 无 | OVERWRITE | 重复转换时覆盖旧文件 |
这几个参数已经覆盖了我日常90%的需求,不需要再深挖别的配置。
5. 一点经验之谈
数据存储这件事,我一直信奉一个原则:能存二进制就别存纯文本,能让机器高效读就别让人眼舒服。CSV的最大优点是“打开就能看”,但这也是它最致命的地方——它的一切设计都在为“人可读”服务,完全牺牲了机器效率。
换到Parquet之后,副作用不止是省磁盘。文件小了,拷贝传输就快了;有列式存储和压缩,查询响应速度也能上一个大台阶;类型信息保留,下游的ETL、BI报表都不再需要反复做数据类型清洗。你可能觉得换格式要花好几小时学新工具,但以我的经验,从CSV迁移到Parquet,最耗时的反而是说服团队“我们真该换了”的过程,真正动手转换的时间,半小时顶天了。
如果你手头也有一个躺了好几年、用了之后卡到不行的超大CSV,我强烈建议你今天就花个十分钟跑一遍DuckDB的转换流程。等那个30GB的文件真的缩到3GB,你回头再看当初各种“文件太大跑不动”的问题,会发现很多难题压根不需要堆硬件,换个思路就解决了。