30GB CSV到3GB Parquet:列式存储压缩原理与转换实操
2026/9/15 4:10:11 网站建设 项目流程

你有没有遇到过这种场景:凌晨两点,只是想从一份30GB的CSV里筛出某一天的记录,结果Pandas的read_csv直接内存溢出,电脑风扇满速运转,屏幕上的小圆圈转了十分钟还在转。我经历过,不止一次。后来我把同一个CSV用DuckDB转成了Parquet格式,文件从30GB变成3GB,同一类查询从“要等几分钟”变成“几百毫秒”。这篇文章就把这件事完整拆开说一说:CSV为什么会这么膨胀,换格式的压缩原理是什么,真实转换怎么做,以及我在这个过程中踩过的、文档里基本不会写的坑。

如果你手上有几GB甚至几十GB的CSV,或者日常被“Excel打不开”“Pycharm打开是乱码”“手机能看电脑打不开”这类问题困扰,这篇文章应该能帮到你。

1. 被30GB CSV拖垮的那天之后,我下定决心换格式

1.1 那个让人崩溃的用户行为日志

当时我在处理一份用户行为日志,单文件30GB,大约8000万行、40列,里面有用户ID、事件类型、页面URL、设备类型、城市、时间戳、停留时长等字段。听起来就是很普通的数据表,但作为CSV出现时,它已经超出了绝大多数常规工具的处理边界。

Excel双击文件只会读取前104万行,然后给你看一片冰冷的内存不足提示;用Pycharm打开也是一整片没有任何对齐的原始文本,隔几秒就卡死;手机端的WPS倒是能打开,但那是因为手机版通常会做分页预览,根本不加载全部数据。更崩溃的是,我当时的Pandas脚本只要一执行read_csv,内存占用就冲上20GB,然后进程被杀。

这不是某个工具的Bug,而是CSV这个格式本身堆到了量级天花板。文本文件的处理成本随行数线性增长,而且“读进来”和“用起来”完全是两回事。当时我意识到:要么忍受每次几十秒甚至几分钟的加载,要么把数据换一种存法。

1.2 为什么“瘦身CSV”不是路,换格式才是

最直觉的想法是给CSV压缩。gzip一下确实能把它从30GB压到4~6GB,但每次做查询都要先解压,还要额外管理磁盘空间,速度并没有本质提升。另一种思路是拆分成几百个小CSV,每次查询还要自己决定扫描哪些文件,汇总统计时还是要全部扫一遍,治标不治本。

所以最终的方向是“换格式”:把纯文本的CSV转成带类型的列式存储格式。同样是这一份30GB文件,转成Parquet之后只有3GB,查询时只读需要的列,速度和存储双双解决。下面先把压缩的原理讲清楚,你才知道这个数字从哪里来。

2. CSV为什么这么“虚胖”:体积到底被什么撑大了

2.1 纯文本格式的每一字节都花在哪

CSV的本质是一份带分隔符的纯文本。它不区分“数字”和“文字”,所有内容都以字符形式写入,所以数字在文件里的存储开销远高于二进制表示。

举个最简单的例子:一个精度适中的浮点数“12345678.9”,在CSV里要按10个ASCII字符保存,占用10字节。同样这个数,在Parquet里作为double类型只占8字节。如果精度更高的数字长到15位,CSV要15字节,而Parquet里用fixed-length或delta编码后平均可能只有4到8字节。这只是单个数字的差距,8000万行乘以40列,积少成多就很可观。

再看时间戳。“2024-06-01 12:30:45”这串文本在CSV里是19字节,转成Parquet的timestamp类型后,底层通常按整数存储,可能只占8字节,配合delta编码还能更小。字符串里的空格、逗号、引号转义,也会让CSV文件多出大量无意义的填充字符。

2.2 重复数据是体积膨胀的主因

CSV是“行存储”的,每一条记录都是独立一行,列与列之间只有逗号分隔。行式存储最大的浪费在于:同一列在不同行的重复值,会被机械地重复写入。

以我那份日志来说,event_type字段大概只有20种取值,8000万行里每个取值被重复了几百万次,但CSV完全不在意,每次都原样把完整字符串写一遍。page_url字段更夸张,几百个真实页面都带有相同的前缀,比如“https://example.com/product/detail?id=1234”和“https://example.com/product/detail?id=5678”之间的公共部分长得惊人。user_id虽然唯一值多,但同一用户多次访问必然带来重复ID。

这不是个案。订单表里的商品分类、用户表里的城市名称、传感器数据里的设备编号,在CSV里都是这样反复写得满满当当。你看到的30GB,其实就是海量重复文本的累积。

2.3 列式存储+编码压缩:30GB变3GB的数学基础

Parquet这类列式格式的核心思路非常朴素:按列而不是按行存放数据。把8000万行的event_type放到一起,就是20种字符串组成的大数组,然后压缩算法就可以大显身手。

第一层是字典编码。20种取值先建立一个字典,每个值对应一个小整数,那么这一列在文件里就只需要记录每行对应哪个整数。20种取值只需要5个bit就能表达,8000万行大概也就20多MB,和原来几GB的字符串对比,压缩比非常可观。

第二层是RLE(Run-Length Encoding)。如果某种事件类型在日志里连续出现上千行,那就直接记录“这种类型连续出现了1500次”,不需要一行一行重复写。很多订单、埋点数据天然有局部聚集性,RLE效果很好。

第三层是数字列的delta编码。时间戳、自增ID这类数据相邻行之间差值很小,比如时间戳是每秒一条,差值基本是1,那么只需要存初始值和后续偏差,而不是每一行都存完整数字。

做完这些编码之后,再整体套一层Snappy或Zstd压缩,进一步把字符串中的重复片段处理掉。注意,Zstd压缩后的数据通常只有原始CSV的1/10左右,这个数量级对重复率高的业务数据来说完全正常。如果CSV里存的全是随机数、UUID这类不可压缩内容,压缩率会差很多,30GB变3GB的前提是数据本身有冗余。

3. 格式选型:为什么不选ORC和Arrow,偏偏选Parquet

3.1 三种常见候选格式对比

市面上的列式格式不只Parquet一种,我比较常用的是Parquet、ORC和Arrow(落盘时对应Feather)。放一张自己整理的对比表:

格式存储布局主要生态压缩能力适合场景
Parquet列式Spark、Presto、DuckDB、pandas、AWS Athena等强(字典 + RLE + Snappy/Zstd)分析型数据存储、数据湖、BI查询
ORC列式Hive、Spark(依赖Hive集成)强(支持轻量索引)大规模Hadoop/Hive生态
Arrow / Feather列式内存计算、Python生态一般(主要为内存交换设计)进程内数据传输、交互分析

单看压缩率,Parquet和ORC都在一个档次,但Parquet在我的场景里有一个决定性的优势:生态覆盖最广,几乎不需要配置就能接入各种工具。

3.2 Parquet和我的使用场景为什么这么搭

我当时的工作流是“本机Python脚本 + DuckDB + 少数BI报表”的组合,正好踩中Parquet的强项。

第一,列裁剪。Parquet按列组织数据,查询时如果只需要user_id和event_type,它不会去读另外38列。DuckDB读取Parquet的projection还有进一步下推,IO会少得可怜。原来CSV全表30GB,现在同样查询只需要读不超过1GB,速度自然上去了。

第二,自带schema。Parquet文件头里保存了每列的类型、编码和统计信息。别人拿到这个文件,不用问“这列是字符串还是时间戳”,文件自己就说明白了。CSV则完全靠猜,这是它们之间“专业格式”和“交换格式”的本质区别。

第三,统计信息跳过。Parquet的每个row group都记录了各列的最小值和最大值。比如你的查询条件是只筛某天的时间戳,文件里row group的min/max显示这个分组根本不在那段时间范围内,就直接跳过整块数据,连读取都省了。

3.3 什么情况不建议用Parquet

虽然Parquet很好,但它不是万能药。如果你的数据需要被下游系统以纯文本形式实时流式读取,比如后端程序逐行消费日志,那Parquet的二进制布局反而会增加解析成本;如果你需要高频小批量追加写入,Parquet也有点使不上力,因为它更适合整块写入而非逐行追加;如果你追求的是极致压缩,那可以去调Zstd压缩级别,但换取更多压缩率的同时转换耗时也会明显增加。

我当时的判断依据很简单:数据是一次性落下来、反复做分析查询的,典型的“写一次读多次”场景。Parquet是这个场景里最顺手的答案。如果你的场景也是低频写、高频读、多引擎查询,这个选择大概率也成立。

4. 30GB到3GB实操:分块策略、工具选型与完整命令

4.1 动手前先摸清文件底细

拿到30GB CSV,第一件事不是写转换代码,而是先回答三个问题:文件长什么样、磁盘够不够、内存能扛多少。我习惯用一组命令先探底:

head -n 5 big.csv tail -n 5 big.csv wc -l big.csv file big.csv df -h .

head和tail是为了看文件首尾的结构是否一致,防止最后一行是截断的脏数据;wc -l能估算总行数,但要注意如果某个字段里含换行符,这个数字会失真;file命令能识别编码是UTF-8还是带BOM的文本;df -h是用来确认转换过程中CSV和Parquet两份文件同时存在,还要预留临时文件空间。

我当时的实际情况是:至少需要保留30GB原文件、写入3GB目标文件,再加几GB临时文件,所以空闲空间低于40GB我就不会直接开跑。

4.2 首选方案:DuckDB一条SQL完成转换

真正执行转换时,我最推荐的方式是DuckDB,因为它可以流式读取CSV并直接写出Parquet,几乎不消耗什么内存。命令简单到一句话:

duckdb -c "COPY (SELECT * FROM read_csv('big.csv', header=true, auto_detect=true, sample_size=-1)) TO 'big.parquet' (FORMAT 'parquet', COMPRESSION 'zstd')"

这里有两个参数要说明。sample_size=-1表示用全量数据来推断每一列的类型,而不是默认的只采样前几千行。这个参数在数据尾部存在脏数据时特别关键,强行按默认采样可能会在转换到一半时失败。COMPRESSION='zstd'是压缩算法选择,实际测试下来压缩率比snappy再高10%~15%,解压速度也能接受。

如果担心字段名或类型推断不准确,可以手工指定schema:

COPY ( SELECT * FROM read_csv('big.csv', header = true, columns = { 'ts': 'TIMESTAMP', 'user_id': 'VARCHAR', 'event_type': 'VARCHAR', 'page_url': 'VARCHAR', 'device_type': 'VARCHAR', 'city': 'VARCHAR', 'duration_ms': 'INT' }) ) TO 'big.parquet' (FORMAT 'parquet', COMPRESSION 'zstd');

把上面这段存成convert.sql,然后执行duckdb < convert.sql。手工指定schema最稳,但要求你事前知道每一列的语义;如果你对列不熟,先用auto_detect跑一遍看看schema,再决定要不要覆盖。

我自己实测,DuckDB在处理30GB文件时峰值内存大概3GB左右,远远低于Pandas的20GB。它对外表现更像一个把压力放在磁盘IO上的流式管道,特别适合单机大文件转换。

提示:如果CSV文件编码不是UTF-8而是GBK,read_csv默认会失败或乱码。先用iconv把整体编码转成UTF-8:iconv -f GBK -t UTF-8 big.csv > big_utf8.csv。30GB级别的转换需要提前留好额外磁盘空间。

4.3 备选方案:pandas分块加pyarrow手动写

如果你不想引入DuckDB,只用Python也能完成。思路是用pandas按分块读取CSV,每读取100万行就通过pyarrow写进同一个Parquet文件。示例代码如下:

import pandas as pd import pyarrow as pa import pyarrow.parquet as pq csv_path = "big.csv" parquet_path = "big.parquet" chunk_size = 1_000_000 writer = None for chunk in pd.read_csv( csv_path, chunksize=chunk_size, # dtype={"duration_ms": "int32"}, # 明确指定类型,避免类型推断失败 # parse_dates=["ts"], # 告诉pandas哪些列是时间 ): table = pa.Table.from_pandas(chunk, preserve_index=False) if writer is None: writer = pq.ParquetWriter( parquet_path, table.schema, compression="zstd" ) writer.write_table(table) if writer is not None: writer.close()

这段代码的关键是chunksize不能太大,否则单块就占掉大量内存;也不能太小,不然频繁创建表结构开销很亏。100万行一个chunk在大多数16GB内存机器上都算稳妥。如果某列在某个chunk里的取值触发类型异常,最稳的办法就是在read_csv时就显式给定dtype,相当于把类型判断提前到转换之前。

需要提醒的是,pandas分块方案的速度比DuckDB慢不少,但它有一个优点:转换逻辑完全透明,每一步都能插入日志和断点检查。

4.4 转换完成后的三道校验关

格式换完之后,我最担心的不是文件变小了,而是数据有没有在转换过程中丢行或错乱。我的校验分三步走。

第一步,行数对比。在DuckDB里分别统计CSV和Parquet的行数,两边应该完全相等:

SELECT count(*) FROM read_csv('big.csv', header=true, auto_detect=true, sample_size=-1); SELECT count(*) FROM 'big.parquet';

如果CSV里有字段包含换行符,count结果反而可能比wc -l更准,因为DuckDB的CSV解析器会正确处理引号包裹的换行。

第二步,抽样对比。取CSV第1万行、第1000万行、第7000万行附近的数据,和Parquet里对应偏移位置的数据逐字段比对。这一步主要是抓字段错位的低级错误。

第三步,统计值对比。分别计算关键列的sum、min、max和唯一值数量,比如时间戳范围、用户数、事件类型分布。如果两边分布完全一致,基本可以放心把CSV归档走。

顺便说一句,网上很多人会问“CSV文件怎么进行MD5校验”。对30GB这种量级,跑一遍MD5要很久,而且校验完之后也不能证明字段语义没变。行数加抽样加分布对比这套组合,在转换场景下比MD5更实用,速度也快得多。

5. 转换中真实踩过的坑:四段排查记录

5.1 类型推断错误:整数列里混进一个“不详”

第一次用DuckDB转换时,我直接用了默认的read_csv。文件前10000行看起来一切正常,ts、user_id、event_type、duration_ms都判断无误。结果跑到第十几分钟,控制台抛出一个类型转换异常,提示某一行期望数值却遇到了“不详”这个字符串。

排查下来发现是一个埋点上报异常,device_type列里混入了人工标记的“不详”二字。默认sample_size只采样了前10000行,当然看不到这个尾部脏数据。解决方式有两个:要么用sample_size=-1强制全量采样,要么直接在read_csv里手工指定schema。我最终还是选择手工指定schema,因为全量采样在大文件上会拖慢首轮扫描速度。

注意:预先知道脏数据存在是好事,怕的是不知道。转换之前跑一遍全表的字段类型分布检测,比如用DuckDB的read_csv加sample_size=-1来生成schema,是最划算的防错手段。

5.2 BOM头让第一列列名变成“\ufeffuser_id”

第二次转换时换了一个来源的CSV,一切就绪,结果查询时发现第一列的列名怎么都group不出来。用DuckDB查看schema才发现列名是“\ufeffuser_id”——文件开头带了一个UTF-8的BOM标记,被解析成了列名的一部分。

这种现象在Excel或WPS另存为CSV时特别常见,肉眼看不出来,却会实实在在影响后面的字段引用。解决方式我试过三种,最省事的是在DuckDB读取时,把列名里带BOM的字段做一次别名映射:

SELECT "\ufeffuser_id" AS user_id, event_type FROM read_csv('big.csv', header = true, auto_detect = true, sample_size = -1 );

这个方案不用重写30GB文件,查询时临时改一下名字就行。更一劳永逸的方案是在转换入口去掉BOM,比如用GNU sed处理文件的第一行:

sed -i '1s/^\xEF\xBB\xBF//' big.csv

不过sed -i会重写整个30GB文件,时间成本也不低。我的实际做法是接受这个脏列名,只在读取时做rename。因为Parquet文件里列名一旦写错,后续所有查询都要补一层映射,成本更高。所以正式转换前,务必用xxd或file命令检查是不是带BOM。

5.3 磁盘空间在转换中途告急

有一次我自信满满地开始转换,跑到中途系统报No space left on device,整个人直接愣住。一看df -h,那块盘只剩20GB空间,而我的CSV是30GB,Parquet还要写3GB,DuckDB还会用磁盘做临时buffer,多个因素叠加直接把盘塞满了。

这次事故给我的教训是:大文件转换前必须先规划磁盘,而不是只盯着源文件和目标文件算大小。DuckDB允许显式设置临时目录:

SET temp_directory='/data/tmp';

如果另一块盘空间充足,把临时目录指过去就不会碰到这个问题。另一个更保守的方法是分块转换,比如把CSV按月份拆成若干个Parquet文件,再通过DuckDB的统一查询能力去读,但这会稍微增加后续管理成本。

5.4 “NaN”字符串和NULL:空值的三种写法

CSV里的空值简直是深水炸弹。我遇到过同一列里同时出现空字符串、单词“NaN”和真正的空值,它们在人眼看来都算“空”,但在CSV文本里就是三种不同的字面量。

如果不做处理直接转换,Parquet里可能会把“NaN”当成一个普通字符串列,把空字符串当成另一个独立取值,导致后续统计时sum结果莫名变少,或者group by结果里多出一行意想不到的“NaN”。我后来统一在read_csv里声明空值语义:

SELECT * FROM read_csv('big.csv', header = true, auto_detect = true, sample_size = -1, nullstr = ['', 'NaN', 'NULL'] );

这样“NaN”和“NULL”在读取阶段就被统一映射成真正的NULL,后续统计才不会被奇怪的字符串影响。这个细节虽然很小,但对数据质量的影响却不小,尤其是你后面还要做聚合计算时。

6. 省下27GB之后,工作流到底发生了什么变化

6.1 性能对比:同一查询从40秒到0.3秒

格式换完,最直观的感受是查询变快。同样的数据,我做过一次简单的对比:

  • 用Pandas全量读CSV,再按event_type分组统计,整个过程需要50秒左右,内存峰值超过20GB;
  • 换成Parquet后,用pyarrow列裁剪只读event_type和user_id两列,同样的分组统计在1秒左右完成;
  • 用DuckDB直接查询Parquet,加上行组统计信息跳过,条件过滤场景只需要0.3秒。

这些数字当然和机器配置、数据分布有关,但量级的差距是实打实的。存储从30GB降到3GB,查询时只读需要的列,IO成本被砍掉超过90%。

6.2 生态协同:Parquet在各种工具中的免配置体验

换格式以后,另一个惊喜是生态协同顺滑了很多。pandas直接pd.read_parquet('big.parquet')就能读,不再需要担心内存爆掉;DuckDB执行select * from 'big.parquet'原生支持,不用安装任何扩展;Power BI和Superset这类BI工具也能通过各自的Parquet连接器直接读。

Parquet文件自带schema,每次加载不用再猜列类型。同事拿到文件后问“这列的日期是字符串还是时间?”——这类问题的出现频率直接降为零。相比CSV,它确实更接近“一个结构清晰的数据表”而非“一份可读的文本”。

6.3 还可以再进一步:分区、Zstd与row group

转换完成后并不是终点,有几个方向还能继续优化。

第一是分区。如果数据按日期字段会被频繁过滤,可以在转换时按日期分区成多个Parquet文件,查询时DuckDB会只扫描涉及的分区。分区字段的选择要结合常用查询条件,分不好反而会让文件数爆炸。

第二是压缩级别。Zstd压缩级别可以继续调高,文件还能再小一些,但压缩时间会成倍增加。对一次写入多次读取的场景,我更倾向用较高的压缩级别来换永久存储空间和查询IO。如果追求速度,zstd level 3已经能压得很快也压得很好。

第三是row group的大小。Parquet文件由多个row group组成,每个row group内部有统计信息和列数据。DuckDB默认会按写入块大小生成,pyarrow写Parquet时也能通过row_group_size参数控制。行组越大,统计信息跳过效果越明显,但如果单组过大,列裁剪时加载的数据粒度也会变大,需要根据常见查询条件做平衡。我一般把row_group_size设在500万到1000万行左右。

6.4 这次换格式带给我的三个教训

回头看看,从被30GB CSV折磨到最终把它换成3GB的Parquet,整个过程给我最深的三点体会是:存储格式本身是一种性能优化手段,而且往往比业务代码层面的优化效果大得多;对几十GB的单文件来说,问题早已不是“能不能打开”,而是“用对格式和工具去读”;像DuckDB、Arrow这类流式工具组合起来,完全可以在单机上处理过去以为必须上集群才能跑的数据。

现在我手里所有新项目默认都是Parquet落地,CSV只作为对外交接格式保留。除非合作的系统非要CSV不可,否则我几乎不会再主动存一份30GB的文本文件。大文件换格式这件事,做一次可能只需要几分钟到十几分钟,但换来的查询速度会持续回馈后续每一次取数、每一份报表、每一个临时分析任务。如果你手上也躺着几个几GB甚至几十GB的CSV,真的建议尽早给它们换个房子住。

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

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

立即咨询