前面我们了解了 Parquet 的文件结构和编码技术,但一直回避了一个问题:Parquet 如何描述数据的结构?特别是,如何处理嵌套的复杂结构?这篇文章,我们来深入 Parquet 的 Schema 设计,看看它是如何优雅地把嵌套数据”拍平”成列式存储的。
从一个问题开始
假设我们要存储用户的地址信息:
{
"user_id": 1,
"name": "张三",
"addresses": [
{"city": "北京", "street": "长安街1号"},
{"city": "上海", "street": "南京路100号"}
]
}
问题来了:
addresses是一个数组,怎么用列式存储?总不能有一列就叫 addresses,然后使用 json string 存储吧。- 数组里的元素是对象,又有
city和street两个字段 - 不同用户的地址数量不同,有的 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:必填,不允许 NULLoptional:可选,允许 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 是一个数组,数组的每个元素是一个包含 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 时:
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,全是最大值
(全文完)
0 条评论