深入Parquet文件格式设计原理及实现细节

 更新时间:2023年08月30日 09:17:59   作者:Ye Ding  
这篇文章主要介绍了深入Parquet文件格式设计原理及实现细节,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪

引言

思考半天决定讲一个大家既熟悉又陌生的话题: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对象所构成的数据集。

接下来再来讲如何“以面向列的方式保存”。对于一个二维表的数据,相信大家可以很容易地想象出怎样列式地存储这些数据,例如

nameagephoneNumber
Lei Li1613354127165
Meimei Han1418561628306
Lucy1514550091758

把它列式存储就变成了

"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就是

ValueRepetition Level
Meimei Han0
Lucy1
Lily0
Lucy1

还不是很明白?换个更典型的例子

[["a", "b"], ["c", "d", "e"]]

它对应的repetition level会被编码成

ValueRepetition Level
a0
b2
c1
d2
e2

因为这个数组的嵌套层级是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是这样编码的

ValueDefinition Level
555 987 65432
NULL1
NULL0

可以看到,凡是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文件原理设计的资料请关注脚本之家其它相关文章!

相关文章

最新评论