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

从一个问题开始

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

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

问题来了:

  1. addresses 是一个数组,怎么用列式存储?总不能有一列就叫 addresses,然后使用 json string 存储吧。
  2. 数组里的元素是对象,又有 citystreet 两个字段
  3. 不同用户的地址数量不同,有的 0 个,有的 3 个

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

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

Parquet 的类型系统

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

原始类型(Primitive Types)

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

类型说明对应 Java 类型
BOOLEAN布尔值boolean
INT3232位有符号整数int
INT6464位有符号整数long
INT9696位有符号整数(主要用于时间戳,已不推荐)-
FLOAT32位浮点数float
DOUBLE64位浮点数double
BINARY字节数组byte[]
FIXED_LEN_BYTE_ARRAY固定长度字节数组byte[]

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

逻辑类型(Logical Types)

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

逻辑类型底层原始类型说明
STRINGBINARYUTF-8 编码的字符串
DECIMALINT32/INT64/BINARY/FIXED精确小数
DATEINT32从 1970-01-01 起的天数
TIMEINT32/INT64一天内的时间(毫秒/微秒)
TIMESTAMPINT64/INT96时间戳(毫秒/微秒/纳秒)
UUIDFIXED_LEN_BYTE_ARRAY(16)UUID
ENUMBINARY枚举值
JSONBINARYJSON 字符串

这种设计的好处:

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

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:嵌套对象

{
  "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:数组

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

Schema 定义:

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

repeated 表示字段可以出现 0 次或多次(数组)。

repeated group:对象数组

回到开头的例子:

{
  "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 是一个数组,数组的每个元素是一个包含 citystreet 的对象。当然我们也可以继续嵌套,数组套对象,对象里面再套数组。

这里虽然 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 时:

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,全是最大值

(全文完)