上一篇我们理解了列式存储的基本原理和优势。这一篇,我们深入 Parquet 文件内部,看看它到底是怎么组织数据的。理解文件结构是后续理解编码、压缩、查询优化的基础。
Parquet 文件整体结构
一个 Parquet 文件的整体结构如下:
+------------------------------------------+
| Magic Number (PAR1) | 4 bytes
+------------------------------------------+
| |
| Row Group 1 |
| |
+------------------------------------------+
| |
| Row Group 2 |
| |
+------------------------------------------+
| ... |
+------------------------------------------+
| |
| Row Group N |
| |
+------------------------------------------+
| File Metadata |
+------------------------------------------+
| Footer Length (4 bytes) |
+------------------------------------------+
| Magic Number (PAR1) | 4 bytes
+------------------------------------------+
几个关键点:
- Magic Number:文件开头和结尾都是写死的
PAR1(4 字节),用于标识这是一个 Parquet 文件 - Row Group:数据的水平切分单元,是 Parquet 最重要的概念之一
- File Metadata:元数据放在文件末尾,这个设计很有意思,后面会解释
- Footer Length:倒数第 5-8 字节存储 File Metadata 的大小,这样是为了快速定位 File Metadata 的开始位置
为什么 Metadata 放在文件末尾?
你可能会问:元数据放开头不是更方便吗?读文件先读元数据,知道数据在哪,再去读数据。
Parquet 把元数据放末尾,其实是为了支持流式写入:
写入过程:
1. 写入 Magic Number (PAR1)
2. 写入 Row Group 1 的数据
3. 写入 Row Group 2 的数据
4. ... 持续写入 ...
5. 所有数据写完后,生成并写入 Metadata
6. 写入 Footer Length
7. 写入 Magic Number (PAR1)
如果元数据放开头,你需要预先知道所有 Row Group 的位置和大小,这在流式写入时是做不到的。放末尾就没这个问题——数据写完了,统计信息自然也有了。
那读取时怎么办?
首先,我们需要一次 seek 操作,读取文件最后的 8 个字节,其中 4 个字节是魔数 “PAR1”,另外 4 个字节 Footer Length 可以告诉我们 File Metadata 的位置从哪里开始(文件末尾 - 8 - Footer Length)。
然后再一次 seek 操作读取完整的 File Metadata 内容。
最后,根据 metadata 中的信息,决定需要读取哪些 Row Group。
Row Group:水平切分的艺术
Row Group 是 Parquet 中最核心的概念。它把数据水平切分成多个组,每个 Row Group 包含一定数量的行。
原始数据(100万行,6列):
+----+-------+-----+--------+--------+-------------+
| id | name | age | city | salary | create_time |
+----+-------+-----+--------+--------+-------------+
| 1 | ... | ... | ... | ... | ... |
| 2 | ... | ... | ... | ... | ... |
| .. | ... | ... | ... | ... | ... | Row Group 1 (行 1-250,000)
| .. | ... | ... | ... | ... | ... |
+----+-------+-----+--------+--------+-------------+
| .. | ... | ... | ... | ... | ... |
| .. | ... | ... | ... | ... | ... | Row Group 2 (行 250,001-500,000)
| .. | ... | ... | ... | ... | ... |
+----+-------+-----+--------+--------+-------------+
| .. | ... | ... | ... | ... | ... |
| .. | ... | ... | ... | ... | ... | Row Group 3 (行 500,001-750,000)
+----+-------+-----+--------+--------+-------------+
| .. | ... | ... | ... | ... | ... |
| .. | ... | ... | ... | ... | ... | Row Group 4 (行 750,001-1,000,000)
+----+-------+-----+--------+--------+-------------+
为什么需要 Row Group?
把一个 parquet 文件分为几个 row group,是有很多优点的。
谓词下推:很多时候,我们只需要其中的几个 row group,这样可以降低 IO 的负载,不用每次拉取整个文件
IO 并行:一个 row group 可以使用一个 scan task 来完成,如果只有一个 row group,没法实现并行
内存控制:后面会介绍到,我们需要对一个 row group 进行解压、解码等操作,如果只有一个超大的 row group,会导致内存峰值不可控
Row Group 大小的权衡
Row Group 的大小是一个重要的调优参数,默认通常是 128MB(Spark)或 1GB(某些场景)。
Row Group 太小:
- 元数据开销大(每个 Row Group 都有自己的统计信息)
- 列裁剪效果打折扣(每列的数据块太小,I/O 效率低)
- 压缩效果差(数据量小,规律性不明显)
Row Group 太大:
- 内存压力大(写入时需要在内存中缓存整个 Row Group)
- 读取粒度粗(即使只需要几行数据,也要读取整个 Row Group)
- 并行处理不友好(一个 Row Group 通常由一个 Task 处理)
经验值:
| 场景 | 建议大小 | 原因 |
|---|---|---|
| 常规 HDFS 分析 | 128MB - 256MB | 匹配 HDFS Block 大小 |
| 大内存集群 | 512MB - 1GB | 更好的压缩比 |
| 实时/流式场景 | 32MB - 64MB | 更快的写入和更细的读取粒度 |
Column Chunk:Row Group 内的列
每个 Row Group 内部,数据是按列组织的。每一列的数据形成一个 Column Chunk。
Row Group 1 的结构:
|
+-- Column Chunk: id
| +-- Page
| +-- Page
| +-- Page
|
+-- Column Chunk: name
| +-- Page
| +-- Page
|
+-- Column Chunk: age
| +-- Page
| +-- Page
|
+-- Column Chunk: city
| +-- Page
| +-- Page
|
+-- Column Chunk: salary
| +-- Page
| +-- Page
|
+-- Column Chunk: time
+-- Page
+-- Page
一个 Row Group 有多少列,就有多少个 Column Chunk。
Column Chunk 的特点:
- 独立压缩:每个 Column Chunk 可以选择不同的压缩算法
- 独立编码:每个 Column Chunk 可以选择最适合该列数据的编码方式
- 连续存储:同一个 Column Chunk 的数据在文件中是连续的
这意味着,当查询只需要 city 和 salary 两列时:
SELECT city, AVG(salary) FROM users GROUP BY city;
Parquet 只需要读取每个 Row Group 中的 city 和 salary 两个 Column Chunk,其他 4 个 Column Chunk 完全不用读。
多列查询时如何对应数据?
你可能会问:SELECT age FROM users WHERE id=10,条件在 id 列,结果在 age 列,怎么对应?
答案是通过位置对齐。在同一个 Row Group 内,各列的第 N 个值属于同一行:
id 列: [1, 2, 3, ...] ← 位置 0, 1, 2, ...
age 列: [28, 35, 42, ...] ← 位置 0, 1, 2, ...
└──────────────────────┘
位置相同 = 同一行
执行时:扫描 id 列找到 id=10 的位置(比如位置 9),然后读取 age 列位置 9 的值。
这也解释了为什么列式存储点查询效率不高,因为需要扫描过滤列才能定位。后面讲的统计信息和 Page Index 可以部分缓解这个问题。
Page:最小的存储单元
Column Chunk 还可以进一步细分为 Page,Page 是 Parquet 中最小的存储单元,每个 page 存储一段连续范围的数据。
Row Group
|
+-- Column Chunk: city
| +-- Page 1 (rows 1–10k)
| +-- Page 2 (rows 10k–20k)
| +-- Page 3 (rows 20k–30k)
|
+-- Column Chunk: salary
+-- Page 1
+-- Page 2
Page 的类型
Parquet 定义了三种 Page:
- Data Page:存储实际的列数据,这是最常见的 Page 类型
- Dictionary Page:存储字典编码的字典,如果列使用了字典编码,会有一个 Dictionary Page
- Index Page:存储索引信息(Parquet 2.0 引入,用于加速查询)
一个使用字典编码的 Column Chunk 结构示例:
Column Chunk: city
+---------------------------------------------------------------+
| Dictionary Page | Data Page 1 | Data Page 2 | Data Page 3 |
|-----------------+-------------+-------------+-----------------|
| 0:北京 | [0,1,0,2...]| [1,0,0,1...]| [0,0,1,0...] |
| 1:上海 | | | |
| 2:广州 | | | |
| 3:深圳 | | | |
+---------------------------------------------------------------+
这里第一个 page 是字典,后面的 page 是 data。
Data Page 内部结构
每个 Data Page 包含三部分:
+--------------------------------------------------+
| Page Header |
| - 压缩前大小 |
| - 压缩后大小 |
| - 值的数量 |
| - 编码方式 |
+---------------------------------------------------+
| Repetition Levels (可选) |
| (用于嵌套结构,后面文章会讲) |
+---------------------------------------------------+
| Definition Levels (可选) |
| (用于处理 NULL 值和嵌套结构) |
+--------------------------------------------------+
| Encoded Values |
| (经过编码和压缩的实际数据) |
+--------------------------------------------------+
Page 大小的影响
Page 的默认大小通常是 1MB。
Page 太小:
- Header 开销占比大
- 不利于顺序 I/O
Page 太大:
- 读取单个值需要解压更多数据
- 内存占用大
Page 大小一般不需要特别调整,默认值在大多数场景下都工作得很好。
File Metadata:文件的”目录”
上面介绍完了一个 row group 的内部结构,我们再回过头来看 parquet 文件中的 metadata 内容。
File Metadata 存储了整个文件的元信息,是查询优化的关键,主要就是统计信息和索引。
File Metadata 结构:
+--------------------------------------------------+
| Schema |
| - 列名、类型、嵌套结构等 |
+--------------------------------------------------+
| Row Group Metadata[] |
| +--------------------------------------------+ |
| | Row Group 1: | |
| | - file_offset (在文件中的起始位置) | |
| | - total_byte_size | |
| | - num_rows (行数) | |
| | - Column Chunk Metadata[]: | |
| | +------------------------------------+ | |
| | | Column: id | | |
| | | - file_offset | | |
| | | - type: INT64 | | |
| | | - encodings: [DELTA, RLE] | | |
| | | - compression: SNAPPY | | |
| | | - num_values: 250000 | | |
| | | - total_compressed_size | | |
| | | - total_uncompressed_size | | |
| | | - statistics: | | |
| | | min: 1 | | |
| | | max: 250000 | | |
| | | null_count: 0 | | |
| | +------------------------------------+ | |
| | | Column: name ... | | |
| | | Column: age ... | | |
| | | ... | | |
| +--------------------------------------------+ |
| | Row Group 2: ... | |
| +--------------------------------------------+ |
+--------------------------------------------------+
| Key-Value Metadata |
| - 自定义元数据(如 Spark schema、作者信息等) |
+--------------------------------------------------+
| Created By |
| - 创建该文件的程序和版本 |
+--------------------------------------------------+
Statistics:谓词下推的基础
注意到每个 Column Chunk 都有 statistics 字段,包含 min、max、null_count。这是实现**谓词下推(Predicate Pushdown)**的关键。
假设执行这个查询:
SELECT * FROM users WHERE id > 500000;
查询引擎的处理过程:
1. 读取 File Metadata
2. 遍历每个 Row Group 的 id 列统计信息:
- Row Group 1: id min=1, max=250000 → 跳过(max < 500000)
- Row Group 2: id min=250001, max=500000 → 跳过(max <= 500000)
- Row Group 3: id min=500001, max=750000 → 需要读取
- Row Group 4: id min=750001, max=1000000→ 需要读取
3. 只读取 Row Group 3 和 4
通过统计信息,直接跳过了一半的数据,I/O 减少 50%!
这就是为什么数据排序对 Parquet 如此重要。如果 id 是乱序的,每个 Row Group 的 min/max 范围可能都是 1-1000000,谓词下推就失效了。
完整的文件结构图
让我们把所有层级串起来:
Parquet File
│
├── Magic Number (PAR1)
│
├── Row Group 1
│ ├── Column Chunk (id)
│ │ ├── Dictionary Page (可选)
│ │ ├── Data Page 1
│ │ ├── Data Page 2
│ │ └── ...
│ ├── Column Chunk (name)
│ │ ├── Data Page 1
│ │ └── ...
│ ├── Column Chunk (age)
│ ├── Column Chunk (city)
│ ├── Column Chunk (salary)
│ └── Column Chunk (create_time)
│
├── Row Group 2
│ └── ... (同上结构)
│
├── ...
│
├── Row Group N
│
├── File Metadata
│ ├── Schema
│ ├── Row Group Metadata[]
│ │ ├── Row Group 1 Metadata
│ │ │ ├── Column Chunk Metadata (id)
│ │ │ │ ├── offset, size, encoding, compression
│ │ │ │ └── statistics (min, max, null_count)
│ │ │ ├── Column Chunk Metadata (name)
│ │ │ └── ...
│ │ └── Row Group 2 Metadata ...
│ └── Key-Value Metadata
│
├── Footer Length (4 bytes)
│
└── Magic Number (PAR1)
用工具验证一下
说了这么多,我们用 parquet-tools 来看一个真实的 Parquet 文件。
查看文件元数据:
parquet-tools meta example.parquet
输出类似:
file: file:/path/to/example.parquet
creator: parquet-mr version 1.12.0
file schema: spark_schema
--------------------------------------------------------------------------------
id: OPTIONAL INT64
name: OPTIONAL BINARY L:STRING
age: OPTIONAL INT32
city: OPTIONAL BINARY L:STRING
salary: OPTIONAL DOUBLE
create_time: OPTIONAL INT96
row group 1: RC:250000 TS:45678901 OFFSET:4
--------------------------------------------------------------------------------
id: INT64 SNAPPY DO:0 FPO:4 SZ:1234567/2345678/1.90 VC:250000 ENC:DELTA
min=1, max=250000, null_count=0
name: BINARY SNAPPY DO:1234567 FPO:1234571 SZ:3456789/6789012/1.96 VC:250000 ENC:DICT,RLE
city: BINARY SNAPPY DO:4691356 FPO:4691360 SZ:123456/456789/3.70 VC:250000 ENC:DICT,RLE
min=上海, max=深圳, null_count=0
...
查看 Schema:
parquet-tools schema example.parquet
查看前几行数据:
parquet-tools head -n 5 example.parquet
总结
这篇文章我们深入了解了 Parquet 的文件结构:
| 层级 | 说明 | 关键点 |
|---|---|---|
| File | 整个文件 | 首尾 Magic Number,Metadata 在末尾 |
| Row Group | 水平切分单元 | 平衡列式存储和数据局部性,默认 128MB |
| Column Chunk | Row Group 内的列 | 独立压缩和编码,列裁剪的基础 |
| Page | 最小存储单元 | Data Page、Dictionary Page、Index Page |
| Metadata | 文件元数据 | 包含 Schema、统计信息,谓词下推的基础 |
理解了这个结构,你就能理解:
- 为什么 Parquet 能高效地只读取需要的列(Column Chunk 独立存储)
- 为什么 Parquet 能跳过不需要的数据(Row Group 统计信息)
- 为什么 Parquet 写入后不适合修改(结构层层嵌套,牵一发动全身)
下一篇我们深入 Parquet 的编码技术,看看 Dictionary、RLE、Delta、Bit Packing 这些编码是如何工作的,以及 Parquet 如何根据数据特点选择最优的编码方式。
0 条评论