如果你做过数据分析,大概率经历过这种烦躁:一个 CSV 文件几十 GB,明明只想查两列,却必须把整行整行扫过去;明明只想要 date = '2026-05-08' 的数据,程序还是老老实实从头读到尾。
Parquet 解决的不是“文件能不能存下数据”这么朴素的问题。它真正解决的是:
当数据大到不能随便全量读取时,文件格式本身能不能帮助查询引擎少读、快读、压缩读。
这也是很多人第一次理解 Parquet 时最容易错过的点。Parquet 不是“压缩过的 CSV”,它是把 查询路径、列式布局、统计信息、编码压缩 一起设计进文件格式里的数据存储格式。
一、先把结论放前面
Parquet 是一种面向分析场景的 列式存储文件格式,最适合这种数据:
- 数据量大
- 字段多
- 经常只查部分列
- 经常按时间、地区、用户类型等条件过滤
- 主要追加写入,很少单行更新
它不适合这种数据:
- 频繁更新单行
- 高并发事务写入
- 强 OLTP 查询
- 小文件数量极多
- 需要像数据库索引一样随机改某一行
一句话版本:
CSV 是把数据写给人和简单程序看的;Parquet 是把数据写给查询引擎看的。
这句话记住,后面所有设计都会变得顺理成章。
二、CSV 和 JSON 的根本问题:它们太“行式”了
假设有一张用户行为表:
| user_id | event | amount | city | created_at |
|---|---|---|---|---|
| 1001 | pay | 99.9 | Shanghai | 2026-05-08 10:01:00 |
| 1002 | view | 0 | Beijing | 2026-05-08 10:02:00 |
| 1003 | add_cart | 32.5 | Chengdu | 2026-05-08 10:03:00 |
CSV 大概会这样存:
user_id,event,amount,city,created_at
1001,pay,99.9,Shanghai,2026-05-08 10:01:00
1002,view,0,Beijing,2026-05-08 10:02:00
1003,add_cart,32.5,Chengdu,2026-05-08 10:03:00
这对人类很友好,但对分析查询很浪费。
如果你只想算:
SELECT sum(amount)
FROM events
WHERE event = 'pay';
CSV 读者依然要把每一行的 user_id、event、amount、city、created_at 都扫过去,然后再丢掉大部分字段。
JSON Lines 更灵活,但问题更重:
{"user_id":1001,"event":"pay","amount":99.9,"city":"Shanghai","created_at":"2026-05-08 10:01:00"}
{"user_id":1002,"event":"view","amount":0,"city":"Beijing","created_at":"2026-05-08 10:02:00"}
每一行都重复字段名,类型还要动态解析。小数据无所谓,大数据一上来,CPU 和 IO 都会开始替你交税。
三、列式存储的核心 aha:同一列的数据应该放在一起
Parquet 的第一个关键转身是:不要按行连续存,而是按列连续存。
行式存储像这样:
row 1: user_id, event, amount, city, created_at
row 2: user_id, event, amount, city, created_at
row 3: user_id, event, amount, city, created_at
列式存储更像这样:
user_id: 1001, 1002, 1003, ...
event: pay, view, add_cart, ...
amount: 99.9, 0, 32.5, ...
city: Shanghai, Beijing, Chengdu, ...
created_at: ...
这个变化带来三个直接收益。
第一,只读需要的列。查 sum(amount) 时,查询引擎可以不碰 city 和 created_at。
第二,同类型数据更好压缩。一列里的值类型一致、分布相近,压缩算法更容易找到规律。
第三,统计信息更有用。如果某个数据块的 created_at 最大值都小于查询日期,引擎可以直接跳过它。
Parquet 的很多性能优势都来自这个朴素判断:分析查询的最小成本,不是“读得更快”,而是“尽量不要读”。
四、Parquet 文件内部长什么样
Parquet 文件可以粗略理解成四层:
Parquet File
Row Group 1
Column Chunk: user_id
Page 1
Page 2
Column Chunk: event
Page 1
Page 2
Column Chunk: amount
Page 1
Page 2
Row Group 2
Column Chunk: user_id
Column Chunk: event
Column Chunk: amount
Footer Metadata
官方概念里有三个词非常重要:
| 概念 | 直觉理解 | 作用 |
|---|---|---|
| Row Group | 一批行的水平分块 | 并行读取、过滤跳过、控制读写粒度 |
| Column Chunk | 某个 Row Group 里某一列的数据 | 支撑列裁剪,只读需要的列 |
| Page | Column Chunk 内部更小的数据页 | 编码、压缩、页级统计、细粒度跳过 |
文件尾部还有一块 metadata,里面会记录 schema、row group 位置、column chunk 位置、行数、压缩方式、统计信息等。
Parquet 文件开头和结尾都有 PAR1 magic number。真正关键的是文件尾部:读者通常会先读 footer,知道数据在哪里、有哪些列、每块数据的统计范围,再决定要读哪些 column chunk。
这就解释了 Parquet 为什么适合对象存储和数据湖。它不是傻乎乎从头读到尾,而是先看“地图”,再去拿真正需要的数据。
五、Row Group:Parquet 的并行和跳过单位
Row Group 是 Parquet 里最值得花时间理解的结构。
假设一个文件有 1 亿行,Parquet 不会把所有行混成一个巨大块,而是切成多个 row group:
row group 0: 第 0 到 999999 行
row group 1: 第 1000000 到 1999999 行
row group 2: 第 2000000 到 2999999 行
...
每个 row group 内部,再按列拆成 column chunk。
这带来两个能力。
第一,并行读。 不同线程可以处理不同 row group。
第二,整块跳过。 如果查询条件是:
WHERE created_at >= '2026-05-08'
而某个 row group 的 created_at 统计信息是:
min = 2026-05-01
max = 2026-05-03
那这个 row group 对结果没有任何贡献,可以直接跳过。
这就是 predicate pushdown 的基础。查询条件不是等数据读出来后才判断,而是尽可能“推”到文件扫描阶段,用 metadata 提前剪枝。
六、Column Chunk:为什么查两列就真的只读两列
在一个 row group 里,每一列都有自己的 column chunk。
比如查询:
SELECT user_id, amount
FROM events
WHERE event = 'pay';
查询引擎通常只需要读取:
event column chunk
user_id column chunk
amount column chunk
city、device、referer、payload 这些列,如果没有出现在查询条件或返回结果里,就可以不读。
这对宽表非常狠。
很多日志表、埋点表、交易明细表有几十列甚至上百列,但一次分析常常只关心其中几列。CSV 的成本接近“整表宽度”,Parquet 的成本更接近“本次查询用到的列宽”。
所以你会看到一个很反直觉的现象:同样是 100GB 原始数据,Parquet 查询可能只实际读几 GB,甚至更少。
七、Page、编码和压缩:Parquet 为什么能小很多
Parquet 不是简单地把一列原样压缩。它通常会先做 encoding,再做 compression。
这两个词容易混。
| 步骤 | 做什么 | 例子 |
|---|---|---|
| Encoding | 利用数据类型和分布改写表示方式 | 字典编码、RLE、Delta 编码 |
| Compression | 对编码后的字节流做通用压缩 | Snappy、Zstd、Gzip |
举个最常见的字段:event。
pay, view, view, view, add_cart, pay, view
这列重复值很多。Parquet 可以用字典编码,把字符串表变成:
dictionary:
0 = pay
1 = view
2 = add_cart
values:
0, 1, 1, 1, 2, 0, 1
字符串被替换成整数后,再压缩就舒服多了。
对连续递增或变化平滑的数字,可以用 Delta 编码存差值。对大量重复值,可以用 RLE。对浮点数,还可能使用 byte stream split 这类更适合后续压缩的布局。
所以 Parquet 的压缩率往往不是因为某个压缩算法特别神,而是因为它先把数据摆成了压缩算法喜欢的样子。
八、Footer Metadata:查询引擎的“藏宝图”
Parquet 的 footer metadata 很关键。里面不仅有 schema,还有每个 row group、每个 column chunk 的位置和统计信息。
常见统计信息包括:
num_values
null_count
min
max
compression
encoding
这些 metadata 让查询引擎可以做几件事:
| 优化 | 含义 |
|---|---|
| Projection Pushdown | 只读查询需要的列 |
| Predicate Pushdown | 用 min/max/null_count 跳过不可能命中的块 |
| Parallel Scan | 多个 row group 并行扫描 |
| Schema Discovery | 不读全量数据也能知道表结构 |
这里要注意一个边界:Parquet 的 min/max 不是数据库 B-Tree 索引。
它能帮你跳过“不可能命中”的块,但不能像传统索引一样精准定位某一行。如果文件没有按过滤字段排序,或者 row group 里数据分布很散,统计信息的剪枝效果就会下降。
这也是为什么数据湖里经常会配合分区目录、排序、Z-Order、Iceberg/Delta/Hudi 表格式一起使用。Parquet 负责单个文件里的高效存储,表格式和布局策略负责更大范围的数据组织。
九、动手写一个 Parquet 文件
用 Python 和 PyArrow 最直观。
安装依赖:
pip install pyarrow duckdb
写入 Parquet:
import pyarrow as pa
import pyarrow.parquet as pq
table = pa.table(
{
"user_id": [1001, 1002, 1003, 1004],
"event": ["pay", "view", "add_cart", "pay"],
"amount": [99.9, 0.0, 32.5, 18.0],
"city": ["Shanghai", "Beijing", "Chengdu", "Shanghai"],
}
)
pq.write_table(
table,
"events.parquet",
compression="zstd",
row_group_size=100_000,
)
只读部分列:
import pyarrow.parquet as pq
table = pq.read_table(
"events.parquet",
columns=["user_id", "amount"],
)
print(table)
用 DuckDB 直接查:
duckdb -c "SELECT event, count(*) AS cnt, sum(amount) AS total FROM read_parquet('events.parquet') GROUP BY event;"
这就是 Parquet 很舒服的地方:它不是某个数据库的私有格式,而是被 Spark、DuckDB、Trino、Presto、Flink、Pandas、Polars、ClickHouse 等工具广泛支持的开放列式格式。
十、查看 Parquet 的 metadata
理解 Parquet 最快的方式,是亲手看一下 metadata。
import pyarrow.parquet as pq
pf = pq.ParquetFile("events.parquet")
print(pf.schema)
print(pf.metadata)
for i in range(pf.metadata.num_row_groups):
row_group = pf.metadata.row_group(i)
print("row group:", i)
print("rows:", row_group.num_rows)
print("bytes:", row_group.total_byte_size)
for j in range(row_group.num_columns):
column = row_group.column(j)
print(column.path_in_schema, column.compression, column.encodings)
print(column.statistics)
你会看到每列的压缩方式、编码方式、统计信息。看到这些东西,Parquet 就不再是一个“神秘高性能文件格式”,而是一套非常工程化的取舍:
把数据按列放好
把列切成可管理的块
给每个块留下统计信息
让读取器少读无关数据
这就是它的全部锋利之处。
十一、Parquet 和分区目录:不要把所有希望压在单文件上
Parquet 很强,但它不是数据湖布局的全部。
真实项目里通常会这样组织:
events/
dt=2026-05-06/
part-000.parquet
part-001.parquet
dt=2026-05-07/
part-000.parquet
part-001.parquet
dt=2026-05-08/
part-000.parquet
part-001.parquet
这种叫 Hive-style partitioning。dt=2026-05-08 不是文件内容,而是目录名里的分区信息。
查询:
SELECT count(*)
FROM read_parquet('events/*/*.parquet')
WHERE dt = '2026-05-08';
引擎可以先通过目录跳过其他日期,再进入目标 Parquet 文件内部,用 row group 和 page metadata 继续剪枝。
分区的核心原则是:高频过滤字段适合做分区,但不能分得太碎。
常见选择:
| 数据类型 | 常见分区 |
|---|---|
| 日志、埋点 | dt、hour |
| 订单、交易 | dt、region、biz_type |
| 多租户数据 | tenant_id、dt |
| IoT 数据 | dt、device_type、site |
不要把高基数字段无脑拿来分区,比如 user_id、order_id。这样很容易制造海量小目录和小文件,查询引擎还没开始干正事,光列文件清单就累了。
十二、写入参数怎么选
Parquet 的性能不只取决于“是不是 Parquet”,还取决于写入参数。
1. 文件大小
数据湖里最怕“小文件地狱”。
一个常见经验值:
单个 Parquet 文件压缩后尽量在 128MB 到 1GB 之间
太小,调度和元数据开销大;太大,单任务处理慢,失败重试成本高。
2. Row Group 大小
官方配置文档里推荐较大的 row group,比如 512MB 到 1GB,原因是大 row group 能带来更大的连续 IO 和更好的压缩。但真实项目里要看引擎、内存和对象存储环境。
可以粗略记成:
批量分析、大宽表:row group 可以大一些
内存紧张、点查较多:row group 不要过大
如果你用 Spark、DuckDB、PyArrow 这类工具,通常先用默认值或团队已有规范,不要一上来就微调到飞起。Parquet 参数优化很吃数据分布,拍脑袋常常不如实测。
3. 压缩算法
常见选择:
| 压缩 | 特点 | 适合场景 |
|---|---|---|
| Snappy | 压缩率一般,速度快,兼容性好 | 通用默认、安全选择 |
| Zstd | 压缩率好,速度也不错 | 存储成本敏感、现代引擎 |
| Gzip | 压缩率好,速度偏慢 | 冷数据、兼容老链路 |
| None | 不压缩 | 少见,通常只用于测试 |
我更偏向的默认选择是:新链路优先试 Zstd,兼容性保守时用 Snappy。
4. 排序
如果经常按 created_at 查询,把数据按 created_at 排序后再写 Parquet,row group 的 min/max 会更集中,剪枝效果会好很多。
比如未排序:
row group 0: min=2026-05-01, max=2026-05-31
row group 1: min=2026-05-01, max=2026-05-31
查询任意一天,两个 row group 都可能要读。
按时间排序后:
row group 0: min=2026-05-01, max=2026-05-03
row group 1: min=2026-05-04, max=2026-05-06
查询 2026-05-05,就能跳过大量无关块。
十三、Schema:Parquet 的强约束,也是一种保护
CSV 的字段类型通常要读的时候猜。Parquet 会把 schema 写进文件 metadata。
这很重要。
user_id: int64
event: string
amount: double
created_at: timestamp
有了 schema,读者不需要从字符串里重新猜类型,跨工具读取也更稳定。
但 schema 也会带来演进问题。
比较安全的变化:
新增可空列
放宽某些兼容类型
保留旧字段语义
危险变化:
同名字段改类型
同名字段改含义
删除下游仍在使用的列
不同分区写出不兼容 schema
很多数据湖事故不是 Parquet 本身坏了,而是同一个目录下混进了 schema 不一致的文件。今天 amount 是 double,明天某个任务写成 string,后天查询引擎直接开始怀疑人生。
所以生产链路里,Parquet 往往需要配合 schema registry、表格式或写入校验,而不是让每个任务随手写。
十四、嵌套结构:Parquet 怎么存数组和对象
Parquet 不只能存扁平表,也支持嵌套结构,比如:
{
"user_id": 1001,
"items": [
{ "sku": "A001", "price": 19.9 },
{ "sku": "B002", "price": 29.9 }
]
}
这类数据的难点在于:列式存储喜欢“一列一列放”,但数组和对象天然有层级。
Parquet 的解法是 definition level 和 repetition level。
可以不用死背名字,先理解直觉:
| 信息 | 解决什么问题 |
|---|---|
| Definition Level | 这个嵌套路径上的值到底存不存在,用来表达 null |
| Repetition Level | 当前值是不是同一个父对象里的重复元素,用来表达 list |
也就是说,Parquet 会把嵌套数据拆成列,同时用额外的 level 信息保留“这个值属于哪个数组、哪个对象、是不是空值”。
这个设计很强,但也意味着:嵌套层级越深,读写和理解成本越高。
如果数据本质上是分析宽表,别为了“贴近原始 JSON”把所有东西塞成复杂嵌套结构。能扁平化的关键字段,优先扁平化。原始 payload 可以另存,但不要让每次分析都被深层嵌套拖住。
十五、Parquet 不是什么
Parquet 被用得太广后,很容易被误解成“数据湖万能药”。它不是。
1. Parquet 不是数据库
它没有事务、锁、二级索引、并发更新控制。你不能期待它像 MySQL 那样稳定处理高并发单行写入。
2. Parquet 不是表格式
Parquet 只是文件格式。它不知道一张表有哪些快照,不知道哪些文件被删除,不知道事务提交是否成功。
Delta Lake、Apache Iceberg、Apache Hudi 这类表格式,解决的是更上层的问题:
事务提交
快照管理
Schema 演进
增量读取
文件清理
分区演进
它们经常把 Parquet 当底层数据文件,但它们不是 Parquet 的替代品,而是 Parquet 上面的治理层。
3. Parquet 不适合大量小写
每秒写一条,每条生成一个 Parquet 文件,这是灾难现场。
Parquet 更适合批量写入:
先缓冲
再批量生成较大的文件
再提交到数据湖
实时链路里常见做法是:Kafka 接入,Flink/Spark Streaming 做微批,最后按时间窗口写 Parquet。
十六、和 CSV、JSON、Avro、ORC 怎么选
可以用这张表快速判断:
| 格式 | 存储方式 | 优点 | 短板 | 适合 |
|---|---|---|---|---|
| CSV | 行式文本 | 简单、人能读、兼容极强 | 无强 schema、类型解析慢、压缩和裁剪差 | 小数据交换、人工查看 |
| JSON Lines | 行式文本 | 半结构化友好、灵活 | 体积大、解析慢、字段名重复 | 日志原始落盘、接口数据 |
| Avro | 行式二进制 | Schema 清晰、流式写入友好 | 分析查询列裁剪不如 Parquet | 消息、事件流、行级处理 |
| ORC | 列式二进制 | 分析性能强、Hive 生态深 | 生态偏 Hadoop/Hive | Hive 数仓 |
| Parquet | 列式二进制 | 生态广、列裁剪强、压缩好 | 不适合频繁单行更新 | 数据湖、OLAP、离线分析 |
我的经验判断很简单:
给人看、临时交换:CSV
保留原始半结构化日志:JSON Lines
事件流和强 schema 消息:Avro
Hive 重度生态:ORC
通用数据湖和分析查询:Parquet
十七、真实项目里的 Parquet 检查清单
落地时,比“我用了 Parquet”更重要的是这些细节:
- 文件不要太小,先把小文件合并掉。
- 高频过滤字段做目录分区,但别拿高基数字段乱分。
- 常用过滤字段尽量排序写入,让 min/max 真能剪枝。
- 新链路可以优先评估 Zstd,保守兼容用 Snappy。
- 明确 schema 演进规则,不要让不同任务随手改类型。
- 不要把 Parquet 当数据库做单行更新。
- 用 DuckDB、PyArrow 或 parquet-tools 定期检查 metadata。
- 对象存储上注意 listing 成本,不要制造海量小目录。
- 数据湖表最好交给 Iceberg、Delta 或 Hudi 管快照和事务。
如果只能记三句话,我会这样记:
Parquet 的性能来自少读,不只是读得快。
Parquet 的压缩来自列式布局加编码,不只是压缩算法。
Parquet 是文件格式,不是数据库,也不是完整的数据湖表。
十八、一个更贴近本质的理解
很多文件格式的默认假设是:数据写进去,以后有人完整读出来。
Parquet 的默认假设更像是:数据写进去,以后查询引擎会带着目的来读它,而且最好读得越少越好。
这就是它在现代数据栈里变成默认选择的原因。不是因为它名字高级,也不是因为大数据圈子喜欢造概念,而是因为它把一个冷冰冰的工程事实刻进了文件结构里:
在分析系统里,最便宜的 IO 是根本不发生的 IO。
理解了这一点,再看 row group、column chunk、page、footer metadata、encoding、compression,就不再是一堆术语,而是一套围绕“少读数据”展开的精密设计。
参考资料:
- Apache Parquet File Format: https://parquet.apache.org/docs/file-format/
- Apache Parquet Concepts: https://parquet.apache.org/docs/concepts/
- Apache Parquet Configurations: https://parquet.apache.org/docs/file-format/configurations/
