朋友一直叫我维克,干这行久了,接触最多的就是各种乱七八糟的数据文件。上周有人丢给我一个30GB的CSV,说是做年度流量分析用的,让我先看看能不能跑得动。我盯着磁盘剩余空间沉默了几秒,然后花十五分钟左右把整个文件换成了Parquet格式,落盘只剩3GB。同样这批数据,原来想查一个城市的汇总得等十几分钟,现在秒级出结果。
这篇就把整个思路、操作和踩过的坑彻底讲一遍。不管你是处理日志、报表导出,还是整理大型数据集,只要手上有超过几个GB的CSV,这套“换格式”的玩法都值得参考。
1. 30GB的CSV,先算清楚这30GB到底装了什么
1.1 文本存储的三笔“冤枉账”
CSV本质上是一个纯文本文件,里面所有东西都以字符形式存放。这意味着三个问题。
第一,数值类型被“写开了”。比如整数123456,在二进制里用int32存只要4字节,但CSV里要写6个字符,等于6字节;带小数比如0.123456789012345,在二进制double里固定8字节,CSV里却要16字节往上的文本,而且长度还不固定。数值越精确,这个浪费越明显。单条数据看着也就几十字节,但一旦乘上千万级、亿级行数,就是几十GB和几GB的差别。
第二,分隔符和换行符被重复存储。每一行都要有逗号分隔字段、换行符结束本行。这个开销是固定的,但数据量一大就很可观。一亿行数据,每行就算只有10个字段,光逗号就是一亿个字符,约100MB;如果文件是Windows环境生成的,换行是CRLF,一亿行就是200MB。这些开销在二进制列式格式里都可以优化掉。
第三,重复的字符串只能反复写。比如几十亿条日志里某个城市名要出现无数次,CSV每次都得原样写一遍;换成二进制格式后,字典编码可以把“Beijing”映射成整数0,每行只存一个很小的整数。这一项在低基数字段上非常可观,通常是压缩能到10:1的最大来源。
这三笔账加在一起,就能解释为什么30GB的CSV里装的“真实数据体积”可能远没有那么大。
1.2 不同数据类型的体积对标:一份数据能挤到多小
我习惯在动手前先做一个“体积下界估算”,避免对压缩结果产生不切实际的期望,也方便判断压缩效率是否正常。假设一个表有4列:用户ID(字符串)、城市名(字符串)、时间戳、消费金额(小数)。
| 字段 | CSV中的表示 | 大概字节数 | Parquet中的表示 | 大概字节数 |
|---|---|---|---|---|
| 用户ID | u_10000001 | 11 | 字符串+压缩 | 6~11 |
| 城市名 | Beijing | 7 | 字典编码 | 0.2~1 |
| 时间戳 | 2024-01-01 12:30:45 | 19 | timestamp类型 | 8 |
| 消费金额 | 123.45 | 6 | double类型 | 8 |
| 逗号和换行 | ,\n | 2 | 无需分隔符 | 0 |
这样一行在CSV里约45字节。如果表有5000万行,CSV理论体积大约是45×5000万≈2.25GB。但同样的数据存成Parquet,时间戳用时间类型存8字节,城市名走字典编码后平均每个值可能不到1字节,用户ID如果基数值高压缩空间有限,整体体积能压到1GB左右。
当然这只是估算模型,真实文件里字符串长短不一、空值多、嵌套引号多,实际结果会有波动。但方向是一致的:30GB变3GB,不是魔法,是文本编码被换成了二进制编码,顺便做了一次针对性的压缩。
1.3 目标不是“压缩”,是去掉冗余编码
这里要特别澄清一个概念:CSV直接打zip也能变小,很多时候能压到30%甚至更低,但那个方案的体验和Parquet完全不一样。zip是把整个文件当作一个大字符串流做压缩,虽然体积变小了,但你要分析数据时,解压还是要等很久;要随机取某一行,得先把前面都解压完。也就是说,zip只解决了“占空间”,没有解决“读取慢、分析难受”。
真正的目标应该是两层:第一层是去掉文本格式带来的重复和类型浪费,第二层是让压缩逻辑能够利用数据的结构特点。列式存储做的事情恰恰是这两件事一起做。所以公式不是“30GB压缩成3GB”,而是“30GB里真正的信息量可能只有3GB,格式转换只是把那27GB的文本容器去掉了”。
这一点想明白,后面选什么格式、用什么压缩算法、要不要保留原始CSV,都会有更清晰的判断依据。对于归档、分析、传输场景,二进制列式格式是比zip更优的答案;如果你只是想把文件存起来永久不动,那zip也够用,但既然要做数据分析,就别只想着“塞进柜子”,还得想着“随时能翻出来用”。
2. 方案选型:换格式为什么选Parquet,而不是压缩包
2.1 CSV只是“交换格式”,不是“存储格式”
CSV之所以无处不在,是因为它极简:纯文本、无schema、任何编程语言都能读。但它也为此付出了代价——所有数据结构信息都被抹平,类型、长度、重复关系全都丢掉了。用行话讲,CSV是一种“交换格式”,适合在不同系统之间传递数据,不适合作为长期分析的基础存储格式。
打个比方:CSV像你出门旅行时随手拍的行李照片,方便跟朋友描述带了多少东西;Parquet像快递仓库里的标准化货架,每一件货品都按编号、尺寸、存放位置登记。你说照片能不能当仓库用?也能,但找东西和搬运就很痛苦了。数据量到30GB这个量级,每次读取都要全量解析文本,CPU和内存都被大量浪费在“字符切分”和“类型转换”上。
所以第一步选型不是“用哪个工具”,而是“要不要继续把CSV当主力格式”。只要后续还会反复查询、聚合、抽样,就应该换成二进制格式;如果只是给别人交付一次,对方也不一定需要高性能读取,那保持CSV也可以。
2.2 列式存储的压缩逻辑:让同类型的数据待在一起
Parquet最关键的设计是列式布局。普通行式存储比如CSV,是按行把每条记录完整写在一起;Parquet则是先把整个文件按行切成若干行组(row group),每个行组内部又按列分别存储。
这个布局带来的好处非常直观:同一列的数据类型相同,值域相近,压缩算法能发挥最大的威力。比如时间戳列,全是时间类型,连续两行的时间往往很接近,用delta编码就能记录差值而不是完整值;城市名列重复率高,字典编码先把城市列表提取出来去重,每一行只存一个字典下标。这些操作在CSV里是不可能做的,因为文本流里数字、字母、符号混在一起,压缩器只能看到一串没有结构意义的字符。
列式布局还能带来查询时的“列裁剪”:如果只要金额这一列,扫描时根本不需要读其他列。这在30GB级别的大文件上,效果比压缩本身还要明显。你做了格式转换后,不只是少了27GB磁盘空间,更是给后续的SQL查询装上了“只读需要的那块数据”的能力。
2.3 Parquet、ORC、zip怎么选
当下主流的列式二进制格式里,Parquet和ORC是最常见的两个。ORC在Hive生态里非常流行,很多OLAP场景都有优化;Parquet背靠Apache Arrow和DuckDB,和数据分析生态的结合更紧密,Python、R、Spark、Polars、DuckDB都能直接读,兼容性最好。
| 格式 | 核心特点 | 适合场景 | 上手门槛 |
|---|---|---|---|
| Parquet | 列式存储、生态广、支持字典编码和谓词下推 | 数据分析、跨工具使用 | 低,pip安装pyarrow即可 |
| ORC | Hive生态友好,压缩比高 | 大数据平台,Hadoop系 | 偏高,需适配环境 |
| zip/gzip CSV | 压缩率高,但读取仍需全量解压 | 冷数据归档 | 最低 |
| Arrow/feather | 读写速度极快,内存映射友好 | 单机高频交互式分析 | 低,但压缩率一般 |
个人处理CSV换格式的场景,我推荐Parquet。原因很简单:它不需要你额外搭一套Hadoop环境,装个pyarrow或者duckdb就能开始,学习成本和迁移成本最低。
至于zip,前面已经说了,只能解决体积不能解决查询性能,适合归档不适合分析。gzip也类似,你把CSV压缩成csv.gz,读取时还是要全量解压,数据量大时依旧吃力。这里直接给结论:如果目的是“存档”,可以选gzip或者zstd压缩的CSV;如果目的是“存档+继续分析”,选Parquet。
2.4 压缩算法和分块参数:从3GB再扣一点
确定用Parquet之后,还有两个参数需要做决定:压缩算法和行组大小。
Parquet支持的常见压缩算法有Snappy、Gzip、Zstd、LZ4、Brotli。在30GB这个量级,我最推荐Zstd,原因是压缩比直逼Gzip,速度却快很多,实测比Snappy多压出15%到30%,速度损失却可以接受。如果对写入速度要求极高,可以退而求其次用Snappy;如果要追求极致压缩比且不介意时间长,可以选Brotli或Gzip。
行组大小是另一个关键参数。行组越大,压缩率通常越高,但读取单条记录时的随机读代价也越大;行组越小,查询裁剪越细,但整体体积会略微增大。我建议用默认值128MB,对大多数场景已经足够。如果查询模式经常是“按某个分区字段过滤”,可以配合Hive分区目录,把数据按日期或地区拆分到多个Parquet文件,进一步缩小单次扫描量。
这一步的取舍一句话总结:不要为了“再压小一点”把查询速度牺牲掉。3GB这个结果本身就很好,没必要为了2.8GB去等更久。
3. 实操过程:从30GB CSV到3GB Parquet的完整步骤
3.1 转换前先给CSV做个体检
拿到CSV之后,不要直接闷头转换。先花五分钟做个体检:确认文件编码、分隔符、表头、字段数量、行数、有没有空值和异常字符。这一步能避免后面白跑一遍。
我常用的体检方式是先用文本工具看文件头部和尾部,确认第一行是不是表头、分隔符到底是逗号还是分号。然后可以用DuckDB的read_csv_auto自动探测:
SELECT * FROM read_csv_auto('input.csv') LIMIT 5;如果这条语句能跑通,且字段类型看着合理,那就说明文件大概率可以直接转。如果报错,通常要么是编码不对,要么是某些行字段数不一致。此时再用Python的csv模块读几行,把异常行打出来看看。
另一个容易忽略的点是文件编码。中文CSV经常出现UTF-8和GBK混用的情况,手机App通常会自动识别,但命令行工具和数据库不一定。我的经验是:先让DuckDB自动读,乱码就用encoding参数指定编码:
SELECT * FROM read_csv_auto('input.csv', encoding='gbk') LIMIT 5;编码确认对了,再进入下一步。
3.2 用DuckDB一行命令完成转换
真到了转换环节,我首选DuckDB,因为它把CSV解析、类型推断、Parquet写入都封装好了,一个COPY语句就能完成任务,而且是流式处理,30GB的文件不用一次性塞进内存。
完整命令如下:
INSTALL parquet; LOAD parquet; COPY ( SELECT * FROM read_csv_auto('input.csv') ) TO 'output.parquet' ( FORMAT 'parquet', COMPRESSION 'zstd' );如果体检时发现分隔符不是逗号、字段类型需要指定,可以改成这样:
COPY ( SELECT * FROM read_csv( 'input.csv', header = true, delim = ',', encoding = 'utf-8', auto_detect = true ) ) TO 'output.parquet' ( FORMAT 'parquet', COMPRESSION 'zstd', ROW_GROUP_SIZE 122880 );这一步里,read_csv_auto会扫描文件并推断出类型,COPY的TO子句负责把结果写入Parquet。默认情况下DuckDB会利用多线程并行读取和写入,处理30GB文件大概也就是几分钟到十几分钟的量级,取决于磁盘速度。
转换过程中,DuckDB CLI下会看到进度条;如果是在Python里调用duckdb,可以直接执行并把结果打印出来确认没有报错。
注意:不要在转换命令上同时加ORDER BY或者复杂的JOIN,除非确有必要。排序会拖慢写入速度,而且Parquet本身不保证行顺序,硬要排序会浪费大量时间。
3.3 用pyarrow流式转换,把内存占用压到最低
如果你的环境不方便装DuckDB,或者你想更精细地控制类型映射,可以用pyarrow直接流式转换。这里最关键的是不要用pandas.read_csv一次性读入,30GB大概率直接内存溢出。正确姿势是用pyarrow.csv.open_csv拿到RecordBatchReader,然后分批写入ParquetWriter。
示例代码如下:
import pyarrow as pa import pyarrow.csv as pv import pyarrow.parquet as pq # 流式读取CSV,block_size控制按块读取的字节数 reader = pv.open_csv( 'input.csv', read_options=pv.ReadOptions(block_size=64 * 1024 * 1024), parse_options=pv.ParseOptions(delimiter=','), convert_options=pv.ConvertOptions(strings_can_be_null=True) ) # 用读取到的schema初始化ParquetWriter writer = pq.ParquetWriter( 'output.parquet', schema=reader.schema, compression='zstd' ) for batch in reader: writer.write_batch(batch) writer.close()这段代码的好处是内存占用基本恒定,不会因为文件是30GB就吃掉30GB内存。批处理大小可以通过block_size调整,默认值很小,我通常会调到64MB,减少批次数,同时单批内存压力也不大。
如果你需要对特定列做类型修正,比如把“user_id”从字符串变成整数、把“created_at”解析成时间戳,可以自己在循环里对batch做cast或compute操作,写完后再交给writer。类型修正这个步骤,看起来简单,实际是CSV转Parquet最容易出错的环节,后面会单独讲。
3.4 转换后如何验证文件没“变样”
转换不是跑完就结束,验证环节一定要做。最简单的验证有三步:看文件大小、看行数、看抽样对比。
看文件大小:
ls -lh input.csv output.parquet如果转换正常,output.parquet应该远小于input.csv。如果没有明显缩小,说明类型推断可能出了问题,比如所有列都被读成了字符串,字段没有利用字典编码和数值压缩。
看行数:
-- DuckDB 直接查两个文件的行数 SELECT 'csv' AS src, count(*) FROM read_csv_auto('input.csv') UNION ALL SELECT 'parquet', count(*) FROM 'output.parquet';行数一致说明没有丢行,这是底线。抽样对比则更严格:
SELECT * FROM read_csv_auto('input.csv') USING SAMPLE 1000; SELECT * FROM 'output.parquet' USING SAMPLE 1000;把两个抽样结果并排看,确认关键字段内容一致。更高强度的校验可以用全外连接对比两边的行数/主键,方法不止一种,我在第4部分会专门展开讲MD5校验的做法和误区。
注意:对比行数的时候,如果CSV里有空行或者末尾多了一个换行,DuckDB对空行的处理策略可能会让行数差一,这一步建议先在体检时确认CSV没有空行,否则先清洗再转换。
至此,从CSV到Parquet的主流程就闭环了。30GB到3GB不是梦,已验证。
4. 常见问题与排查技巧实录
4.1 转换时内存直接爆掉怎么办
我见过不少人在这一步踩坑,核心原因几乎都是用了pandas.read_csv读全量文件。30GB的CSV用read_csv读进来,内存占用往往会飙到60GB以上,因为pandas在解析时会生成多个中间副本。这时候机器直接卡死,甚至OOM被系统杀掉。
处理办法有三个,按优先级排序:
- 第一选择:用DuckDB的COPY,它天然就是流式处理,不需要手动分块。
- 第二选择:用pyarrow的open_csv + ParquetWriter,也就是上面那段代码,内存恒定。
- 第三选择:如果你只能用pandas,那至少要用chunksize分批读,但pandas解析CSV本身开销大,同样体积下比pyarrow慢不少,不推荐在30GB量级硬撑。
还有一个容易被忽略的点:不是只有读CSV才占内存,ParquetWriter在写入时也会缓存一定量的数据用于压缩。如果发现单批batch写完后内存并没有释放,可以检查是不是把整个RecordBatchReader一次性list化或者collect了。正确做法是循环里处理一个batch丢一个batch,不要攒着。
4.2 中文乱码:手机正常、电脑乱码到底是谁的锅
很多人的CSV是从数据库或者平台导出的,编码格式可能是UTF-8,也可能是GBK。手机App普遍会自动识别编码,所以看着很正常;电脑上如果用Windows记事本或者老Excel打开,可能默认按ANSI(也就是GBK)去解析UTF-8文件,于是满屏乱码。
这个问题和格式转换直接相关:如果你用DuckDB读CSV时没有指定encoding,默认会按UTF-8处理,GBK文件就会被读出一堆乱码,转出来的Parquet自然也是脏数据。所以体检阶段一定要确认编码,可以用Python的chardet试试:
import chardet with open('input.csv', 'rb') as f: raw = f.read(10000) print(chardet.detect(raw))但这方法不是绝对可靠,大文件最好多截几段。更实用的办法是:先尝试read_csv_auto,看结果字段是否正常;不正常就换成encoding='gbk'再看一眼。一旦确定了编码,转换时固定写死,不要让它自动猜,避免后续偶发差异。
转换完之后,如果还有下游工具要求CSV,你可以再从Parquet导出一份编码统一的CSV,比如统一为UTF-8,彻底解决手机和电脑打开不一致的问题。
顺便提一句,如果你在PyCharm里生成的CSV文件,用PyCharm打开却不是一个表格,这其实是正常现象。CSV本质是文本文件,PyCharm默认用文本编辑器打开它,不是表格软件。要想在IDE里像表格一样看,需要装CSV插件或者右键选择“打开方式”里的Spreadsheet;更直接的办法是用pandas、DuckDB这类工具去读,而不是指望编辑器充当Excel。
4.3 字段类型被猜错:科学计数法、日期和长ID的坑
CSV没有类型信息,所以工具只能靠“猜”来推断每列类型。猜错的地方主要集中在三类:长整型ID、日期格式、科学计数法。
先说长ID。一个18位的订单号,在Excel里经常会变成科学计数法,比如1.23457E+17,后面的精度直接丢了;在DuckDB里自动推断时,也可能会把它读成DOUBLE而不是VARCHAR,转成Parquet后精度立刻损失。处理办法:在read_csv里显式指定该列为VARCHAR,不要依赖自动推断。一旦丢失精度,数据就没有回头路,这一条比压缩比重要得多。
日期格式的坑在于不同系统输出的格式太多:2024-01-01、2024/01/01、20240101、01-JAN-24等等。DuckDB的自动推断一般能处理常见ISO格式,但别指望它认识所有格式。最好的做法是先用LIMIT 5观察样例,再在read_csv里统一指定DATE或者TIMESTAMP类型,必要时用strptime转换。
科学计数法的问题通常出现在浮点型字段,比如经纬度、金额。CSV里写成1.23E-05,自动推断成DOUBLE没问题,但如果某些行是类似“1.23456E+05”,而另一行是普通小数,工具也可能误判。稳妥起见,数值列先按DOUBLE读进来,再检查min/max和精度是否符合预期。
4.4 转换后怎么校验数据一致性,MD5应该怎么做
很多人关心MD5校验,但有个误区要先点破:不要把MD5直接对CSV原文件和Parquet文件算,因为文件格式不同,二进制内容不可能相同,MD5也必然不同。MD5校验在这种场景下不是用来比“文件是否相同”,而是用来比“数据内容是否一致”。
正确做法有两种。
第一种,存根法:转换前,先对CSV做一个“内容基线”的MD5。具体做法是把每一行按统一的规则规范化,比如去掉行尾空格、统一分隔符、转成UTF-8,然后逐行拼接成一个字符串流,对整个流计算MD5。转换后,把Parquet导出回CSV,用同样的规范化规则再算一次MD5,两个哈希应该一致。这个方法能准确发现“哪一行内容变了”,但实现起来要注意很多细节,比如空值怎么表示、Float的精度会不会因为二进制存储和文本转换产生误差。
第二种,数据库比对法,更实在:用DuckDB同时读CSV和Parquet,对两张表做全外连接或GROUP BY对比。比如:
SELECT count(*) FROM ( SELECT * FROM read_csv_auto('input.csv') EXCEPT SELECT * FROM 'output.parquet' );如果差集为空,说明Parquet里的数据覆盖了CSV的全部内容。反向再查一次,就能确认没有多行、少行、字段不一致。这种方式比MD5更直接,唯一前提是两边schema要一致,而你在转换时已经控制好类型了,所以这个前提是成立的。
MD5更适合用来做传输完整性校验,比如文件从A机器传到B机器后,确认字节没被改;而数据转换的一致性校验,交给SQL集合运算更高效,也更不容易被格式化细节坑到。两者分工不同,别混着用。
4.5 换完格式后如何继续拆分、分析和导入数据库
转成Parquet之后,很多原本对CSV的操作习惯可以升级成更高效的SQL操作。比如拆分文件,以前用CSV拆分工具很痛苦,现在直接按条件导出:
COPY ( SELECT * FROM 'output.parquet' WHERE date >= '2024-01-01' AND date < '2024-02-01' ) TO '2024_01.parquet' (FORMAT 'parquet');如果下游只认CSV,也可以导出成CSV,但建议还是尽量保持一致格式,避免又绕回文本文件的坑里。
往数据库导数据也一样。很多同学在DBeaver里导入CSV时经常遇到字段类型、分隔符的问题;如果先把CSV转成Parquet,再用支持Parquet导入的工具或者直接用DuckDB的SQL语法写回数据库,整个流程会顺很多。像PostgreSQL可以先用COPY写CSV,但如果你已经拿到Parquet了,更常见的做法是先用DuckDB做查询和清洗,最终只把结果表导出成小体积CSV,再导入DBeaver或者数据库客户端。文件越小,导入失败的概率越低。
还有一些特定场景,比如把示波器波形数据从CSV导进MATLAB做FFT分析、把航迹数据导入坐标转换工具等,这类工具往往只接受CSV或TXT输入,转成Parquet未必更方便。遇到这种场景,我的建议是:不要盲目追求转格式,而是先用格式转换能力把“非必要的大文件变小”,比如只保留需要的列、只保留目标时间段,再导出回CSV给下游工具。转换格式是手段,最终目标永远是“处理效率高、数据不丢”,别本末倒置。
4.6 常见问题速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 转换时内存爆掉 | pandas一次性读入CSV | 用DuckDB或pyarrow流式处理 |
| 中文乱码 | 编码识别错误 | 显式指定encoding,再转UTF-8 |
| 长ID变成科学计数法 | 类型被推断为浮点 | read_csv里指定为VARCHAR |
| 日期解析不对 | 格式不标准 | 先用LIMIT观察,再strptime |
| 转换后体积没变小 | 所有列被读成字符串 | 检查schema,按列指定类型 |
| MD5对不上 | 直接对不同格式文件算MD5 | 改成内容规范化的MD5或SQL集合差 |
这张表基本覆盖了我处理大CSV转换时踩过的大部分坑。实际操作中还有什么特殊问题,欢迎在评论区一起聊。
最后再分享一个我实际处理时的习惯:转完Parquet不要把原始CSV立刻删掉。保留原始文件一段时间作为备份,确认后续所有查询、导入、校验都跑通了,再考虑归档或删除。磁盘空间可以再买,数据丢了可没有撤销键。而且30GB变3GB之后,对比着看两个文件的读取速度、体积变化,你会对格式转换这件事的理解更深一层。下次再有人拿大CSV来找你,你心里就有底了。