深入Parquet文件格式设计原理及实现细节
作者:Ye Ding
引言
思考半天决定讲一个大家既熟悉又陌生的话题:Parquet文件。相信每个做大数据的工程师肯定都接触过Parquet文件,都知道它是一种列式存储格式,在面对OLAP查询时可以减少读取的数据量,提高查询性能。但是对于它的格式具体是如何设计的,以及更重要的:为什么这样设计,可能就没有那么清楚了。
这篇文章会带你深入Parquet文件的原理和实现细节,并试图说明这些设计背后的意义。
Parquet解决什么问题
要理解一个系统,首先第一个要提出的问题就是
这个系统为了解决什么问题?
也就是“这个系统提供了什么功能”。这是理解任何一个系统都需要关注的主线。只要心中有这条主线,就不会陷入各种细节的泥沼,而迷失了方向。
对于Parquet文件来说,这条主线在Twitter宣布Parquet开源的文章中就讲得非常清楚
Parquet is an open-source columnar storage format for Hadoop.
...
Not all data people store in Hadoop is a simple table — complex nested structures abound. For example, one of Twitter’s common internal datasets has a schema nested seven levels deep, with over 80 leaf nodes.
也就是说,Twitter想在Hadoop上面设计一种新的列式存储格式,这种格式可以保存包含嵌套结构的数据。所以Parquet文件格式试图解决的问题,用一句话来说,就是列式存储一个类型包含嵌套结构的数据集。
什么是“包含嵌套结构的数据集”呢?举个例子
假设我们要存储1000个用户的电话簿信息,其中每个用户的电话簿用下面的这个结构来表示
{ "owner": "Lei Li", "ownerPhoneNumbers": ["13354127165", "18819972777"], "contacts": [ { "name": "Meimei Han", "phoneNumber": "18561628306" }, { "name": "Lucy", "phoneNumber": "14550091758" } ] }
可以看到其中 ownerPhoneNumbers
字段是一个数组,而contacts
字段更是一个对象的数组。所以这个类型就不能用简单的二维表来存储,因为它包含了嵌套结构。
如果把这个包含嵌套结构的类型称为AddressBook
,那么Parquet文件的目标就是以面向列的方式保存AddressBook对象所构成的数据集。
接下来再来讲如何“以面向列的方式保存”。对于一个二维表的数据,相信大家可以很容易地想象出怎样列式地存储这些数据,例如
name | age | phoneNumber |
Lei Li | 16 | 13354127165 |
Meimei Han | 14 | 18561628306 |
Lucy | 15 | 14550091758 |
把它列式存储就变成了
"Lei Li"
"Meimei Han"
"Lucy"
16
14
15
"13354127165"
"18561628306"
"14550091758"
但如果数据是一个包含数组和对象的复杂嵌套结构呢?可能就不是这么直观了。
在Parquet里面,保存嵌套结构的方式是把所有字段打平以后顺序存储。
什么意思呢?以电话簿的例子来说,真正有数据的其实只有4列:
- owner
- ownerPhoneNumbers
- contacts.name
- contacts.phoneNumber
所以只需要把原始数据看做是一个4列的表即可。举个例子:
假设有2条AddressBook记录
{ "owner": "Lei Li", "ownerPhoneNumbers": ["13354127165", "18819972777"], "contacts": [ { "name": "Meimei Han", "phoneNumber": "18561628306" }, { "name": "Lucy", "phoneNumber": "14550091758" } ] }, { "owner": "Meimei Han", "ownerPhoneNumbers": ["15130245254"], "contacts": [ { "name": "Lily" }, { "name": "Lucy", "phoneNumber": "14550091758" } ] }
以列式保存之后,就会变成这样
"Lei Li"
"Meimei Han"
"13354127165"
"18819972777"
"15130245254"
"Meimei Han"
"Lucy"
"Lily"
"Lucy"
"18561628306"
"14550091758"
"14550091758"
聪明的朋友肯定很快就发现了,因为原始结构里有个数组,长度是不定的,如果只是把数据按顺序存放,那就无法区分record之间的边界,也就不知道每个值究竟属于哪条record了。所以简单地打平是不可行的。
为了解决这个问题,Parquet的设计者引入了两个新的概念:repetition level和definition level。这两个值会保存额外的信息,可以用来重构出数据原本的结构。
关于repetition level和definition level具体是如何工作的,我会放到最后来讲。这里只需要记住,Parquet文件对每个value,都同时保存了它们的repetition level和definition level,以便确定这个value属于哪条record。
Parquet具体是怎么存放数据
接下来我们会深入Parquet文件的内部,讲讲Parquet具体是怎么存放数据的。
首先放一张Parquet文件的整体结构图
其实Parquet还有一张更常见的结构图,官方也经常引用,但我觉得层次不清晰,反而更让人费解,所以就自己画了上面这张图。
看过Parquet的整体结构图之后,可能你已经被这些概念搞迷糊了:Header,Row Group,Column Chunk,Page,Footer……没关系,还是回到我们的主线——列式存储一个包含嵌套结构的数据集,我会把解决这个问题的思路自上而下地拆解,自然而然地就能产生这些概念。
Row Group
首先,因为我们要存储的对象是一个数据集,而这个数据集往往包含上亿条record,所以我们会进行一次水平切分,把这些record切成多个“分片”,每个分片被称为Row Group。为什么要进行水平切分?虽然Parquet的官方文档没有解释,但我认为主要和HDFS有关。因为HDFS存储数据的单位是Block,默认为128m。如果不对数据进行水平切分,只要数据量足够大(超过128m),一条record的数据就会跨越多个Block,会增加很多IO开销。Parquet的官方文档也建议,把HDFS的block size设置为1g,同时把Parquet的parquet.block.size也设置为1g,目的就是使一个Row Group正好存放在一个HDFS Block里面。
Column Chunk
在水平切分之后,就轮到列式存储标志性的垂直切分了。切分方式和上文提到的一致,会把一个嵌套结构打平以后拆分成多列,其中每一列的数据所构成的分片就被称为Column Chunk。最后再把这些Column Chunk顺序地保存。
Page
把数据拆解到Column Chunk级别之后,其结构已经相当简单了。对Column Chunk,Parquet会进行最后一次水平切分,分解成为一个个的Page。每个Page的默认大小为1m。这次的水平切分又是为了什么?尽管Parquet的官方文档又一次地没有解释,我认为主要是为了让数据读取的粒度足够小,便于单条数据或小批量数据的查询。因为Page是Parquet文件最小的读取单位,同时也是压缩的单位,如果没有Page这一级别,压缩就只能对整个Column Chunk进行压缩,而Column Chunk如果整个被压缩,就无法从中间读取数据,只能把Column Chunk整个读出来之后解压,才能读到其中的数据。
Header, Index和Footer
最后聊聊Data以外的Metadata部分,主要是:Header,Index和Footer。
Header
Header的内容很少,只有4个字节,本质是一个magic number,用来指示文件类型。这个magic number目前有两种变体,分别是“PAR1”和“PARE”。其中“PAR1”代表的是普通的Parquet文件,“PARE”代表的是加密过的Parquet文件。
Index
Index是Parquet文件的索引块,主要为了支持“谓词下推”(Predicate Pushdown)功能。谓词下推是一种优化查询性能的技术,简单地来说就是把查询条件发给存储层,让存储层可以做初步的过滤,把肯定不满足查询条件的数据排除掉,从而减少数据的读取和传输量。举个例子,对于csv文件,因为不支持谓词下推,Spark只能把整个文件的数据全部读出来以后,再用where条件对数据进行过滤。而如果是Parquet文件,因为自带Max-Min索引,Spark就可以根据每个Page的max和min值,选择是否要跳过这个Page,不用读取这部分数据,也就减少了IO的开销。
目前Parquet的索引有两种,一种是Max-Min统计信息,一种是BloomFilter。其中Max-Min索引是对每个Page都记录它所含数据的最大值和最小值,这样某个Page是否不满足查询条件就可以通过这个Page的max和min值来判断。BloomFilter索引则是对Max-Min索引的补充,针对value比较稀疏,max-min范围比较大的列,用Max-Min索引的效果就不太好,BloomFilter可以克服这一点,同时也可以用于单条数据的查询。
Footer
Footer是Parquet元数据的大本营,包含了诸如schema,Block的offset和size,Column Chunk的offset和size等所有重要的元数据。另外Footer还承担了整个文件入口的职责,读取Parquet文件的第一步就是读取Footer信息,转换成元数据之后,再根据这些元数据跳转到对应的block和column,读取真正所要的数据。
关于Footer还有一个问题,就是为什么Parquet要把元数据放在文件的末尾而不是开头?这主要是为了让文件写入的操作可以在一趟(one pass)内完成。因为很多元数据的信息需要把文件基本写完以后才知道(例如总行数,各个Block的offset等),如果要写在文件开头,就必须seek回文件的初始位置,大部分文件系统并不支持这种写入操作(例如HDFS)。而如果写在文件末尾,那么整个写入过程就不需要任何回退。
Parquet如何把嵌套结构编码进列式存储
讲完了Parquet的整体结构之后,我们还剩下最后一个问题,也就是我之前埋下的伏笔:Parquet如何把嵌套结构编码进列式存储。在上文中我提到了Parquet是通过repetition level和definition level来解决这个问题,接下来就会详细地讲解一下这是怎么实现的。
还是上文用到的例子
{ "owner": "Lei Li", "ownerPhoneNumbers": ["13354127165", "18819972777"], "contacts": [ { "name": "Meimei Han", "phoneNumber": "18561628306" }, { "name": "Lucy", "phoneNumber": "14550091758" } ] }, { "owner": "Meimei Han", "ownerPhoneNumbers": ["15130245254"], "contacts": [ { "name": "Lily" }, { "name": "Lucy", "phoneNumber": "14550091758" } ] }
注意其中的第三列contacts.name,它有4个值”Meimei Han”,“Lucy”,“Lily”,“Lucy”,其中前两个属于前一条record,后两个属于后一条record。Parquet是如何表达这个信息的呢?它是用repetition level这个值来表达的。
repetition level主要用来表达数组类型字段的长度,但它并不直接记录长度,而是通过记录嵌套层级的变化来间接地表达长度,即如果嵌套层级不变,那么说明数组还在延续,如果嵌套层级变了,说明前一个数组结束了。如果在某个值上嵌套层级由0提高到了1,则这个值的repetition level就是0。如果在某个值的位置嵌套层级不变,则这个值的repetition level就是它的嵌套层级。对于上文中的例子,对应的repetition level就是
Value | Repetition Level |
Meimei Han | 0 |
Lucy | 1 |
Lily | 0 |
Lucy | 1 |
还不是很明白?换个更典型的例子
[["a", "b"], ["c", "d", "e"]]
它对应的repetition level会被编码成
Value | Repetition Level |
a | 0 |
b | 2 |
c | 1 |
d | 2 |
e | 2 |
因为这个数组的嵌套层级是2,而”a”是从level 0到level 2的边界,所以它的repetition level是0,”c”是从level 1到level 2的边界,所以它的repetition level是1,其他字母的嵌套层级没有发生变化,所以它们的repetition level就是2。
总结一下,repetition level主要用来表达数组的长度。
讲完了repetition level,再来讲讲definition level。与repetition level类似的,definition level主要用来表达null的位置。因为Parquet文件里不会显式地存储null,所以通过definition level来判断某个值是否是null。例如对于下面这个例子
AddressBook { contacts: { phoneNumber: "555 987 6543" } contacts: { } } AddressBook { }
对应的definition level是这样编码的
Value | Definition Level |
555 987 6543 | 2 |
NULL | 1 |
NULL | 0 |
可以看到,凡是definition level小于嵌套层级的,都表达了这个值是null。而definition level具体的值则表达null出现在哪一个嵌套层级。
Parquet最难理解的部分到此就结束了。你或许会有疑问,如果对每个值都保存repetition level和definition level,那么这部分的数据量肯定不小(两个int32整数,共8个字节),搞不好比本来要存的数据还要大,是不是本末倒置了?显然Parquet也考虑到了这个问题,所以有很多的优化措施,例如“对非数组类型的值不保存repetition level”,“对必填字段不保存definition level”等,真正存储这两个level时,也使用的是bit-packing + RLE编码,尽可能地对这部分数据进行了压缩。篇幅有限,就不在这里展开了。
最后聊聊Parquet格式的演进
Parquet格式最初由Twitter和Cloudera提出,作为RCFile格式的替代者,和早一个月提出的ORC格式类似。联想到ORC是由Facebook和Hortonworks提出的,这两者的竞争关系不言自明。(有机会可以再来写写ORC格式)
Parquet在2013年宣布开源后,2014年被Cloudera捐给Apache基金会,进入孵化流程,并于2015年毕业成为顶级项目。Parquet的框架在进入Apache基金会之前已经基本成型,此后变化得也不快,主要新增了几个功能:
- Column Index
- BloomFilter
- 模块化加密
这些改动主要是为了加强对谓词下推的支持,但也有个副作用:文件体积变得更大了。
以上就是深入Parquet文件原理实现细节及设计意义的详细内容,更多关于Parquet文件原理设计的资料请关注脚本之家其它相关文章!