Parquet文件格式原理与实战:列式存储、压缩编码与生产避坑
1. 为什么今天的数据工程师,几乎没人再用 CSV 存原始数据了?
我第一次在生产环境里把一个 2TB 的用户行为日志从 CSV 换成 Parquet,是在 2019 年底。当时 Spark 作业跑一次聚合要 47 分钟,改完格式后,同一份 SQL 在同样集群上只用了 6 分 23 秒——不是优化了代码,没动一行逻辑,只是把 df.write.csv("s3://...") 换成了 df.write.parquet("s3://...")。更关键的是,存储成本直接掉了 68%:原来每天新增 120GB 原始日志,换成 Parquet 后压到 39GB,而查询延迟反而更稳。这不是玄学,是列式存储、字典编码、页级统计和谓词下推这些设计,在真实数据流里打出的组合拳。
Apache Parquet 不是“又一个文件格式”,它是现代数据栈的底层操作系统级协议。你可能没写过一行 Parquet 相关代码,但只要用过 Spark SQL、Athena 查 S3、BigQuery 查询 Cloud Storage,甚至只是在 Databricks notebook 里 display(df),背后全都是 Parquet 在默默扛着。它解决的从来不是“怎么存”,而是“怎么让万亿行数据像查内存一样快”。它的核心价值,藏在三个被低估的事实里:第一,它把“读多少数据”这件事,从应用层移交给了存储层本身;第二,它让 schema 变得可演进、可兼容、可回溯,而不是一纸契约;第三,它把压缩、编码、索引这些传统数据库内核能力,下沉到了文件格式这一层,让任何支持它的引擎(无论 Spark、Trino 还是 DuckDB)都能开箱即用。
这篇文章不讲抽象概念,不堆术语定义。我会带你拆开一个真实的 .parquet 文件,看它内部的 row group、column chunk、page 是怎么一层层组织的;手把手写四套实操代码——用 pandas 最简方式落地、用 PyArrow 精确控制压缩与编码、用 PySpark 处理 TB 级分区数据、用 DuckDB 直接在本地秒查百万行;更重要的是,告诉你我在金融风控、电商实时数仓、IoT 设备日志三个不同场景踩过的坑:比如为什么 snappy 在 SSD 上比 zstd 快 1.8 倍,但在 HDD 上反而慢;为什么按 user_id 分区会导致小文件爆炸,而按 dt=20240512/hour=14 分区却能稳定在 256MB/文件;还有那个让整个团队加班到凌晨的 bug——当 Spark 写入时自动添加的 _SUCCESS 文件被 Athena 误读为数据文件,导致查询结果多出 12 行空记录。所有内容,都来自我过去五年在三家不同规模公司落地 Parquet 的实战笔记。
2. Parquet 的底层设计:为什么它天生就适合大数据分析?
2.1 列式存储不是“把行转成列”那么简单
很多人初学 Parquet,第一反应是:“哦,就是把 CSV 的横向存储改成纵向?” 这个理解太浅了。CSV 是纯文本,没有结构;Parquet 是二进制,自带骨架。它的列式,是物理存储层面的强制约束,直接决定了 I/O 路径。我们来看一个最基础的对比:
假设你有一张用户表,含 1000 万行,5 个字段:user_id(BIGINT), name(STRING), age(INT), city(STRING), reg_time(TIMESTAMP)。用 CSV 存储时,磁盘上是连续的 1000 万行文本,每行包含全部 5 个字段的值,用逗号分隔。当你执行 SELECT name, city FROM users WHERE age > 30,系统必须:
- 逐行扫描全部 1000 万行;
- 对每一行解析整行字符串,提取
age字段做比较; - 如果满足条件,再提取
name和city字段拼成结果。
这叫“全表扫描+行内解析”,I/O 和 CPU 都在做无用功。而 Parquet 的物理布局完全不同:它把这 1000 万行,按固定大小(默认 128MB)切分成多个 row group(行组)。每个 row group 内部,不是存“行”,而是存 5 个独立的 column chunk(列块)——user_id 列块、name 列块……每个列块再切成更小的 page(页),每页默认 1MB。关键来了:当你执行同样的 SQL,Parquet 引擎会:
- 先读取每个 row group 的 footer(页脚元数据),拿到该 group 中
age列的 min/max 统计值; - 发现某个 row group 的
age.max < 30,直接跳过整个 group(I/O 完全省掉); - 对剩下需要检查的 group,只加载
age列块的 pages 进行过滤; - 过滤出满足条件的行号(row index),再用这些行号去精准加载
name和city列块中对应位置的 pages; - 完全不碰
user_id和reg_time列块,零 I/O。
这就是列式存储的威力:它把“读什么数据”的决策权,从应用逻辑层,交给了存储格式自身。而这个决策的依据,就藏在 Parquet 文件的 footer 里。
2.2 文件结构解剖:从 footer 开始逆向阅读
Parquet 文件是一个自描述的二进制容器,结构严格遵循 Parquet Format Specification。它没有“头文件”,真正的入口是文件末尾的 footer。你可以把它想象成一本书的目录和索引,放在了书的最后一页。一个典型的 Parquet 文件结构如下(按字节顺序从头到尾):
-
File Metadata (Footer):这是整个文件的“大脑”,固定位于文件末尾,长度由最后 4 个字节的
footer_length字段指定。它包含:schema:完整的嵌套 schema,包括字段名、类型、是否可空、重复/可选层级(对 JSON/Avro 数据至关重要);row_groups:一个数组,每个元素描述一个 row group 的起始偏移量、大小、各 column chunk 的偏移量和大小;key_value_metadata:用户自定义键值对,常用于存储业务标签(如source=web_app,etl_job_id=20240512_001);created_by:生成该文件的库和版本(如parquet-cpp version 12.0.1)。
-
Row Groups:文件的逻辑分片单元。每个 row group 包含一批连续的行(例如 100 万行),是并行读写的最小单位。Spark 读取 Parquet 时,一个 task 通常处理一个 row group。它的核心价值在于:
- 统计信息下推:每个 row group 的 footer 中,都记录了其内部每个 column chunk 的
min,max,null_count。这是谓词下推(Predicate Pushdown)的基础。 - 独立压缩:每个 row group 可以用不同的压缩算法,适应不同数据特征。
- 统计信息下推:每个 row group 的 footer 中,都记录了其内部每个 column chunk 的
-
Column Chunks:每个 row group 内,为每个列单独存储一个 column chunk。这是列式的核心体现。chunk 内部不是裸数据,而是经过编码和压缩的 pages。
-
Pages:column chunk 的物理存储单元。每个 page 包含:
data_page:实际数据,已应用编码(如 RLE、Dictionary);dictionary_page(可选):字典编码的字典表,