---
name: parquet-02-parquet-file-structure
title: 深入理解 Parquet（二）：文件结构剖析
date: 2026-01-28
---

上一篇我们理解了列式存储的基本原理和优势。这一篇，我们深入 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 的数据在文件中是连续的

这意味着，当查询只需要 `city` 和 `salary` 两列时：

```sql
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` 字段，包含 `min`、`max`、`null_count`。这是实现**谓词下推（Predicate Pushdown）**的关键。

假设执行这个查询：

```sql
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 文件。

**查看文件元数据**：

```bash
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**：

```bash
parquet-tools schema example.parquet
```

**查看前几行数据**：

```bash
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 的编码技术](/post/parquet-03-parquet-encoding)，看看 Dictionary、RLE、Delta、Bit Packing 这些编码是如何工作的，以及 Parquet 如何根据数据特点选择最优的编码方式。

