上一篇我们理解了列式存储的基本原理和优势。这一篇,我们深入 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
+------------------------------------------+

几个关键点:

  1. Magic Number:文件开头和结尾都是写死的 PAR1(4 字节),用于标识这是一个 Parquet 文件
  2. Row Group:数据的水平切分单元,是 Parquet 最重要的概念之一
  3. File Metadata:元数据放在文件末尾,这个设计很有意思,后面会解释
  4. 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 的特点

  1. 独立压缩:每个 Column Chunk 可以选择不同的压缩算法
  2. 独立编码:每个 Column Chunk 可以选择最适合该列数据的编码方式
  3. 连续存储:同一个 Column Chunk 的数据在文件中是连续的

这意味着,当查询只需要 citysalary 两列时:

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:

  1. Data Page:存储实际的列数据,这是最常见的 Page 类型
  2. Dictionary Page:存储字典编码的字典,如果列使用了字典编码,会有一个 Dictionary Page
  3. 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 字段,包含 minmaxnull_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 ChunkRow Group 内的列独立压缩和编码,列裁剪的基础
Page最小存储单元Data Page、Dictionary Page、Index Page
Metadata文件元数据包含 Schema、统计信息,谓词下推的基础

理解了这个结构,你就能理解:

  • 为什么 Parquet 能高效地只读取需要的列(Column Chunk 独立存储)
  • 为什么 Parquet 能跳过不需要的数据(Row Group 统计信息)
  • 为什么 Parquet 写入后不适合修改(结构层层嵌套,牵一发动全身)

下一篇我们深入 Parquet 的编码技术,看看 Dictionary、RLE、Delta、Bit Packing 这些编码是如何工作的,以及 Parquet 如何根据数据特点选择最优的编码方式。