backgroundbackground

Parquet 数据存储详解:它为什么是数据湖里的默认格式

Parquet / 数据湖 / 列式存储 / Apache Parquet / OLAP / DuckDB / PyArrow / 大数据存储

教程

2026-05-08 08:28

如果你做过数据分析,大概率经历过这种烦躁:一个 CSV 文件几十 GB,明明只想查两列,却必须把整行整行扫过去;明明只想要 date = '2026-05-08' 的数据,程序还是老老实实从头读到尾。

Parquet 解决的不是“文件能不能存下数据”这么朴素的问题。它真正解决的是:

当数据大到不能随便全量读取时,文件格式本身能不能帮助查询引擎少读、快读、压缩读。

这也是很多人第一次理解 Parquet 时最容易错过的点。Parquet 不是“压缩过的 CSV”,它是把 查询路径列式布局统计信息编码压缩 一起设计进文件格式里的数据存储格式。

一、先把结论放前面

Parquet 是一种面向分析场景的 列式存储文件格式,最适合这种数据:

  • 数据量大
  • 字段多
  • 经常只查部分列
  • 经常按时间、地区、用户类型等条件过滤
  • 主要追加写入,很少单行更新

它不适合这种数据:

  • 频繁更新单行
  • 高并发事务写入
  • 强 OLTP 查询
  • 小文件数量极多
  • 需要像数据库索引一样随机改某一行

一句话版本:

CSV 是把数据写给人和简单程序看的;Parquet 是把数据写给查询引擎看的。

这句话记住,后面所有设计都会变得顺理成章。

二、CSV 和 JSON 的根本问题:它们太“行式”了

假设有一张用户行为表:

user_ideventamountcitycreated_at
1001pay99.9Shanghai2026-05-08 10:01:00
1002view0Beijing2026-05-08 10:02:00
1003add_cart32.5Chengdu2026-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_ideventamountcitycreated_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) 时,查询引擎可以不碰 citycreated_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 里某一列的数据支撑列裁剪,只读需要的列
PageColumn 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

citydevicerefererpayload 这些列,如果没有出现在查询条件或返回结果里,就可以不读。

这对宽表非常狠。

很多日志表、埋点表、交易明细表有几十列甚至上百列,但一次分析常常只关心其中几列。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 继续剪枝。

分区的核心原则是:高频过滤字段适合做分区,但不能分得太碎。

常见选择:

数据类型常见分区
日志、埋点dthour
订单、交易dtregionbiz_type
多租户数据tenant_iddt
IoT 数据dtdevice_typesite

不要把高基数字段无脑拿来分区,比如 user_idorder_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/HiveHive 数仓
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,就不再是一堆术语,而是一套围绕“少读数据”展开的精密设计。

参考资料: