parquet
Parquet 是一种列式存储文件格式,可用于 Hadoop 生态系统中的任何项目,他也是最主流的大数据存储格式,几乎所有大数据处理框架都能够处理该格式。
上图就是 Parquet 的文件格式,如果以 thrift 定义该格式如下:
4-byte magic number "PAR1"
<Column 1 Chunk 1 + Column Metadata>
<Column 2 Chunk 1 + Column Metadata>
...
<Column N Chunk 1 + Column Metadata>
<Column 1 Chunk 2 + Column Metadata>
<Column 2 Chunk 2 + Column Metadata>
...
<Column N Chunk 2 + Column Metadata>
...
<Column 1 Chunk M + Column Metadata>
<Column 2 Chunk M + Column Metadata>
...
<Column N Chunk M + Column Metadata>
File Metadata
4-byte length in bytes of file metadata (little endian)
4-byte magic number "PAR1"
比价特殊的是文件的元数据,他们被放置在 Footer 部分(整个文件的入口,读取 Parquet 文件的第一步就是读取这里的内容),并且与数据是分开的,这允许将列拆分为多个文件,即实现单个元数据文件引用多个 parquet 文件。
Note
将文件元数据保存在 Footer 中是为了让文件写入的操作可以在一趟(one pass)内完成。因为很多元数据的信息需要把文件基本写完以后才知道(例如总行数,各个 Block 的 offset 等),如果要写在文件开头,就必须 seek 回文件的初始位置,大部分文件系统并不支持这种写入操作(例如 HDFS)。而如果写在文件末尾,那么整个写入过程就不需要任何回退。
概念
文件格式中涉及几个术语,理解他们是能够更好的理解 Parquet 格式:
File
: 文件,这个文件特指包含文件元数据(FileMetaData)的数据库,比较特殊的是 Parquet 文件中不需要实际包含数据,通过文件元数据能够索引到数据文件地址Row group
: 行组,将数据以行的形式进行分区,行组由列块组成。在逻辑上他是以行进行分区的,在实际物理结构中以列块组成。他的大小通常设定为 HDFS 中的块(Block),这样在 HDFS 中分块存储时能够连续的处理行组数据Column chunk
: 列块,他们是行组的组成部分。他们在磁盘中式连续存储的Page
: 页面,列块被划分为多个页面,他是不可分割的单元(在压缩和编码方面),同样他也是操作的最小单元,为了让数据读取的粒度足够小,便于单条数据或小批量数据的查询。因为 Page 是 Parquet 文件最小的读取单位,同时也是压缩的单位,如果没有 Page 这一级别,压缩就只能对整个Column Chunk
进行压缩,而Column Chunk
如果整个被压缩,就无法从中间读取数据,只能把Column Chunk
整个读出来之后解压,才能读到其中的数据。
从层次结构上讲,一个文件中包含文件头和文件尾以及文件元数据,文件的元数据记录了文件数据的地址、类型等元信息。文件数据由行组(Row group)组成,通常行组在磁盘中是连续存储的(Parquet 不保证,可以设置行组的大小为 HDFS 块大小来让 HDFS 保证)。行组由列块(Column chunk)组成,其中列块是保证在磁盘中连续存储的。一个列块包含一个或多个页面(Page),页面能够更细致的控制列块中数据的存储和压缩。
MetaData
Parquet 包含三种类型的元数据,FileMetaData、ColumnMetaData 和 PageHeaderMetadata,他的定义如下:
Tips
所有这些元数据都使用 thriftCompactProtocol 进行了序列化
数据类型
Parquet 支持下面的数据类型,他们也被称为基元类型:
- BOOLEAN: 1 bit boolean
- INT32: 32 bit signed ints
- INT64: 64 bit signed ints
- INT96: 96 bit signed ints
- FLOAT: IEEE 32-bit floating point values
- DOUBLE: IEEE 64-bit floating point values
- BYTE_ARRAY: arbitrarily long byte arrays
- FIXED_LEN_BYTE_ARRAY: fixed length byte arrays
Parquet 作为文件格式,重点关注类型对磁盘存储的影响,并且为了降低读取器和写入器的复杂性,刻意的仅支持尽可能少的数据类型。但是这不意味着表现力不行,例如尽管不明确支持 16 位整数,当时在设计 32 位整数时通过高效的编码覆盖了 16 位的需求。
Parquet 通过注释配合基元类型来定义逻辑类型,例如字符串存储为带有 UTF8 注释的字节数组,通过注释定义了如何进一步解释数据,这样能够将类型系统保持在最低限度而重点关注如何高效编码。时间日期也是同理可以通过 INT96 来保存 ns 级时间戳。逻辑类型通过 LogicalType 字段保存在元数据中。
Tips
具体逻辑类型的注释可以查看 LogicalTypes.md。
嵌套编码
Parquet 的定义之初就决定他能够保存嵌套结构,而实现的方式是在保存嵌套结构的方式是把所有字段打平以后顺序存储。
{
"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"
}
]
}
上面的 ownerPhoneNumbers 就是嵌套列表,而 Contacts 就是嵌套字典,他的存储方式如下:
==== owner ====
"Lei Li"
"Meimei Han"
==== ownerPhoneNumbers ====
"13354127165"
"18819972777"
"15130245254"
==== contacts.name =====
"Meimei Han"
"Lucy"
"Lily"
"Lucy"
==== contacts.phoneNumber ====
"18561628306"
"14550091758"
"14550091758"
这种打平的方式有一个问题就是,原始结构中数组的长度是不定的直接打平存储就没办法区分数组的边界了。而为了解决这个问题 Parquet 中引入了两个概念:
- repetition level: 表达数组长度
- definition level: 确定 null 的位置
Tips
具体如何编码的可以查看 Dremel: Interactive Analysis of Web-Scale Datasets
Index
Index 是 Parquet 文件的索引块,主要为了支持谓词下推(Predicate Pushdown)功能而提供的。谓词下推是一种优化查询性能的技术,简单地来说就是把查询条件发给存储层,让存储层可以做初步的过滤,把肯定不满足查询条件的数据排除掉,从而减少数据的读取和传输量。
对于 csv 文件,因为不支持谓词下推,Spark 等计算框架只能把整个文件的数据全部读出来以后,再用 where 条件对数据进行过滤。而如果是 Parquet 文件,因为自带 Max-Min 索引,Spark 就可以根据每个 Page 的 max 和 min 值,选择是否要跳过这个 Page,不用读取这部分数据,也就减少了 IO 的开销。
目前 Parquet 的索引有两种,一种是 Max-Min 列统计信息和字典(column statistics and dictionaries),一种是 BloomFilter。其中 Max-Min 索引是对每个 Page 都记录它所含数据的最大值和最小值,这样某个 Page 是否不满足查询条件就可以通过这个 Page 的 max 和 min 值来判断。BloomFilter 索引则是对 Max-Min 索引的补充,针对 value 比较稀疏,Max-Min 范围比较大的列,用 Max-Min 索引的效果就不太好,BloomFilter 可以克服这一点,同时也可以用于单条数据的查询。
Tips
BloomFilter 的使用原则是绝对否和可能是。即如果判断不存在就绝对不存在,如果判断存在就可能存在。