来聊一聊存储格式

在Hadoop生态圈的快速发展过程中,涌现了一批开源的数据分析引擎,例如Hive、Spark SQL、Impala、Presto等,同时也产生了多个高性能的列式存储格式,例如RCFile、ORC、Parquet等

Google Dremel

Google 在 2004-2006 年期间发表了著名的“三驾马车”论文,开启了大数据时代。在 2010 年,Google 又发表了 3 篇论文,被称为 Google 的“新三驾马车”,可见其分量之重,其中一篇《Dremel: Interactive Analysis of Web-Scale Datasets》,提出了列式存储与多级执行树,文中介绍了 Google 运用 Dremel 分析来自互联网的千亿条级别数据的实践。
与论文中提出的列式存储相比,行式存储可以看成是一个行的集合,其中每一行都要求对齐,哪怕某个字段为空(下图中的左半部分),而列式存储则可以看成一个列的集合(下图中的右半部分)。列式存储的优点很明显,主要有以下 4 点:

  1. 查询时可以只读取涉及的列(选择操作),并且列可以直接作为索引,非常高效,而行式存储则必须读入整行。
  2. 列式存储的投影操作非常高效。
  3. 在数据稀疏的情况下,压缩率比行式存储高很多,甚至可以考虑将相关的表进行预先连接,来完全避免投影操作。
  4. 因为可以直接作用于某一列上,聚合分析非常迅速。


行式存储一般擅长的是插入与更新操作,而列式存储一般适用于数据为只读的场景。对于结构化数据,列式存储并不陌生。因此,列式存储技术经常用于传统数据仓库中。下图分别展示了行式存储和列式存储的区别。



在文章中,Dremel 在一开始就指出其面对的是只读的嵌套数据,而嵌套数据属于半结构化数据,例如 JSON、XML,所以 Dremel 的创新之处在于提出了一种支持嵌套数据的列式存储,而如今互联网上的数据又正好多是嵌套结构。下图左边是一个嵌套的 Schema,而右边的 r1、r2 为两条样例记录:



这个 Schema 其实可以转换为一个树形结构,如下图所示。

该树结构有 6 个叶子节点,可以看到叶子节点其实就是 Schema 中的基本数据类型,如果将这种嵌套结构的数据展平,那么展平后的表应该有 6 列。如果要应用列式存储来存储这种嵌套结构,还需要解决一个问题,我们看到 r1、r2 的数据结构还是差别非常大,所以需要标识出哪些列的值组成一条完整的记录,但我们不可能为每条记录都维护一个树结构。Google 提出的 record shredding and assembly algorithm 算法很好地解决了这个问题,该算法规定,在保存字段值时,还需要额外存储两个数字,分别表示 Repetition level(r)和 Definition level(d)其中,Repetition level 值记录了当前值属于哪一条记录以及它处于该记录的什么位置;


另外对于 repeated 和 optional 类型的列,可能一条记录中某一列是没有值的,如果不进行标识就会导致本该属于下一条记录的值被当作当前记录的一部分,对于这种情况就需要用 Definition level 来标识这种情况,通过 Striping & Assembly 算法我们可以将一整条记录还原出来,如下图所示。这样就能用尽可能少的存储空间来表达复杂的嵌套数据格式了。


Dremel 的另外一个组成部分是查询执行树,利用这种架构,Dremel 可以用很低的延迟分析大量数据,这使得 Dremel 非常适合进行交互式分析。Dremel 有很多种开源实现,与 Dremel 一样,它主要分为两部分,一个实现了 Dremel 的嵌套列式存储,如 Apache Parquet、Apache ORC,还有一些实现了 Dremel 的查询执行架构,也就是多级执行树,如 Apache Impala、Aapche Drill 与 Presto。


这里要特别说明的是,多级执行树这种技术与 Spark 这种 MapReduce 类型的计算框架完全不同,它类似于一种大规模并行处理,希望以较低的延迟完成查询,所以并行程度要远远大于 Spark,但是每个执行者的性能要远远弱于 Spark。如果把 Spark 看成是对 CPU 核心的抽象,那么多级执行树可以看成是对线程的抽象。基于此,多级执行树 + 列式存储的组合往往用于 OLAP 的场景。


Parquet 和 ORC 这两种数据格式和 Json 一样都是自描述数据格式,Spark 很早就支持由 Parquet、ORC 格式的数据直接生成 DataFrame。我们可以非常方便地通过 read 读取器和 write 写入器读取和生成 Parquet 和 ORC 文件。列式存储在选择、投影操作的性能优化提升非常明显

查询操作 – 投影
问题:假如要查看 小黑的 数学及语文的成绩,如何操作?
【行式存储】(1)寻址:在磁盘中找到小黑的一整条记录(包含不需要的信息);(2)将信息载入内存,在内存中剔除不需要的字段(如,英语成绩);(3)展示给用户最终结果。
【列式存储】(1)寻址:在磁盘中数学列处找到小黑的数学成绩,在语文列处找到小黑的语文成绩,相当于进行了两次磁盘寻址;(2)将信息载入内存,载入内存时没有冗余(不同于行式存储);(3)展示给用户最终结果。
如果只是投影一个属性(少数属性),那么行式存储与列式存储,个人感觉差别是不太大的。但是一旦涉及到多个属性,那么列式存储就处于相对的劣势了。

此外,Dremel 的高压缩比率也对 Spark 这种 I/O 密集型作业非常友好。在目前 Hadoop、Spark 体系的数据仓库中,已经很少采用 CSV、TEXT 这种格式了。

列式存储的实现

Apache Parquet

Apache Parquet 是 Dremel 的开源实现,它最先是由 Twitter 与 Cloudera 合作开发并开源,和 Impala 配合使用。Parquet 支持几乎 Hadoop 生态圈的所有项目,与数据处理框架、数据结构以及编程语言无关。


Parquet文件是以二进制方式存储的,是不可以直接读取和修改的,Parquet文件是自解析的,文件中包括该文件的数据和元数据。在HDFS文件系统和Parquet文件中存在如下几个概念:

  • HDFS块(Block):它是HDFS上的最小的副本单位,HDFS会把一个Block存储在本地的一个文件并且维护分散在不同的机器上的多个副本,通常情况下一个Block的大小为256M、512M等。
  • HDFS文件(File):一个HDFS的文件,包括数据和元数据,数据分散存储在多个Block中。
  • 行组(Row Group):按照行将数据物理上划分为多个单元,每一个行组包含一定的行数,在一个HDFS文件中至少存储一个行组,Parquet读写的时候会将整个行组缓存在内存中,所以如果每一个行组的大小是由内存大的小决定的。
  • 列块(Column Chunk):在一个行组中每一列保存在一个列块中,行组中的所有列连续的存储在这个行组文件中。不同的列块可能使用不同的算法进行压缩。
  • 页(Page):每一个列块划分为多个页,一个页是最小的编码的单位,在同一个列块的不同页可能使用不同的编码方式。
    通常情况下,在存储Parquet数据的时候会按照HDFS的Block大小设置行组的大小,由于一般情况下每一个Mapper任务处理数据的最小单位是一个Block,这样可以把每一个行组由一个Mapper任务处理,增大任务执行并行度。Parquet文件的格式如下图所示。


上图展示了一个Parquet文件的结构,一个文件中可以存储多个行组,文件的首位都是该文件的Magic Code,用于校验它是否是一个Parquet文件,Footer length存储了文件元数据的大小,通过该值和文件长度可以计算出元数据的偏移量,文件的元数据中包括每一个行组的元数据信息和当前文件的Schema信息。除了文件中每一个行组的元数据,每一页的开始都会存储该页的元数据,在Parquet中,有三种类型的页:数据页、字典页和索引页。数据页用于存储当前行组中该列的值,字典页存储该列值的编码字典,每一个列块中最多包含一个字典页,索引页用来存储当前行组下该列的索引,目前Parquet中还不支持索引页,但是在后面的版本中增加。

Apache ORC

Apache ORC(OptimizedRC file)来源于 RC(RecordColumnar file)格式,但目前已基本取代 RC 格式。ORC 提供 ACID 支持、也提供不同级别的索引,如布隆过滤器、列统计信息(数量、最值等),和 Parquet 一样,它也是自描述的数据格式,但与 Parquet 不同的是,ORC 支持多种复杂数据结构,如集合、映射等。ORC 与 Presto 配合使用,效果非常好。

Apache CarbonData

CarbonData 是华为开源的一种列式存储格式,是专门为海量数据分析和处理而生的。CarbonData 于 2016 年开源,目前发展非常迅猛,与 Apache Kylin 并列为由国人主导的两个Apache 顶级项目。它的设计初衷源于,在很多时候,对于同样一份数据,处理方式是不同的,比如以下几种处理方式:

  • 全表扫描,或者选取几列进行过滤;
  • 随机访问,如行键值查询,要求低延迟;
  • ad-hoc 交互式分析,如多维聚合分析、上卷、下钻、切片等。

不同的处理方式对于数据格式的需求侧重点是不同的,但 CarbonData 旨在为大数据多样化的分析需求提供一种统一的数据格式。CarbonData 的设计目标为:

  • 支持低延迟访问多种数据访问类型;
  • 允许在压缩编码过的数据上进行快速查询;
  • 确保存储空间的高效性;
  • 很好地支持 Hadoop 生态系统;
  • 读最优化的列式存储;
  • 利用多级索引实现低延迟;
  • 支持利用列组来获得基于行的优点;
  • 能够对聚合的延迟解码进行字典编码。


如下图所示,这是一个 CarbonData 数据文件,也是 HDFS 上的一个数据块,每个文件由 File Header、File Footer 与若干个 Blocklet 组成,其中 File Header 保存了文件版本号、Schema 以及更新时间戳;File Footer 包含了一些统计信息(每个 Blocklet 的最值)、多维索引等。一个 Blocklet 的默认大小为 64MB,包含多个 Column Page Group,Blocklet 可以看成一个表的水平切片,这个表有多少列,就有多少个 Column Page Group,在一个 Column Page Group 中,一列被分为若干个连续文件,每一个文件被称为 Page,一个 Page 默认为 32000 行,如下图所示。

这里要特别说明的是,CarbonData 在设计理念上没有采取 Dremel 提出的嵌套的列式存储,而是引入了索引和元数据的设计,但仍然属于列式存储格式。

对比测试

使用列式存储对 Spark 性能提升的影响是非常巨大的,下面是一份测试结果,包含了对于同样一份数据(368.4G),各种数据格式压缩率的对比,以及一些计算作业耗时的对比:

TEXTParquetORCCarbonData
压缩后大小368.4G298G148.4G145.8G
压缩率100%19.11%59.72%60.42%
TEXTParquetORCCarbonData
Count67s116s119s6s
Group By138s75s71s92s
Join231s172s140s95s

可以看到 ORC 的压缩率最高,而 CarbonData 在 Spark 批处理这种场景下,性能表现得非常好,是一种非常有前景的技术。


列式存储的压缩率如此之高,从第一张图也可以看出原因,列式存储作为列的集合,空间几乎没有多余的浪费。如此高的压缩效率也带来了一个优化思路:可以将若干相关的表预先进行连接,连接而成的表可以看成是一张稀疏的宽表,这张宽表对分析来说就非常友好了,但由于采用了列式存储,所以宽表所占的空间并不是指数上涨而是线性增加。在这种场景下,列式存储使得空间换时间成为可能。

映射下推(Project PushDown)

说到列式存储的优势,映射下推是最突出的,它意味着在获取表中原始数据时只需要扫描查询中需要的列,由于每一列的所有值都是连续存储的,所以分区取出每一列的所有值就可以实现TableScan算子,而避免扫描整个表文件内容。


在Parquet中原生就支持映射下推,执行查询的时候可以通过Configuration传递需要读取的列的信息,这些列必须是Schema的子集,映射每次会扫描一个Row Group的数据,然后一次性得将该Row Group里所有需要的列的Cloumn Chunk都读取到内存中,每次读取一个Row Group的数据能够大大降低随机读的次数,除此之外,Parquet在读取的时候会考虑列是否连续,如果某些需要的列是存储位置是连续的,那么一次读操作就可以把多个列的数据读取到内存。

谓词下推(Predicate PushDown)

在数据库之类的查询系统中最常用的优化手段就是谓词下推了,通过将一些过滤条件尽可能的在最底层执行可以减少每一层交互的数据量,从而提升性能,例如”select count(1) from A Join B on A.id = B.id where A.a > 10 and B.b < 100”SQL查询中,在处理Join操作之前需要首先对A和B执行TableScan操作,然后再进行Join,再执行过滤,最后计算聚合函数返回,但是如果把过滤条件A.a > 10和B.b < 100分别移到A表的TableScan和B表的TableScan的时候执行,可以大大降低Join操作的输入数据。


无论是行式存储还是列式存储,都可以在将过滤条件在读取一条记录之后执行以判断该记录是否需要返回给调用者,在Parquet做了更进一步的优化,优化的方法时对每一个Row Group的每一个Column Chunk在存储的时候都计算对应的统计信息,包括该Column Chunk的最大值、最小值和空值个数。通过这些统计值和该列的过滤条件可以判断该Row Group是否需要扫描。另外Parquet未来还会增加诸如Bloom Filter和Index等优化数据,更加有效的完成谓词下推。


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

性能

相比传统的行式存储,Hadoop生态圈近年来也涌现出诸如RC、ORC、Parquet的列式存储格式,它们的性能优势主要体现在两个方面:

  1. 更高的压缩比,由于相同类型的数据更容易针对不同类型的列使用高效的编码和压缩方式。
  2. 更小的I/O操作,由于映射下推和谓词下推的使用,可以减少一大部分不必要的数据扫描,尤其是表结构比较庞大的时候更加明显,由此也能够带来更好的查询性能。





上图是展示了使用不同格式存储TPC-H和TPC-DS数据集中两个表数据的文件大小对比,可以看出Parquet较之于其他的二进制文件存储格式能够更有效的利用存储空间,而新版本的Parquet(2.0版本)使用了更加高效的页存储方式,进一步的提升存储空间。

上图展示了Twitter在Impala中使用不同格式文件执行TPC-DS基准测试的结果,测试结果可以看出Parquet较之于其他的行式存储格式有较明显的性能提升。

上图展示了criteo公司在Hive中使用ORC和Parquet两种列式存储格式执行TPC-DS基准测试的结果,测试结果可以看出在数据存储方面,两种存储格式在都是用snappy压缩的情况下量中存储格式占用的空间相差并不大,查询的结果显示Parquet格式稍好于ORC格式,两者在功能上也都有优缺点,Parquet原生支持嵌套式数据结构,而ORC对此支持的较差,这种复杂的Schema查询也相对较差;而Parquet不支持数据的修改和ACID,但是ORC对此提供支持,但是在OLAP环境下很少会对单条数据修改,更多的则是批量导入。


Parquet 列式存储带来的性能上的提高在业内已经得到了充分的认可,特别是当你们的表非常宽(column 非常多)的时候,Parquet 无论在资源利用率还是性能上都优势明显。


Spark 已经将 Parquet 设为默认的文件存储格式,Cloudera 投入了很多工程师到 Impala+Parquet 相关开发中,Hive/Pig 都原生支持 Parquet。Parquet 现在为 Twitter 至少节省了 1/3 的存储空间,同时节省了大量的表扫描和反序列化的时间。这两方面直接反应就是节约成本和提高性能。


如果说 HDFS 是大数据时代文件系统的事实标准的话,Parquet 就是大数据时代存储格式的事实标准。

如何选择不同的数据格式

考虑因素:

  1. 读写速度

  2. 按行读多还是按列读多

  3. 是否支持文件分割

  4. 压缩率

  5. 是否支持schema evolution

    不同数据格式最佳实践

  6. 读取少数列可以选择面向列存储的ORC或者Parquet

  7. 如果需要读取的列比较多,选择AVRO更优

  8. 如果schema 变更频繁最佳选择avro

  9. 实际上随着版本不断更新,现在parquet和orc都在一定程度上支持schema evolution,比如在最后面加列

  10. ORC的查询性能优于Parquet