大数据技术栈速览之:Parquet

几种hdfs文件存储格式的区别

Text:原始存储,

RCFile:结合列存储和行存储的优缺点,Facebook于是提出了基于行列混合存储的RCFile,它是基于SEQUENCEFILE实现的列存储格式,它即满足快速数据加载和动态负载高适应的需求外,也解决了SEQUENCEFILE的一些瓶颈。该存储结构遵循的是“先水平划分,再垂直划分”的设计理念。先将数据按行水平划分为行组,这样一行的数据就可以保证存储在同一个集群节点;然后在对行进行垂直划分。 

ORC:ORC File,它的全名是Optimized Row Columnar (ORC) file,其实就是对RCFile做了一些优化。据官方文档介绍,这种文件格式可以提供一种高效的方法来存储Hive数据。它的设计目标是来克服Hive其他格式的缺陷。运用ORC File可以提高Hive的读、写以及处理数据的性能。ORC File包含一组组的行数据,称为stripes,除此之外,ORC File的file footer还包含一些额外的辅助信息。在ORC File文件的最后,有一个被称为postscript的区,它主要是用来存储压缩参数及压缩页脚的大小。

ORCFile的主要优点为:

(1)、每个task只输出单个文件,这样可以减少NameNode的负载;

(2)、支持各种复杂的数据类型,比如:datetime, decimal, 以及一些复杂类型(struct、list、 map和 union);

(3)、在文件中存储了一些轻量级的索引数据;

(4)、基于数据类型的块模式压缩:

            Integer类型的列用行程长度编码(run-length encoding)

            String类型的列用字典编码(dictionary encoding)

(5)、用多个互相独立的RecordReaders并行读相同的文件;

(6)、无需扫描markers就可以分割文件;

(7)、绑定读写所需要的内存;

(8)、metadata的存储是用 Protocol Buffers的,所以它支持添加和删除一些列。

avro:它可以提供:
1 丰富的数据结构类型
2 快速可压缩的二进制数据形式
3 存储持久数据的文件容器
4 远程过程调用RPC
5 简单的动态语言结合功能,Avro和动态语言结合后,读写数据文件和使用RPC协议都不需要生成代码,而代码生成作为一种可选的优化只值得在静态类型语言中实现。
Avro依赖于模式(Schema)。Avro数据的读写操作是很频繁的,而这些操作都需要使用模式,这样就减少写入每个数据资料的开销,使得序列化快速而又轻巧。这种数据及其模式的自我描述方便于动态脚本语言的使用。

Parquet:Parquet是面向分析型业务的列式存储格式,由Twitter和Cloudera合作开发。一个Parquet文件是由一个header以及一个或多个block块组成,以一个footer结尾。header中只包含一个4个字节的数字PAR1用来识别整个Parquet文件格式。文件中所有的metadata都存在于footer中。footer中的metadata包含了格式的版本信息,schema信息、key-value paris以及所有block中的metadata信息。footer中最后两个字段为一个以4个字节长度的footer的metadata,以及同header中包含的一样的PAR1。

之前新统计系统的日志都是用Avro做序列化和存储,鉴于Parquet的优势和对Avro的兼容,将HDFS上的存储格式改为Paruqet,并且只需做很小的改动就用原读取Avro的API读取Parquet,单列查询效率可以提高近一个数量级。

 

在互联网大数据应用场景下,通常数据量很大且字段很多,

但每次查询数据只针对其中的少数几个字段,这时候列式存储是极佳的选择。列式存储,天然擅长分析,千万级别的表,count,sum,group by ,秒出结果!!

列式存储要解决的问题:

  • 把IO只给查询需要用到的数据
    • 只加载需要被计算的列
  • 空间节省
    • 列式的压缩效果更好
    • 可以针对数据类型进行编码
  • 开启矢量化的执行引擎(不再1条1条的处理数据,而是一次处理1024条数据)

 

Parquet是列式存储的一种文件类型,是Hadoop生态系统中任何项目可用的列式存储格式。spark天然支持parquet,并为其推荐的存储格式,hive 也支持parquet格式存储。 被多种查询引擎支持(Hive、Impala、Drill等)

Parquet的灵感来自于2010年Google发表的Dremel论文,文中介绍了一种支持嵌套结构的存储格式,并且使用了列式存储的方式提升查询性能,在Dremel论文中还介绍了Google如何使用这种存储格式实现并行查询的,如果对此感兴趣可以参考论文和开源实现Apache Drill。

嵌套数据模型

在接触大数据之前,我们简单的将数据划分为结构化数据和非结构化数据,通常我们使用关系数据库存储结构化数据,而关系数据库中使用数据模型都是扁平式的,遇到诸如List、Map和自定义Struct的时候就需要用户在应用层解析。但是在大数据环境下,通常数据的来源是服务端的埋点数据,很可能需要把程序中的某些对象内容作为输出的一部分,而每一个对象都可能是嵌套的,所以如果能够原生的支持这种数据,这样在查询的时候就不需要额外的解析便能获得想要的结果。

另外,随着嵌套格式的数据的需求日益增加,目前Hadoop生态圈中主流的查询引擎都支持更丰富的数据类型,例如Hive、SparkSQL、Impala等都原生的支持诸如struct、map、array这样的复杂数据类型,这样也就使得诸如Parquet这种原生支持嵌套数据类型的存储格式也变得至关重要,性能也会更好。

列式存储

列式存储,顾名思义就是按照列进行存储数据,把某一列的数据连续的存储,每一行中的不同列的值离散分布。OLAP场景下的数据大部分情况下都是批量导入,而查询的时候大多数都是只使用部分列进行过滤、聚合,对少数列进行计算。列式存储可以大大提升这类查询的性能,较之于行是存储,列式存储能够带来这些优化:

  • 由于每一列中的数据类型相同,所以可以针对不同类型的列使用不同的编码和压缩方式,这样可以大大降低数据存储空间。

  • 读取数据的时候可以把映射(Project)下推,只需要读取需要的列,这样可以大大减少每次查询的I/O数据量,更甚至可以支持谓词下推,跳过不满足条件的列。

  • 由于每一列的数据类型相同,可以使用更加适合CPU pipeline的编码方式,减小CPU的缓存失效。

特点:

    ---> 可以跳过不符合条件的数据,只读取需要的数据,降低 IO 数据量

    ---> 压缩编码可以降低磁盘存储空间(由于同一列的数据类型是一样的,可以使用更高效的压缩编码(如 Run Length Encoding t  Delta Encoding)进一步节约存储空间)

    ---> 只读取需要的列,支持向量运算,能够获取更好的扫描性能

    ---> Parquet 格式是 Spark SQL 的默认数据源,可通过 spark.sql.sources.default 配置

Parquet的设计目标:

  • 适配通用性
  • 存储空间优化
  • 计算时间优化

适配通用性

Parquet只是一种存储格式,它与上层平台、语言无关,不需要与任何一种数据处理框架绑定,基本上通常使用的查询引擎和计算框架都已适配,并且可以很方便的将其它序列化工具生成的数据转换成Parquet格式。目前已经适配的组件包括:

  • 查询引擎:Hive\Impala\Pig\Presto\Drill\Tajo\HAWQ\IBM Big SQL
  • 计算引擎:MapReduce\Spark\Cascading\Crunch\Scalding\Kite
  • 数据模型:Avro\Thrift\Protocol Buffers

存储空间优化

Parquet的数据模型

每条记录中的字段可以包含三种类型:required, repeated, optional。最终由所有叶子节点来代表整个schema。

  • 元组的Schema可以转换成树状结构,根节点可以理解为repeated类型
  • 所有叶子结点都是基本类型
  • 没有Map、Array这样的复杂数据结构,但是可以通过repeated和group组合来实现这样的需求

Striping/Assembly算法

Parquet的一条记录的数据如何分成多少列,又如何组装回来?是由Striping/Assembly算法决定的。

在该算法中,列的每一个值都包含三个部分:

  • value : 字段值
  • repetition level : 重复级别
  • definition level : 定义级别

Repetition Levels

repetition level的设计目标是为了支持repeated类型的节点:

  • 在写入时该值等于它和前面的值从哪一层节点开始是不共享的。
  • 在读取的时候根据该值可以推导出哪一层上需要创建一个新的节点。

例子:对于这样的schema和两条记录:

message nested {
 repeated group leve1 {
  repeated string leve2;
 }
}
r1:[[a,b,c,] , [d,e,f,g]]
r2:[[h] , [i,j]]

计算一下各个值的repetition level。
repetition level计算过程:

  • value=a是一条记录的开始,和前面的值在根结点上是不共享的,因此repetition level=0
  • value=b和前面的值共享了level1这个节点,但是在level2这个节点上不共享,因此repetition level=2
  • 同理,value=c的repetition value=2
  • value=d和前面的值共享了根节点,在level1这个节点是不共享的,因此repetition level=1
  • 同理,value=e,f,g都和自己前面的占共享了level1,没有共享level2,因此repetition level=2
  • value=h属于另一条记录,和前面不共享任何节点,因此,repetition level=0
  • value=i跟前面的结点共享了根,但是没有共享level1节点,因此repetition level=1
  • value-j跟前面的节点共享了level1,但是没有共享level2,因此repetition level=2

在读取时,会顺序读取每个值,然后根据它的repetition level创建对象

  • 当读取value=a时,repeatition level=0,表示需要创建一个新的根节点,
  • 当读取value=b时,repeatition level=2,表示需要创建level2节点
  • 当读取value=c时,repeatition level=2,表示需要创建level2节点
  • 当读取value=d时,repeatition level=1,表示需要创建level1节点
  • 剩下的节点依此类推

几点规律:

  • repetition level=0表示一条记录的开始
  • repetition level的值只是针对路径上repeated类型的节点,因此在计算时可以忽略非repeated类型的节点
  • 在写入的时候将其理解为该节点和路径上的哪一个repeated节点是不共享的
  • 读取的时候将其理解为需要在哪一层创建一个新的repeated节点

Definition Levels

有了repetition levle就可以构造出一条记录了,那么为什么还需要definition level呢?

是因为repeated和optional类型的存在,可以一条记录中的某些列是没有值的,如果不记录这样的值,就会导致本该属于下一条记录的值被当做当前记录中的一部分,从而导致数据错误,因此,对于这种情况,需要一个占位符来表示。

definition level的值仅对空值是有效的,表示该值的路径上第几层开始是未定义的;对于非空值它是没有意义的,因为非空值在叶子节点上是有定义的,所有的父节点也一定是有定义的,因此它的值总是等于该列最大的definition level。
例子:对于这样的schema:

message ExampleDefinitionLevel {
 optional group a {
  optional group b {
   optional string c;
  }
 }
}

它包含一个列a.b.c,这个列的的每一个节点都是optional类型的,当c被定义时a和b肯定都是已定义的,当c未定义时我们就需要标示出在从哪一层开始时未定义的
一条记录的definition level的几种可能的情况如下表:

由于definition level只需要考虑未定义的值,对于required类型的节点,只要父亲节点定义了,该节点就必须定义,因此计算时可以忽略路径上的required类型的节点,这样可以减少definition level的最大值,优化存储。

一个完整的例子

下面使用Dremel论文中给的Document示例和给定的两个值展示计算repeated level和definition level的过程,这里把未定义的值记录为NULL,使用R表示repeated level,D表示definition level。

 

  • 首先看DocId这一列,r1和r2都只有一值分别是:
    • id1=10,由于它是记录开始,并且是已定义的,因此R=0,D=0
    • id2=20,由于是新记录的开始,并且是已经定义的,因此R=0,D=0
  • 对于Name.Url这一列,r1中它有三个值,r2中有一个值分别是:

    • url1=’http://A’ ,它是r1中该列的第一个值,并且是定义的,所以R=0,D=2
    • url2=’http://B’ ,它跟上一个值在Name这层是不同的,并且是定义的,所以R=1,D=2
    • url3=NULL,它跟上一个值在Name这层是不同的,并且是未定义的,所以R=1,D=1
    • url4=’http://C’ ,它跟上一个值属于不同记录,并且是定义的,所以R=0,D=2
  • 对于Links.Forward这一列,在r1中有三个值,在r2中有1个值,分别是:

    • value1=20,它是r1中该列的第一个值,并且是定义的,所以R=0,D=2
    • value2=40,它跟上一个值在Links这层是相同的,并且是定义的,所以R=1,D=2
    • value3=60,它跟上一个值在Links这层是相同的,并且是定义的,所以R=1,D=2
    • value4=80,它是一条新的记录,并且是定义的,所以R=0,D=2
  • 对于Links.Backward这一列,在r1中有一个空值,在r2中两个值,分别是:
    • value1=NULL,它是一条新记录,并且是未定义的,父节点Links是定义的,所以R=0,D=1
    • value2=10,是一条新记录,并且是定义的,所以R=0,D=2
    • value3=30,跟上个值共享父节点,并且是定义的,所以R=1,D=2

Parquet文件格式

Parquet文件是二进制方式存储的,所以是不可以直接读取的,文件中包含数据和元数据,因此Parquet格式文件是自解析的。

先了解一下关于Parquet文件的几个基本概念:

  • 行组(Row Group):每一个行组包含一定的行数,一般对应一个HDFS文件块,Parquet读写的时候会将整个行组缓存在内存中。
  • 列块(Column Chunk):在一个行组中每一列保存在一个列块中,一个列块中的值都是相同类型的,不同的列块可能使用不同的算法进行压缩。
  • 页(Page):每一个列块划分为多个页,一个页是最小的编码的单位,在同一个列块的不同页可能使用不同的编码方式。

Parquet文件组成:

  • 文件开始和结束的4个字节都是Magic Code,用于校验它是否是一个Parquet文件
  • 结束MagicCode前的Footer length是文件元数据的大小,通过该值和文件长度可以计算出元数据Footer的偏移量
  • 再往前推是Footer文件的元数据,里面包含:

    • 文件级别的信息:版本,Schema,Extra key/value对等
    • 每个行组的元信息,每个行组是由多个列块组成的:
      • 每个列块的元信息:类型,路径,编码方式,第1个数据页的位置,第1个索引页的位置,压缩的、未压缩的尺寸,额外的KV
  • 文件中大部分内容是各个行组信息:

    • 一个行组由多个列块组成
      • 一个列块由多个页组成,在Parquet中有三种页:
        • 数据页
          • 一个页由页头、repetition levels\definition levles\valus组成
        • 字典页
          • 存储该列值的编码字典,每一个列块中最多包含一个字典页
        • 索引页
          • 用来存储当前行组下该列的索引,目前Parquet中还不支持索引页,但是在后面的版本中增加

 

计算时间优化

Parquet的最大价值在于,它提供了一种把IO奉献给查询需要用到的数据。主要的优化有两种:

  • 映射下推(Project PushDown)
  • 谓词下推(Predicate PushDown)

映射下推

列式存储的最大优势是映射下推,它意味着在获取表中原始数据时只需要扫描查询中需要的列。

Parquet中原生就支持映射下推,执行查询的时候可以通过Configuration传递需要读取的列的信息,在扫描一个行组时,只扫描对应的列。

除此之外,Parquet在读取数据时,会考虑列的存储是否是连接的,对于连续的列,一次读操作就可以把多个列的数据读到内存。

谓词下推

在RDB中谓词下推是一项非常通用的技术,通过将一些过滤条件尽可能的在最底层执行可以减少每一层交互的数据量,从而提升性能。

例如,

select count(1)
from A Join B
on A.id = B.id
where A.a > 10 and B.b < 100

SQL查询中,如果把过滤条件A.a > 10和B.b < 100分别移到TableScan的时候执行,可以大大降低Join操作的输入数据。

无论是行式存储还是列式存储,都可以做到上面提到的将一些过滤条件尽可能的在最底层执行。

但是Parquet做了更进一步的优化,它对于每个行组中的列都在存储时进行了统计信息的记录,包括最小值,最大值,空值个数。通过这些统计值和该列的过滤条件可以直接判断此行组是否需要扫描。

另外,未来还会增加Bloom Filter和Index等优化数据,更加有效的完成谓词下推。

在使用Parquet的时候可以通过如下两种策略提升查询性能:

  1. 类似于关系数据库的主键,对需要频繁过滤的列设置为有序的,这样在导入数据的时候会根据该列的顺序存储数据,这样可以最大化的利用最大值、最小值实现谓词下推。
  2. 减小行组大小和页大小,这样增加跳过整个行组的可能性,但是此时需要权衡由于压缩和编码效率下降带来的I/O负载。

详细介绍参考:https://www.cnblogs.com/ITtangtang/p/7681019.html

常用parquet文件读写的几种方式
1.用spark的hadoopFile api读取hive中的parquet格式
2.用sparkSql读写hive中的parquet
3.用新旧MapReduce读写parquet格式文件

 

分区过滤与列修剪

 分区过滤

parquet结合spark,可以完美的实现支持分区过滤。如,需要某个产品某段时间的数据,则hdfs只取这个文件夹。

spark sql、rdd 等的filter、where关键字均能达到分区过滤的效果。

使用spark的partitionBy 可以实现分区,若传入多个参数,则创建多级分区。第一个字段作为一级分区,第二个字段作为2级分区。 

 列修剪

列修剪:其实说简单点就是我们要取回的那些列的数据。

当取得列越少,速度越快。当取所有列的数据时,比如我们的120列数据,这时效率将极低。同时,也就失去了使用parquet的意义。

结论

  • parquet的gzip的压缩比率最高,若不考虑备份可以达到27倍。可能这也是spar parquet默认采用gzip压缩的原因吧。
  • 分区过滤和列修剪可以帮助我们大幅节省磁盘IO。以减轻对服务器的压力。
  • 如果你的数据字段非常多,但实际应用中,每个业务仅读取其中少量字段,parquet将是一个非常好的选择。

 

更多参考:

深入分析Parquet列式存储格式

http://www.infoq.com/cn/articles/in-depth-analysis-of-parquet-column-storage-format

第二篇文章里面的示例比较丰富,交叉比较来学习效果比较好.但是怎么持久化就没有说明.

Dremel made simple with Parquet

https://blog.twitter.com/2013/dremel-made-simple-with-parquet

第三篇文章里面可以了解到如何在形成Parquet的树状结构后,以列式的方式持久化到磁盘.

Apache Drill学习笔记二:Dremel原理(上)

http://www.tuicool.com/articles/u6bMnuZ

Presentations

https://parquet.apache.org/presentations/

Spark 编程指南简体中文版

https://www.kancloud.cn/kancloud/spark-programming-guide/51541

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章