目录
概述
数据存储是信息技术对您每天所需的数据内容(从应用到网络协议,从文档到媒体,从地址簿到用户首选项)进行归档、整理和共享的过程。数据存储是大数据的核心环节。
试想一下,计算机就像大脑一样。两者都有短期记忆和长期记忆。大脑通过前额叶皮层来处理短期记忆,而计算机则利用随机存取存储器(RAM)来处理短期记忆。
大脑和 RAM 都要在清醒的状态下处理并记住事务,并且在工作一会儿后会感到疲倦。大脑在睡眠时会将工作记忆转换为长期记忆,而计算机则在睡眠时将活动记忆转换为存储卷。计算机还会按类型来分配数据,就像大脑按语义、空间、情感或规程来分配记忆一样。
随着数据存储技术的发展,数据的存储也从非结构化到半结构化最后到结构化数据的演进。
数据存储模式
目前大数据存储有几种方案可供选择:行存储、列存储、行列混合存储。业界对这几种存储方案有很多争执,集中焦点是:谁能够更有效地处理海量数据,且兼顾安全、可靠、完整性。从目前发展情况看,关系数据库已经不适应这种巨大的存储量和计算要求,基本是淘汰出局。在已知的几种大数据处理软件中,Hadoop的HBase采用列存储,MongoDB是文档型的行存储,Lexst是二进制型的行存储。
下图描述行存、列存、行列混合存储的物理结构如下图所示。
一、行式存储模式
传统的关系型数据库,如 Oracle、DB2、MySQL、SQL SERVER 等采用行式存储法(Row-based),在基于行式存储的数据库中, 数据是按照行数据为基础逻辑存储单元进行存储的, 一行中的数据在存储介质中以连续存储形式存在。
存储方式如下图所示:
行存储适合非常典型的OLTP(On-Line Transaction Processing)应用场景。为什么这么说呢,因为OLTP应用场景有如下特点:
- 随机的增删改查操作。
- 需要在行中选取所有属性的查询操作。
- 需要频繁插入或更新的操作,其操作与索引和行的大小更为相关。
所以传统 OLTP 数据库通常采用的是行式存储。所有的列依次排列构成一行,以行为单位存储,再配合以 B+ 树或 SS-Table 作为索引,就能快速通过主键找到相应的行数据。而且OLTP 数据库大多数场景下是为增删改查一整行记录,显然把一行数据存在物理上相邻的位置是个很好的选择。
二、列式存储模式
列式存储(Column-oriented Storage)并不是一项新技术,最早可以追溯到 1983 年的论文 Cantor。然而,受限于早期的硬件条件和使用场景,主流的事务型数据库(OLTP)大多采用行式存储,直到近几年分析型数据库(OLAP)的兴起,列式存储这一概念又变得流行。
存储方式如下图所示:
对于 OLAP(Online Analytical Processing) 场景,一个典型的查询需要遍历整个表,进行分组、排序、聚合等操作,这样一来按行存储的优势就不复存在了。更糟糕的是,分析型 SQL 常常不会用到所有的列,而仅仅对其中某些感兴趣的列做运算,那一行中那些无关的列也不得不参与扫描。
列式存储就是为这样的需求设计的。同一列的数据被一个接一个紧挨着存放在一起,表的每列构成一个长数组。
总的来说,列式存储的优势一方面体现在存储上能节约空间(提高压缩率)、减少 IO,另一方面依靠列式数据结构做了计算上的优化。
三、行式存储于列式存储的区别
行存储的写入是一次完成。如果这种写入建立在操作系统的文件系统上,可以保证写入过程的成功或者失败,数据的完整性因此可以确定。列存储由于需要把一行记录拆分成单列保存,写入次数明显比行存储多,再加上磁头需要在盘片上移动和定位花费的时间,实际时间消耗会更大。所以,行存储在写入上占有很大的优势。
还有数据修改, 这实际也是一次写入过程。不同的是,数据修改是对磁盘上的记录做删除标记。行存储是在指定位置写入一次,列存储是将磁盘定位到多个列上分别写入,这个过程仍是行存储的列数倍。所以,数据修改也是以行存储占优。数据读取时,行存储通常将一行数据完全读出,如果只需要其中几列数据的情况,就会存在冗余列,出于缩短处理时间的考量,消除冗余列的过程通常是在内存中进行的。列存储每次读取的数据是集合的一段或者全部,如果读取多列时,就需要移动磁头,再次定位到下一列的位置继续读取。再谈两种存储的数据分布。由于列存储的每一列数据类型是同质的,不存在二义性问题。比如说某列数据类型为整型(int),那么它的数据集合一定是整型数据。这种情况使数据解析变得十分容易。相比之下,行存储则要复杂得多,因为在一行记录中保存了多种类型的数据,数据解析需要在多种数据类型之间频繁转换,这个操作很消耗 CPU,增加了解析的时间。所以,列存储的解析过程更有利于分析大数据。
相比于行式存储,列式存储在分析场景下有着许多优良的特性。
- 分析场景中往往需要读大量行但是少数几个列。在行存模式下,数据按行连续存储,所有列的数据都存储在一个block中,不参与计算的列在IO时也要全部读出,读取操作被严重放大。而列存模式下,只需要读取参与计算的列即可,极大的减低了IO cost,加速了查询。
- 同一列中的数据属于同一类型,压缩效果显著。列式存储往往有着高达十倍甚至更高的压缩比,节省了大量的存储空间,降低了存储成本。
- 更高的压缩比意味着更小的data size,从磁盘中读取相应数据耗时更短。
- 自由的压缩算法选择。不同列的数据具有不同的数据类型,适用的压缩算法也就不尽相同。可以针对不同列类型,选择最合适的压缩算法。
- 高压缩比,意味着同等大小的内存能够存放更多数据,系统cache效果更好。
官方数据显示,通过使用列存,在某些分析场景下,能够获得100倍甚至更高的加速效应。
四、行列混合式存储模式
两种存储格式各自的特性都决定了它们不可能是完美的解决方案。如果首要考虑是数据的完整性和可靠性,那么行存储是不二选择,列存储只有在增加磁盘并改进软件设计后才能接近这样的目标。如果以保存数据为主,行存储的写入性能比列存储高很多。在需要频繁读取单列集合数据的应用中,列存储是最合适的。如果每次读取多列,两个方案可酌情选择:采用行存储时,设计中应考虑减少或避免冗余列;若采用列存储方案,为保证读写入效率,每列数据尽可能分别保存到不同的磁盘上,多个线程并行读写各自的数据,这样避免了磁盘竞用的同时也提高了处理效率。既然行式存储和列式存储各有各的优点,也各有各的缺点。那么我们可不可以采用行列混合存储让存储的性能达到更高效的方式。所以业界就产生了行列混合存储的解决方案。
存储方式如下图所示:
行列混合存储的出现解决了单一行存或者单一列存没有办法解决的问题。比较适合在HTAP(Hybrid Transactional/Analytical Processing)领域使用。把用户表拆解成一个一个分组,这些分组采用行存策略,每一个分组中采用列存的策略如上图所示。
介绍完这些存储的方案,那么下面带大家了解一下一种非常出名的基于列存的存储格式Parquet。
Parquet的含义
Apache Parquet是Hadoop生态圈中一种新型列式存储格式,它可以兼容Hadoop生态圈中大多数计算框架(Hadoop、Spark等),被多种查询引擎支持(Hive、Impala、Drill等),并且它是语言和平台无关的。Parquet最初是由Twitter和Cloudera(由于Impala的缘故)合作开发完成并开源,2015年5月从Apache的孵化器里毕业成为Apache顶级项目。
Parquet的灵感来自于2010年Google发表的Dremel论文,文中介绍了一种支持嵌套结构的存储格式,并且使用了列式存储的方式提升查询性能,在Dremel论文中还介绍了Google如何使用这种存储格式实现并行查询的,如果对此感兴趣可以参考论文和开源实现Apache Drill。
Parquet是语言无关的,而且不与任何一种数据处理框架绑定在一起,适配多种语言和组件,能够与Parquet配合的组件有:
查询引擎: Hive, Impala, Pig, Presto, Drill, Tajo, HAWQ, IBM Big SQL
计算框架: MapReduce, Spark, Cascading, Crunch, Scalding, Kite
数据模型: Avro, Thrift, Protocol Buffers, POJOs
Parquet如何和这些组件配合使用呢?通过下面的图来了解下
数据从内存到 Parquet 文件或者反过来的过程主要由以下三个部分组成:
- 数据存储层:定义 Parquet 文件格式,其中元数据在 parquet-format 项目中定义,包括 Parquet 原始类型定义、Page类型、编码类型、压缩类型等等。
- 对象转换层:这一层在 parquet-mr 项目中,包含多个模块,作用是完成其他对象模型与 Parquet 内部数据模型的映射和转换,Parquet 的编码方式使用的是 striping and assembly 算法。
- 对象模型层:定义如何读取 Parquet 文件的内容,这一层转换包括 Avro、Thrift、Protocal Buffer 等对象模型/序列化格式、Hive serde 等的适配。并且为了帮助大家理解和使用,Parquet 提供了 org.apache.parquet.example 包实现了 java 对象和 Parquet 文件的转换。
其中,对象模型可以简单理解为内存中的数据表示,Avro, Thrift, Protocol Buffer, Pig Tuple, Hive SerDe 等这些都是对象模型。例如 parquet-mr 项目里的 parquet-pig 项目就是负责把内存中的 Pig Tuple 序列化并按列存储成 Parquet 格式,以及反过来把 Parquet 文件的数据反序列化成 Pig Tuple。
这里需要注意的是 Avro, Thrift, Protocol Buffer 等都有他们自己的存储格式,但是 Parquet 并没有使用他们,而是使用了自己在 parquet-format 项目里定义的存储格式。所以如果你的项目使用了 Avro 等对象模型,这些数据序列化到磁盘还是使用的 parquet-mr 定义的转换器把他们转换成 Parquet 自己的存储格式。
Parquet的数据模型
Parquet支持嵌套的数据模型,类似于Protocol Buffers,每一个数据模型的schema包含多个字段,每一个字段又可以包含多个字段,每一个字段有三个属性:重复数、数据类型和字段名,重复数可以是以下三种:
- required(出现1次)
- repeated(出现0次或多次)
- optional(出现0次或1次)
每一个字段的数据类型可以分成两种:
- group(复杂类型)
- primitive(基本类型)
用一个网络上面的例子说明一下:
message AddressBook {
required string owner;
repeated string ownerPhoneNumbers;
repeated group contacts {
required string name;
optional string phoneNumber;
}
}
Schema成员关系图如下:
这个 schema 中每条记录表示一个人的 AddressBook。
- 有且只有一个 owner,
- owner 可以有 0 个或者多个 ownerPhoneNumbers,
- owner 可以有 0 个或者多个 contacts。
每个 contact 又有如下属性:
- 有且只有一个 name
- 这个 contact 的 phoneNumber 可有可无。
Parquet 格式的数据类型没有复杂的 Map, List, Set 等,而是使用 repeated fields 和 groups 来表示。例如 List 和 Set 可以被表示成一个 repeated field,Map 可以表示成一个包含有 key-value 对的 repeated field,而且 key 是 required 的。
Lists (or Sets) 可以用repeating field属性描述如下所示:
Map的描述形式如下所示:
Parquet的数据类型
Parquet的存储格式
Parquet文件是以二进制方式存储的,所以是不可以直接读取的,文件中包括该文件的数据和元数据。
Parquet 的存储模型主要由行组(Row Group)、列块(Column Chuck)、页(Page)组成。
- 行组,Row Group:Parquet 在水平方向上将数据划分为行组,默认行组大小与 HDFS Block 块大小对齐,Parquet 保证一个行组会被一个 Mapper 处理。
- 列块,Column Chunk:行组中每一列保存在一个列块中,一个列块具有相同的数据类型,不同的列块可以使用不同的压缩。
- 页,Page:Parquet 是页存储方式,每一个列块包含多个页,一个页是最小的编码的单位,同一列块的不同页可以使用不同的编码方式。
另外 Parquet 文件还包含header与footer信息,分别存储文件的校验码与Schema等信息。参考官网的一张图:
上图展示了一个Parquet文件的内容,一个文件中可以存储多个行组,文件的首位都是该文件的Magic Code,用于校验它是否是一个Parquet文件,Footer length了文件元数据的大小,通过该值和文件长度可以计算出元数据的偏移量,文件的元数据中包括每一个行组的元数据信息和该文件存储数据的Schema信息。除了文件中每一个行组的元数据,每一页的开始都会存储该页的元数据,在Parquet中,有三种类型的页:数据页、字典页和索引页。数据页用于存储当前行组中该列的值,字典页存储该列值的编码字典,每一个列块中最多包含一个字典页,索引页用来存储当前行组下该列的索引,目前Parquet中还不支持索引页,但是在后面的版本中增加。
Striping/Assembly算法
上文介绍了Parquet的数据模型,在AddressBook中存在多个非required列,由于Parquet一条记录的数据分散的存储在不同的列中,如何组合不同的列值组成一条记录是由Striping/Assembly算法决定的,
在该算法中列的每一个值都包含三部分:
- value
- repetition level
- definition level
一、Definition Level
嵌套数据类型的特点是有些field可以是空的,也就是没有定义。如果一个field是定义的,那么它的所有的父节点都是被定义的。从根节点开始遍历,当某一个field的路径上的节点开始是空的时候我们记录下当前的深度作为这个field的Definition Level。如果一个field的Definition Level等于这个field的最大Definition Level就说明这个field是有数据的。对于required类型的field必须是有定义的,所以这个Definition Level是不需要的。在关系型数据中,optional类型的field被编码成0表示空和1表示非空(或者反之)。
二、Repetition Level
记录该field的值是在哪一个深度上重复的。只有repeated类型的field需要Repetition Level,optional 和 required类型的不需要。Repetition Level = 0 表示开始一个新的record。在关系型数据中,repetion level总是0。
三、举例说明Striping和assembly的过程
下面用AddressBook的例子来说明Striping和assembly的过程。
对于每个column的最大的Repetion Level和 Definition Level如下图所示。
下面这样两条record:
AddressBook {
owner: "Julien Le Dem",
ownerPhoneNumbers: "555 123 4567",
ownerPhoneNumbers: "555 666 1337",
contacts: {
name: "Dmitriy Ryaboy",
phoneNumber: "555 987 6543",
},
contacts: {
name: "Chris Aniszczyk"
}
}
AddressBook {
owner: "A. Nonymous"
}
以contacts.phoneNumber这一列为例,"555 987 6543"这个contacts.phoneNumber的Definition Level是最大Definition Level=2。而如果一个contact没有phoneNumber,那么它的Definition Level就是1。如果连contact都没有,那么它的Definition Level就是0。
下面我们拿掉其他三个column只看contacts.phoneNumber这个column,把上面的两条record简化成下面的样子:
AddressBook {
contacts: {
phoneNumber: "555 987 6543"
}
contacts: {
}
}
AddressBook {
}
这两条记录的序列化过程如下图所示:
如果我们要把这个column写到磁盘上,磁盘上会写入这样的数据
注意:NULL实际上不会被存储,如果一个column value的Definition Level小于该column最大Definition Level的话,那么就表示这是一个空值。
下面是从磁盘上读取数据并反序列化成AddressBook对象的过程:
- 读取第一个三元组R=0, D=2, Value=”555 987 6543”
- R=0 表示是一个新的record,要根据schema创建一个新的nested record直到Definition Level=2。
- D=2 说明Definition Level=Max Definition Level,那么这个Value就是contacts.phoneNumber这一列的值,赋值操作contacts.phoneNumber=”555 987 6543”。
- 读取第二个三元组 R=1, D=1
- R=1 表示不是一个新的record,是上一个record中一个新的contacts。
- D=1 表示contacts定义了,但是contacts的下一个级别也就是phoneNumber没有被定义,所以创建一个空的contacts。
- 读取第三个三元组 R=0, D=0
- R=0 表示一个新的record,根据schema创建一个新的nested record直到Definition Level=0,也就是创建一个AddressBook根节点。
可以看出在Parquet列式存储中,对于一个schema的所有叶子节点会被当成column存储,而且叶子节点一定是primitive类型的数据。对于这样一个primitive类型的数据会衍生出三个sub columns (R, D, Value),也就是从逻辑上看除了数据本身以外会存储大量的Definition Level和Repetition Level。那么这些Definition Level和Repetition Level是否会带来额外的存储开销呢?实际上这部分额外的存储开销是可以忽略的。因为对于一个schema来说level都是有上限的,而且非repeated类型的field不需要Repetition Level,required类型的field不需要Definition Level,也可以缩短这个上限。例如对于Twitter的7层嵌套的schema来说,只需要3个bits就可以表示这两个Level了。
对于存储关系型的record,record中的元素都是非空的(NOT NULL in SQL)。Repetion Level和Definition Level都是0,所以这两个sub column就完全不需要存储了。所以在存储非嵌套类型的时候,Parquet格式也是一样高效的。
投影下推(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负载。
Parquet的缺点
Parquet的实时写方面是硬伤,基于Parquet的方案基本上都是批量写。一般情况,都是定期生成Parquet文件,所以数据延迟比较严重。为了提高数据的实时性,还需要其他解决方案来解决数据实时的查询,Parquet只能作为历史数据查询的补充。
结论
本文主要讲述了大数据领域,数据的存储方式有哪些,并且这些存储方式有哪些特点。那么针对于这些存储方式的痛点,业界又是用什么方案来解决的。着重讲述了基于列式存储的Parquet格式,Parquet是Apache的顶级开源项目,除了 Parquet,另一个常见的列式存储格式是 ORC(OptimizedRC File)也是Apache的顶级开源项目,那么他们之间有什么样的不同,和适用于哪些场景在之后的文章里面会有描述。今天就到这里了,下次再见。
参考资料
- http://parquet.apache.org/
- https://blog.twitter.com/2013/dremel-made-simple-with-parquet
- http://blog.cloudera.com/blog/2015/04/using-apache-parquet-at-appnexus/
- http://blog.cloudera.com/blog/2014/05/using-impala-at-scale-at-allstate/
- http://static.googleusercontent.com/media/research.google.com/zh-CN//pubs/archive/36632.pdf
- https://docs.cloudera.com/documentation/enterprise/latest/topics/impala_file_formats.html

微信公众号名称:技术茶馆
微信公众号ID : Night_ZW