---
name: parquet-04-parquet-schema
title: 深入理解 Parquet（四）：Schema 与嵌套结构
date: 2026-01-28
---

前面我们了解了 Parquet 的文件结构和编码技术，但一直回避了一个问题：Parquet 如何描述数据的结构？特别是，如何处理嵌套的复杂结构？这篇文章，我们来深入 Parquet 的 Schema 设计，看看它是如何优雅地把嵌套数据"拍平"成列式存储的。

## 从一个问题开始

假设我们要存储用户的地址信息：

```json
{
  "user_id": 1,
  "name": "张三",
  "addresses": [
    {"city": "北京", "street": "长安街1号"},
    {"city": "上海", "street": "南京路100号"}
  ]
}
```

问题来了：
1. `addresses` 是一个数组，怎么用列式存储？总不能有一列就叫 addresses，然后使用 json string 存储吧。
2. 数组里的元素是对象，又有 `city` 和 `street` 两个字段
3. 不同用户的地址数量不同，有的 0 个，有的 3 个

如果是行式存储（如 MySQL），这需要拆成两张表，通过外键关联。但 Parquet 作为列式存储，能不能直接存这种嵌套结构？

答案是可以的，而且非常优雅。这个设计来源于 Google 的 Dremel 论文，是 Parquet 最精妙的部分。大家也可以直接去找相关的资料学习，我会尝试使用我的语言来帮助完全不了解的开发者理解这个设计。

## Parquet 的类型系统

在讲嵌套结构之前，先了解 Parquet 的基础类型。

### 原始类型（Primitive Types）

Parquet 只定义了少量的原始类型：

| 类型 | 说明 | 对应 Java 类型 |
|------|------|---------------|
| BOOLEAN | 布尔值 | boolean |
| INT32 | 32位有符号整数 | int |
| INT64 | 64位有符号整数 | long |
| INT96 | 96位有符号整数（主要用于时间戳，已不推荐） | - |
| FLOAT | 32位浮点数 | float |
| DOUBLE | 64位浮点数 | double |
| BINARY | 字节数组 | byte[] |
| FIXED_LEN_BYTE_ARRAY | 固定长度字节数组 | byte[] |

你会发现没有 STRING、DECIMAL、DATE 这些常用类型。别急，Parquet 用 **Logical Types** 来扩展。

### 逻辑类型（Logical Types）

逻辑类型是在原始类型上附加语义信息：

| 逻辑类型 | 底层原始类型 | 说明 |
|---------|------------|------|
| STRING | BINARY | UTF-8 编码的字符串 |
| DECIMAL | INT32/INT64/BINARY/FIXED | 精确小数 |
| DATE | INT32 | 从 1970-01-01 起的天数 |
| TIME | INT32/INT64 | 一天内的时间（毫秒/微秒） |
| TIMESTAMP | INT64/INT96 | 时间戳（毫秒/微秒/纳秒） |
| UUID | FIXED_LEN_BYTE_ARRAY(16) | UUID |
| ENUM | BINARY | 枚举值 |
| JSON | BINARY | JSON 字符串 |

这种设计的好处：
- **存储层简单**：只需要处理几种原始类型
- **语义层丰富**：通过逻辑类型表达各种业务含义
- **向后兼容**：新增逻辑类型不影响存储格式

### Schema 定义示例

一个简单的用户表 Schema：

```
message User {
  required int64 user_id;
  optional binary name (STRING);
  optional int32 age;
  optional binary city (STRING);
  optional double salary;
  optional int64 create_time (TIMESTAMP_MILLIS);
}
```

关键字说明：
- `message`：根节点，类似于"表"
- `required`：必填，不允许 NULL
- `optional`：可选，允许 NULL
- `(STRING)`：逻辑类型注解

## 嵌套结构：repeated 和 group

现在进入核心话题：嵌套结构。先理解一下 Parquet 中嵌套结构是怎么存储的。

### group：嵌套对象

```json
{
  "user_id": 1,
  "address": {
    "city": "北京",
    "street": "长安街1号"
  }
}
```

Schema 定义：

```
message User {
  required int64 user_id;
  optional group address {
    optional binary city (STRING);
    optional binary street (STRING);
  }
}
```

`group` 表示嵌套的结构体。在存储时，嵌套字段会被"拍平"，也就是说对于 Parquet 文件来说，实际存储了 3 列：

```
- user_id（原始类型）
- address.city（原始类型）
- address.street（原始类型）
```

### repeated：数组

```json
{
  "user_id": 1,
  "phone_numbers": ["13800001111", "13900002222"]
}
```

Schema 定义：

```
message User {
  required int64 user_id;
  repeated binary phone_numbers (STRING);
}
```

`repeated` 表示字段可以出现 0 次或多次（数组）。

### repeated group：对象数组

回到开头的例子：

```json
{
  "user_id": 1,
  "addresses": [
    {"city": "北京", "street": "长安街1号"},
    {"city": "上海", "street": "南京路100号"}
  ]
}
```

Schema 定义：

```
message User {
  required int64 user_id;
  repeated group addresses {
    optional binary city (STRING);
    optional binary street (STRING);
  }
}
```

这里 `addresses` 是一个数组，数组的每个元素是一个包含 `city` 和 `street` 的对象。当然我们也可以继续嵌套，数组套对象，对象里面再套数组。

这里虽然 addresses 是一个数组，Parquet 也同样会给它拍平。

也就是说上面这个结构的数据，在储存的时候依然是 3 列：

```
user_id
addresses.city
addresses.street
```

不管套多深，最后都会被拍平成一个普通的列。接下来就是精妙的写入和读取算法了。

## 处理 NULL 值

我们先看 Parquet 是如何处理 NULL 值的。

MySQL 等行式数据库通常用特殊标记表示 NULL：

```
| id | name | age  |
|----|------|------|
| 1  | 张三 | 28    |
| 2  | NULL | 35   |  ← name 字段存 NULL 标记
| 3  | 王五 | NULL  |  ← age 字段存 NULL 标记
```

但是在 Parquet 这种列式存储中，是不存储 null 的，节省空间。

比如上面这个例子，我们有 3 行数据，Parquet 存储 name 列的时候，忽略 null 值，物理上就只会存 ["张三","王五"] 这两个数据。

那读取的时候，不就有问题了，我们并不知道哪一行是 null。这个时候就需要使用 Definition Level 了。

我们把它理解为另一个数组，它的长度等于行数，它标记了每一行是有值还是 null，这不就可以对上了吗？还是这个例子：

```
name 列数据：

实际存储值:         [张三, 王五]
Definition Level: [1, 0, 1]
```

有了这两个信息，大家就知道怎么还原数据了。

## Definition Level

Definition Level 当然没有这么简单，它如果只是一个包含 0 和 1 的数组，那也说不上有什么牛逼之处，还不如直接跟 MySQL 一样搞个特殊标记用来处理 null 了，还不用计算两个数组。

Definition Level 它是用来解决嵌套结构的，等下你就会知道，它为什么要叫 “definition level” 了。

考虑这个结构：

```
message User {
	required int64 user_id;
	optional group order {
		optional group card {
			required int64 card_id;
		}
	}
}
```

对于某个用户来说，不一定有订单 order，即使 有 order，order 也不一定是使用卡支付的，所以 card 也可能没有，如果有 card 的话，它肯定有 card_id。

根据前面说的，Parquet 会把 card_id 拍平成 **order.card.card_id** 列。

现在，如果仅仅用 0 和 1 就没法表达：当一个 user 没有 card_id 的时候，到底是 order 没有，还是说 card 没有。

Definition Level 用来表达“**这个值在 schema 路径上到底定义到了哪一层**”，它回答的问题是：

1、这个位置是否真的“有值”？

2、如果没有，是在哪一层缺失的？

对于上面这个场景来说，definition level 共有 0，1，2 三个值：

- 0：这个用户没有 order，自然就没有 card_id
- 1：这个用户有 order，但是 order.card 是 null，所以也不会有 card_id
- 2：order.card 存在，由于在 card 结构里，card_id 是 required 的，所以必然存在 card_id

比如下面这个数据：

```
order.card.card_id 列数据：

实际存储值:         [1234, 5678]
Definition Level: [2, 0, 1, 2]
```

根据这个我们就可以很容易反向推导出原始数据结构信息：

```
有 4 个用户：
- 第一个用户有 card_id = 1234
- 第二个用户没有 order
- 第三个用户 order 不是使用卡支付，没有 card 信息
- 第四个用户有 card_id = 5678
```

到这里，我们应该是完全知道 definition level 的作用了，它就是用来表示值是否为 null，以及到底是哪一层是 null。这也是为什么，它被命名为 definition level 的原因。

注意，Definition Level 数据结构只有在 optional 和 repeated 的字段上才需要，如果本身是 required 字段，其实这个也没用，所以就没有。

到这里，其实给大家埋了一个坑，我们避开了 repeated 结构，等会再给大家填上。

## Repetition Level

Repetition Level 要解决的问题是数组问题。

考虑下面这个结构：

```
message User {
	required int64 user_id;
	repeated binary cities (STRING);
}
```

一个用户有 0 个或者多个地址。

考虑下面这种数据：

```
row1: 
  - user_id: 123
  - cities: ["上海", "北京", "厦门"]
 
row2:
  - user_id: 456
  - cities: []
  
ro3:
  - user_id: 789
  - cities: ["上海", "深圳", "广州", "杭州"]
```

我们有 3 个用户，他们的城市分别如上，其中第二个用户没有城市。

在 Parquet 中首先还是先拍平，也就是说实际存储数据如下：

```
cities 列：

实际存储数据：["上海", "北京", "厦门", "上海", "深圳", "广州", "杭州"]
```

那反向还原要怎么做呢？怎么能把前面的 ["上海", "北京", "厦门"] 还原到第 1 个用户上，把后面的 ["上海", "深圳", "广州", "杭州"] 还原到第 3 个用户上。

这个时候，我们把 repetition level 数据拿上来：

```
cities 列：

实际存储数据：      ["上海", "北京", "厦门", "上海", "深圳", "广州", "杭州"]
repetition level: [0,     1,      1,     0,      1,     1,     1    ]
```

在 repetition level 的编码上，其实就两句话：

1、当一个新的 value 属于同一个 repeated 列表时，RL = 1；

2、当开始一个新的 repeated 列表（也就是新的一行 User）时，RL = 0

接下来好好解释一下。我们先考虑写的流程，当我们在记录第一个用户的 ["上海", "北京", "厦门"] 的时候，我们要把这三个值加到 "cities" 这一列的物理存储上，同时处理 repetition level，过程如下：

>首先，上海被加入到 cities 列，此时往 repetition level 数组加入 0
>
>然后，北京被加入到 cities 列，因为这个值还是当前第一个用户，所以此时往 repetition level 数组加入 1
>
>再然后，这个用户还有第三个城市厦门，此时再往 repetition level 数组加入 1
>
>此时 repetition level 就是 [0,1,1]

之后，我们处理第二个用户，他的 cities 是空的，此时我们不做任何处理。

然后我们处理第三个用户，我们在往 cities 列写入 ["上海", "深圳", "广州", "杭州"] 这四个值的时候，同上，我们会往 repetition level 数组先写入 0，然后写入 3 个 1。

我们再看这个数据：

```
cities 列：

实际存储数据：      ["上海", "北京", "厦门", "上海", "深圳", "广州", "杭州"]
repetition level: [0,     1,      1,     0,      1,     1,     1    ]
```

我们很容易根据 repetition level 中的 0 进行切分，形成两个数组：["上海", "北京", "厦门"] 和 ["上海", "深圳", "广州", "杭州"]。

但是我们有 3 个用户，这两个数组应该属于哪两个用户呢？我们好像丢失了这个信息。这个其实就是前面说的 Definition Level 应该回答的问题。Definition Level 回答值为 null 以及空数组的问题。

大家也许猜到了，Repetition Level 可能也不止 0 和 1，因为它的名字里面也有 level 这个词。考虑下面这个结构：

```
message User {
  repeated group groups {
    repeated binary cities (STRING);
  }
}
```

这个结构里面，一个 user 有 n 个 group，每个 group 有 n 个 city。此时，在 groups.cities 这一列上的 repetition level，需要值 0，1，2 来完整表达信息：

- 0: 出现了新的一个 user
- 1: 还是属于前面的 user，但是属于新的一个 group
- 2: 还是属于前面的 user 的同一个 group

我随便造一个例子：

```
["上海", "北京", "厦门", "上海", "深圳", "广州", "杭州"]
[0,     2,      0,     0,      1,     1,      2]
```

根据上面这两个数组，我们很容易反向还原出来原始的信息：

```
1: {groups: [{cities: ["上海", "北京"]}]}

2: {groups: [{cities: ["厦门"]}]}

3: {groups: [{cities: ["上海"]}, {cities: ["深圳"]}, {cities: ["广州", "杭州"]}]}
```

## DL & RL 完整示例

让我们用一个完整的例子把所有概念串起来：

```
Schema:
message Document {
  required int64 doc_id;
  repeated group links {
    optional binary url (STRING);
  }
}

数据：
doc_id=1: links = [{url: "a.com"}, {url: "b.com"}]
doc_id=2: links = []
doc_id=3: links = [{url: NULL}, {url: "c.com"}]
```

**doc_id 列**（required，无嵌套）：忽略

**links.url 列**：

```
doc_id=1: links = [{url: "a.com"}, {url: "b.com"}]
  → 值: "a.com", R=0(新文档), D=2(url有值)
  → 值: "b.com", R=1(同文档第二个link), D=2(url有值)

doc_id=2: links = []
  → 值: NULL, R=0(新文档), D=0(links为空)

doc_id=3: links = [{url: NULL}, {url: "c.com"}]
  → 值: NULL, R=0(新文档), D=1(links存在,url为NULL)
  → 值: "c.com", R=1(同文档第二个link), D=2(url有值)

汇总：
值: ["a.com", "b.com", NULL,  NULL,    "c.com"]
R:  [0,       1,       0,     0,       1      ]
D:  [2,       2,       0,     1,       2      ]
```

有了 Repetition Level 和 Definition Level，可以完美还原嵌套结构：

```
读取 links.url 列: ["a.com", "b.com", NULL, NULL, "c.com"]
                R=[0,       1,       0,    0,     1]
                D=[2,       2,       0,    1,     2]

还原过程：
1. R=0, D=2, val="a.com" → 新文档，url有值   → doc1.links[0].url = "a.com"
2. R=1, D=2, val="b.com" → 同文档新link      → doc1.links[1].url = "b.com"
3. R=0, D=0, val=NULL    → 新文档，links空   → doc2.links = []
4. R=0, D=1, val=NULL    → 新文档，url为NULL → doc3.links[0].url = NULL
5. R=1, D=2, val="c.com" → 同文档新link      → doc3.links[1].url = "c.com"
```

这上面描述的是逻辑上的处理过程，实际上，我前面也提到了 null 值是不实际存储的，对于 repetition level，如果碰到空值，也是不添加到 repetition level 数组的，但是这些不会影响到我们理解它的设计，这些是框架实现的开发者需要仔细处理的部分。

## 为什么这个设计很精妙？

### 1. 完全列式存储

即使是复杂的嵌套结构，最终存储的都是"拍平"的列：

```
原始嵌套数据:
{
  "users": [
    {"name": "张三", "orders": [{"amount": 100}, {"amount": 200}]},
    {"name": "李四", "orders": [{"amount": 300}]}
  ]
}

存储为独立的列：
- users.name 列
- users.orders.amount 列

每列都可以独立压缩、独立读取
```

### 2. 列裁剪依然有效

查询只需要 `amount` 时：

```sql
SELECT users.orders.amount FROM table;
```

只读取 `users.orders.amount` 这一列，其他列完全不碰。

### 3. R/D 值很小，压缩效果好

Repetition Level 和 Definition Level 的取值范围很小（通常 0-3），非常适合 RLE 和 Bit-Packing：

```
R 列: [0, 1, 1, 1, 0, 1, 0, ...]   ← 大量重复，RLE 效果极佳
D 列: [2, 2, 2, 2, 2, 2, 2, ...]   ← 如果没有 NULL，全是最大值
```

（全文完）
