网易视频云:新一代列式存储格式Parquet

网易视频云是网易倾力打造的一款基于云计算的分布式多媒体处理集群和专业音视频技术,提供稳定流畅、低时延、高并发的视频直播、录制、存储、转码及点播等音视频的PAAS服务,在线教育、远程医疗、娱乐秀场、在线金融等各行业及企业用户只需经过简单的开发即可打造在线音视频平台。现在,网易视频云的技术专家给大家分享一则技术文:新一代列式存储格式Parquet。

Apache Parquet是Hadoop生态圈中一种新型列式存储格式,它可以兼容Hadoop生态圈中大多数计算框架(Hadoop、Spark等),被多种查询引擎支持(Hive、Impala、Drill等),并且它是语言和平台无关的。Parquet最初是由Twitter和Cloudera(由于Impala的缘故)合作开发完成并开源,2015年5月从Apache的孵化器里毕业成为Apache顶级项目,最新的版本是1.8.1。

Parquet是什么

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

嵌套数据模型

在接触大数据之前,我们简单的将数据划分为结构化数据和非结构化数据,通常我们使用关系数据库存储结构化数据,而关系数据库中使用数据模型都是扁平式的,遇到诸如List、Map和自定义Struct的时候就需要用户在应用层解析。但是在大数据环境下,通常数据的来源是服务端的埋点数据,很可能需要把程序中的某些对象内容作为输出的一部分,而每一个对象都可能是嵌套的,所以如果能够原生的支持这种数据,这样在查询的时候就不需要额外的解析便能获得想要的结果。例如在Twitter,在他们的生产环境中一个典型的日志对象(一条记录)有87个字段,其中嵌套了7层,如下图:
网易视频云:新一代列式存储格式Parquet_第1张图片

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

列式存储

列式存储,顾名思义就是按照列进行存储数据,把某一列的数据连续的存储,每一行中的不同列的值离散分布。列式存储技术并不新鲜,在关系数据库中都已经在使用,尤其是在针对OLAP场景下的数据存储,由于OLAP场景下的数据大部分情况下都是批量导入,基本上不需要支持单条记录的增删改操作,而查询的时候大多数都是只使用部分列进行过滤、聚合,对少数列进行计算(基本不需要select * from xx之类的查询)。列式存储可以大大提升这类查询的性能,较之于行是存储,列式存储能够带来这些优化:

1、由于每一列中的数据类型相同,所以可以针对不同类型的列使用不同的编码和压缩方式,这样可以大大降低数据存储空间。
2、读取数据的时候可以把映射(Project)下推,只需要读取需要的列,这样可以大大减少每次查询的I/O数据量,更甚至可以支持谓词下推,跳过不满足条件的列。
3、由于每一列的数据类型相同,可以使用更加适合CPU pipeline的编码方式,减小CPU的缓存失效。

Parquet的组成

Parquet仅仅是一种存储格式,它是语言、平台无关的,并且不需要和任何一种数据处理框架绑定,目前能够和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-format项目由java实现,它定义了所有Parquet元数据对象,Parquet的元数据是使用Apache Thrift进行序列化并存储在Parquet文件的尾部。
parquet-mr项目由java实现,它包括多个模块,包括实现了读写Parquet文件的功能,并且提供一些和其它组件适配的工具,例如Hadoop Input/Output Formats、Hive Serde(目前Hive已经自带Parquet了)、Pig loaders等。
parquet-compatibility项目,包含不同编程语言之间(JAVA和C/C++)读写文件的测试代码。
parquet-cpp项目,它是用于用于读写Parquet文件的C++库。
下图展示了Parquet各个组件的层次以及从上到下交互的方式。
网易视频云:新一代列式存储格式Parquet_第2张图片

数据存储层定义了Parquet的文件格式,其中元数据在parquet-format中定义,包括Parquet原始类型定义、Page类型、编码类型、压缩类型等等。
对象转换层完成其他对象模型与Parquet内部数据模型的映射和转换,Parquet的编码方式使用的是striping and assembly算法。
对象模型层定义了如何读取Parquet文件的内容,这一层转换包括Avro、Thrift、PB等序列化格式、Hive serde等的适配。并且为了帮助大家理解和使用,Parquet提供了org.apache.parquet.example包实现了java对象和Parquet文件的转换。

数据模型

Parquet支持嵌套的数据模型,类似于Protocol Buffers,每一个数据模型的schema包含多个字段,每一个字段又可以包含多个字段,每一个字段有三个属性:重复数、数据类型和字段名,重复数可以是以下三种:required(出现1次),repeated(出现0次或多次),optional(出现0次或1次)。每一个字段的数据类型可以分成两种:group(复杂类型)和primitive(基本类型)。例如Dremel中提供的Document的schema示例,它的定义如下:

message Document {
required int64 DocId;
optional group Links {
repeated int64 Backward;
repeated int64 Forward;
}
repeated group Name {
repeated group Language {
required string Code;
optional string Country;
}
optional string Url;
}
}

可以把这个Schema转换成树状结构,根节点可以理解为repeated类型,如下图:
网易视频云:新一代列式存储格式Parquet_第3张图片

可以看出在Schema中所有的基本类型字段都是叶子节点,在这个Schema中一共存在6个叶子节点,如果把这样的Schema转换成扁平式的关系模型,就可以理解为该表包含六个列。Parquet中没有Map、Array这样的复杂数据结构,但是可以通过repeated和group组合来实现这样的需求。在这个包含6个字段的表中有以下几个字段和每一条记录中它们可能出现的次数:

DocId int64 只能出现一次
Links.Backward int64 可能出现任意多次,但是如果出现0次则需要使用NULL标识
Links.Forward int64 同上
Name.Language.Code string 同上
Name.Language.Country string 同上
Name.Url string 同上
由于在一个表中可能存在出现任意多次的列,对于这些列需要标示出现多次或者等于NULL的情况,它是由Striping/Assembly算法实现的。

Striping/Assembly算法

上文介绍了Parquet的数据模型,在Document中存在多个非required列,由于Parquet一条记录的数据分散的存储在不同的列中,如何组合不同的列值组成一条记录是由Striping/Assembly算法决定的,在该算法中列的每一个值都包含三部分:value、repetition level和definition level。

Repetition Levels

为了支持repeated类型的节点,在写入的时候该值等于它和前面的值在哪一层节点是不共享的。在读取的时候根据该值可以推导出哪一层上需要创建一个新的节点,例如对于这样的一个schema和两条记录。

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

计算repetition level值的过程如下:
value=a是一条记录的开始,和前面的值(已经没有值了)在根节点(第0层)上是不共享的,所以repeated level=0.
value=b它和前面的值共享了level1这个节点,但是level2这个节点上是不共享的,所以repeated level=2.
同理value=c, repeated level=2.
value=d和前面的值共享了根节点(属于相同记录),但是在level1这个节点上是不共享的,所以repeated level=1.
value=h和前面的值不属于同一条记录,也就是不共享任何节点,所以repeated level=0.
根据以上的分析每一个value需要记录的repeated level值如下:
网易视频云:新一代列式存储格式Parquet_第4张图片

在读取的时候,顺序的读取每一个值,然后根据它的repeated level创建对象,当读取value=a时repeated level=0,表示需要创建一个新的根节点(新记录),value=b时repeated level=2,表示需要创建一个新的level2节点,value=d时repeated level=1,表示需要创建一个新的level1节点,当所有列读取完成之后可以创建一条新的记录。本例中当读取文件构建每条记录的结果如下:
网易视频云:新一代列式存储格式Parquet_第5张图片

可以看出repeated level=0表示一条记录的开始,并且repeated level的值只是针对路径上的repeated类型的节点,因此在计算该值的时候可以忽略非repeated类型的节点,在写入的时候将其理解为该节点和路径上的哪一个repeated节点是不共享的,读取的时候将其理解为需要在哪一层创建一个新的repeated节点,这样的话每一列最大的repeated level值就等于路径上的repeated节点的个数(不包括根节点)。减小repeated level的好处能够使得在存储使用更加紧凑的编码方式,节省存储空间。

Definition Levels

有了repeated level我们就可以构造出一个记录了,为什么还需要definition levels呢?由于repeated和optional类型的存在,可能一条记录中某一列是没有值的,假设我们不记录这样的值就会导致本该属于下一条记录的值被当做当前记录的一部分,从而造成数据的错误,因此对于这种情况需要一个占位符标示这种情况。

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


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

它包含一个列a.b.c,这个列的的每一个节点都是optional类型的,当c被定义时a和b肯定都是已定义的,当c未定义时我们就需要标示出在从哪一层开始时未定义的,如下面的值:
网易视频云:新一代列式存储格式Parquet_第6张图片

由于definition level只需要考虑未定义的值,而对于repeated类型的节点,只要父节点是已定义的,该节点就必须定义(例如Document中的DocId,每一条记录都该列都必须有值,同样对于Language节点,只要它定义了Code必须有值),所以计算definition level的值时可以忽略路径上的required节点,这样可以减小definition level的最大值,优化存储。

一个完整的例子

本节我们使用Dremel论文中给的Document示例和给定的两个值r1和r2展示计算repeated level和definition level的过程,这里把未定义的值记录为NULL,使用R表示repeated level,D表示definition level。
网易视频云:新一代列式存储格式Parquet_第7张图片

首先看DocuId这一列,对于r1,DocId=10,由于它是记录的开始并且是已定义的,所以R=0,D=0,同样r2中的DocId=20,R=0,D=0。

对于Links.Forward这一列,在r1中,它是未定义的但是Links是已定义的,并且是该记录中的第一个值,所以R=0,D=1,在r1中该列有两个值,value1=10,R=0(记录中该列的第一个值),D=2(该列的最大definition level)。

对于Name.Url这一列,r1中它有三个值,分别为url1=’http://A’,它是r1中该列的第一个值并且是定义的,所以R=0,D=2;value2=’http://B’,和上一个值value1在Name这一层是不相同的,所以R=1,D=2;value3=NULL,和上一个值value2在Name这一层是不相同的,所以R=1,但它是未定义的,而Name这一层是定义的,所以D=1。r2中该列只有一个值value3=’http://C’,R=0,D=2.

最后看一下Name.Language.Code这一列,r1中有4个值,value1=’en-us’,它是r1中的第一个值并且是已定义的,所以R=0,D=2(由于Code是required类型,这一列repeated level的最大值等于2);value2=’en’,它和value1在Language这个节点是不共享的,所以R=2,D=2;value3=NULL,它是未定义的,但是它和前一个值在Name这个节点是不共享的,在Name这个节点是已定义的,所以R=1,D=1;value4=’en-gb’,它和前一个值在Name这一层不共享,所以R=1,D=2。在r2中该列有一个值,它是未定义的,但是Name这一层是已定义的,所以R=0,D=1.

Parquet文件格式

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

HDFS块(Block):它是HDFS上的最小的副本单位,HDFS会把一个Block存储在本地的一个文件并且维护分散在不同的机器上的多个副本,通常情况下一个Block的大小为256M、512M等。
HDFS文件(File):一个HDFS的文件,包括数据和元数据,数据分散存储在多个Block中。
行组(Row Group):按照行将数据物理上划分为多个单元,每一个行组包含一定的行数,在一个HDFS文件中至少存储一个行组,Parquet读写的时候会将整个行组缓存在内存中,所以如果每一个行组的大小是由内存大的小决定的,例如记录占用空间比较小的Schema可以在每一个行组中存储更多的行。
列块(Column Chunk):在一个行组中每一列保存在一个列块中,行组中的所有列连续的存储在这个行组文件中。一个列块中的值都是相同类型的,不同的列块可能使用不同的算法进行压缩。
页(Page):每一个列块划分为多个页,一个页是最小的编码的单位,在同一个列块的不同页可能使用不同的编码方式。

文件格式

通常情况下,在存储Parquet数据的时候会按照Block大小设置行组的大小,由于一般情况下每一个Mapper任务处理数据的最小单位是一个Block,这样可以把每一个行组由一个Mapper任务处理,增大任务执行并行度。Parquet文件的格式如下图所示。
网易视频云:新一代列式存储格式Parquet_第8张图片

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

在执行MR任务的时候可能存在多个Mapper任务的输入是同一个Parquet文件的情况,每一个Mapper通过InputSplit标示处理的文件范围,如果多个InputSplit跨越了一个Row Group,Parquet能够保证一个Row Group只会被一个Mapper任务处理。

映射下推(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操作,由于映射下推和谓词下推的使用,可以减少一大部分不必要的数据扫描,尤其是表结构比较庞大的时候更加明显,由此也能够带来更好的查询性能。
网易视频云:新一代列式存储格式Parquet_第9张图片

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

上图展示了Twitter在Impala中使用不同格式文件执行TPC-DS基准测试的结果,测试结果可以看出Parquet较之于其他的行式存储格式有较明显的性能提升。
网易视频云:新一代列式存储格式Parquet_第11张图片

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

项目发展

自从2012年由Twitter和Cloudera共同研发Parquet开始,该项目一直处于高速发展之中,并且在项目之初就将其贡献给开源社区,2013年,Criteo公司加入开发并且向Hive社区提交了向hive集成Parquet的patch(HIVE-5783),在Hive 0.13版本之后正式加入了Parquet的支持;之后越来越多的查询引擎对此进行支持,也进一步带动了Parquet的发展。

目前Parquet正处于向2.0版本迈进的阶段,在新的版本中实现了新的Page存储格式,针对不同的类型优化编码算法,另外丰富了支持的原始类型,增加了Decimal、Timestamp等类型的支持,增加更加丰富的统计信息,例如Bloon Filter,能够尽可能得将谓词下推在元数据层完成。

总结

本文介绍了一种支持嵌套数据模型对的列式存储系统Parquet,作为大数据系统中OLAP查询的优化方案,它已经被多种查询引擎原生支持,并且部分高性能引擎将其作为默认的文件存储格式。通过数据编码和压缩,以及映射下推和谓词下推功能,Parquet的性能也较之其它文件格式有所提升,可以预见,随着数据模型的丰富和Ad hoc查询的需求,Parquet将会被更广泛的使用。

参考

Dremel: Interactive Analysis of Web-Scale Datasets
Dremel made simple with Parquet
Parquet: Columnar storage for the people
Efficient Data Storage for Analytics with Apache Parquet 2.0
深入分析Parquet列式存储格式
Apache Parquet Document

Categories: Uncategorized, 存储, 数据仓库 Tags: 列式存储, 大数据, 数据分析

ceph基于pglog的一致性协议

February 19th, 2016 吴 东 No comments

分布式存储系统通常采用多副本的方式来保证系统的可靠性,而多副本之间如何保证数据的一致性就是系统的核心。ceph号称统一存储,其核心RADOS既支持多副本,也支持纠删码。本文主要分析ceph的多副本一致性协议。

1.pglog及读写流程
ceph使用pglog来保证多副本之间的一致性,pglog的示意图如下:pglog主要是用来记录做了什么操作,比如修改,删除等,而每一条记录里包含了对象信息,还有版本。
ceph使用版本控制的方式来标记一个PG内的每一次更新,每个版本包括一个(epoch,version)来组成:其中epoch是osdmap的版本,每当有OSD状态变化如增加删除等时,epoch就递增;version是PG内每次更新操作的版本号,递增的,由PG内的Primary OSD进行分配的。
网易视频云:新一代列式存储格式Parquet_第12张图片
每个副本上都维护了pglog,pglog里最重要的两个指针就是last_complete和last_update,正常情况下,每个副本上这两个指针都指向同一个位置,当出现机器重启、网络中断等故障时,故障副本的这两个指针就会有所区别,以便于来记录副本间的差异。

为了便于说明ceph的一致性协议,先简要描述一下ceph的读写处理流程。
写处理流程:
1)client把写请求发到Primary OSD上,Primary OSD上将写请求序列化到一个事务中(在内存里),然后构造一条pglog记录,也序列化到这个事务中,然后将这个事务以directIO的方式异步写入journal,同时Primary OSD把写请求和pglog(pglog_entry是由primary生成)发送到Replicas上;
2)在Primary OSD将事务写到journal上后,会通过一系列的线程和回调处理,然后将这个事务里的数据写入filesystem(只是写到文件系统的缓存里,会有线程定期刷数据),这个事务里的pglog记录(也包括pginfo的last_complete和last_update)会写到leveldb,还有一些扩展属性相关的也在这个事务里,在遍历这个事务时也会写到leveldb;
3)在Replicas上,也是进行类似于Primary的动作,先写journal,写成功会给Primary发送一个committed ack,然后将这个事务里的数据写到filesystem,pglog与pginfo写到leveldb里,写完后会给Primary发送另外一个applied ack;
4)Primary在自己完成journal的写入时,以及在收到Replica的committed ack时都会检查是否多个副本都写入journal成功了,如果是则向client端发送ack通知写完成;Primary在自己完成事务写到文件系统和leveldb后,以及在收到replica的applied ack时都会检查是否多个副本都写文件系统成功,如果是则向client端发送ack通知数据可读;
对读流程来说,就比较简单,都是由Primary来处理,这里就不多说了。

2.故障恢复
ceph在进行故障恢复的时候会经过peering的过程,简要来说,peering就是对比各个副本上的pglog,然后根据副本上pglog的差异来构造missing列表,然后在恢复阶段就可以根据missing列表来进行恢复了。peering是按照pg为单位进行的,在进行peering的过程中,I/O请求是会挂起的,当进行完peering阶段进入recovery阶段时,I/O可以继续进行,不过当I/O请求命中了missing列表的时候,对应的这个待恢复的对象会优先进行恢复,当这个对象恢复完成后,再进行I/O的处理。
因为pglog记录数有限制,当对比各个副本上的pglog时,发现故障的副本已经落后太多了,这样就无法根据pglog来恢复了,所以这种情况下就只能全量恢复,称为backfill,坏盘坏机器或者集群扩容时也会触发backfill,这里不做介绍,后续单独一篇文章来进行分析。

基于pglog的一致性协议包含两种恢复过程,一个是Primary挂掉后又起来的恢复,一种是Replica挂掉后又起来的恢复。

2.1Primary故障恢复

简单起见,图中的数字就表示pglog里不同对象的版本。
1)正常情况下,都是由Primary处理client端的I/O请求,这时,Primary和Replicas上的last_update和last_complete都会指向pglog最新记录;
2)当Primary挂掉后,会选出一个Replica作为“临时主”,这个“临时主”负责处理新的读写请求,并且这个时候“临时主”和剩下的Replicas上的last_complete和last_update都更新到该副本上的[pglog的最新记录;
3)当原来的Primary又重启时,会从本地读出pginfo和pglog,当发现last_complete<last_update时,last_complete和last_update之间就可能存在丢失的对象,遍历last_complete到last_update之间的pglog记录,对于每一条记录,从本地读出该记录里对象的属性(包含本地持久化过的版本),对比pglog记录里的对象版本与读出来的版本,如果读出来的对象版本小于pglog记录里的版本,说明该对象不是最新的,需要进行恢复,因此将该对象加到missing列表里;
4)Primary发起peering过程,即“抢回原来的主”,选出权威日志,一般就是“临时主”的pglog,将该权威日志获取过来,与自己的pglog进行merge_log的步骤,构建出missing列表,并且更新自己的last_update为最新的pglog记录(与各个副本一致),这个时候last_complete与last_update之间的就会加到missing列表,并且peering完成后会持久化last_complete和last_update;
5)当有新的写入时,仍然是由Primary负责处理,会更新last_update,副本上会同时更新last_complete,与此同时,Primary会进行恢复,就是从其他副本上拉取对象数据到自己这里进行恢复,每当恢复完一个时,就会更新自己的last_complete(会持久化的),当所有对象都恢复完成后,last_complete就会追上last_update了。
6)当恢复过程中,Primary又挂了再起来恢复时,先读出本地pglog时就会根据自己的last_complete和last_update构建出missing列表,而在peering的时候对比权威日志和本地的pglog发现权威与自己的last_update都一样,peering的过程中就没有新的对象加到missing列表里,总的来说,missing列表就是由两个地方进行构建的:一个是osd启动的时候read_log里构建的,另一个是peering的时候对比权威日志构建的;

 

2.2Replica故障恢复

与Primary的恢复类似,peering都是由Primary发起的,Replica起来后也会根据pglog的last_complete和last_update构建出replica自己的missing,然后Primary进行peering的时候对比权威日志(即自身)与故障replica的日志,结合replica的missing,构建出peer_missing,然后就遍历peer_missing来恢复对象。然后新的写入时会在各个副本上更新last_complete和last_update,其中故障replica上只更新last_update,恢复过程中,每恢复完一个对象,故障replica会更新last_complete,这样所有对象都恢复完成后,replica的last_complete就会追上last_update。
如果恢复过程中,故障replica又挂掉,然后重启后进行恢复的时候,也是先读出本地log,对比last_complete与last_update之间的pglog记录里的对象版本与本地读出来的该对象版本,如果本地不是最新的,就会加到missing列表里,然后Primary发起peering的时候发现replica的last_update是最新的,peering过程就没有新的对象加到peer_missing列表里,peer_missing里就是replica自己的missing里的对象。

Categories: ceph, 分布式, 存储 Tags:

HBase – RegionServer宕机恢复原理和应对之道

January 28th, 2016 fan xinxin No comments

Region Server 宕机总述

HBase一个很大的特色是扩展性极其友好,可以通过简单地加机器实现集群规模的线性扩展,而且机器的配置并不需要太好,通过大量廉价机器代替价格昂贵的高性能机器。但也正因为廉价机器,由于网络硬盘等各方面的原因,机器宕机的概率就会相对比较大。RegionServer作为HBase集群中实际的执行节点,不可避免地也会出现宕机。

宕机并不十分可怕,因为不会丢数据。HBase集群中一台RegionServer宕机(实指RegionServer进程挂掉,下文同)并不会导致已经写入的数据丢失,和MySQL等数据库一样,HBase采用WAL机制保证这点:它会先写HLog,再写缓存,缓存写满后一起落盘。即使意外宕机导致很多缓存数据没有及时落盘,也可以通过HLog日志恢复出来。

可是没有数据丢失并不意味着宕机对业务方没有任何影响。众所周知,RegionServer宕机是由zookeeper首先感知到的,而zookeeper感知到RegionServer宕机事件是需要一定时间的,这段时间默认会有3min。也就是说,在RegionServer宕机之后的3min之内系统并不知晓它实际上已经宕机了,所有的读写路由还会正常落到它上面,可想而知,这些读写必然都会失败。(当然,并不是所有RegionServer宕机都需要3min中才能被Zookeeper感知。如果RegionServer在运行过程中产生自身难以解决的问题,它会自己abort自己,并且RegionServer会主动通知Zookeeper自己已经宕机的事实。这种场景下,影响用户读写的时间会极大的缩短到秒级)
Zookeeper一旦感知到RegionServer宕机之后,就会第一时间通知集群的管理者Master,Master首先会将这台RegionServer上所有Region移到其他RegionServer上,再将HLog分发给其他RegionServer进行回放,这个过程通常会很快。完成之后再修改路由,业务方的读写才会恢复正常。

既然,在分布式领域RegionServer宕机无法避免,那我们就有必要研究一旦宕机应该如何应对,即RegionServer宕机应对之道。另外,RegionServer宕机一定程度上会影响业务方的读写请求,所以我们也有必要研究如何定位宕机原因并设法避免。 Read more…

Categories: 存储 Tags:

TokuDB的索引结构–分形树的实现

December 15th, 2015 hu zheng 1 comment

本文从工程实现角度解析TokuDB的索引结构--分形树。 详细描述了ft-index的磁盘存储结构,ft-index如何实现Point-Query, Range-Query, Insert/Delete/Update操作, 并在描述过程中,试图从各个角度和InnoDB的B+树做详细对比。

分形树简介

分形树是一种写优化的磁盘索引数据结构。 在一般情况下, 分形树的写操作(Insert/Update/Delete)性能比较好,同时它还能保证读操作近似于B+树的读性能。据Percona公司测试结果显示, TokuDB分形树的写性能优于InnoDB的B+树), 读性能略低于B+树。 类似的索引结构还有LSM-Tree, 但是LSM-Tree的写性能远优于读性能。

工业界实现分形树最重要的产品就是Tokutek公司开发的ft-index(Fractal Tree Index)键值对存储引擎。这个项目自2007年开始研发,一直到2013年开源,代码目前托管在Github上。开源协议采用 GNU General Public License授权。 Tokutek公司为了充分发挥ft-index存储引擎的威力,基于K-V存储引擎之上,实现了MySQL存储引擎插件提供所有API接口,用来作为MySQL的存储引擎, 这个项目称之为TokuDB, 同时还实现了MongoDB存储引擎的API接口,这个项目称之为TokuMX。在2015年4月14日, Percona公司宣布收购Tokutek公司, ft-index/TokuDB/TokuMX这一系列产品被纳入Percona公司的麾下。自此, Percona公司宣称自己成为第一家同时提供MySQL和MongoDB软件及解决方案的技术厂商。

本文主要讨论的是TokuDB的ft-index。 ft-index相比B+树的几个重要特点有:

  • 从理论复杂度和测试性能两个角度上看, ft-index的Insert/Delete/Update操作性能优于B+树。 但是读操作性能低于B+树。
  • ft-index采用更大的索引页和数据页(ft-index默认为4M, InnoDB默认为16K), 这使得ft-index的数据页和索引页的压缩比更高。也就是说,在打开索引页和数据页压缩的情况下,插入等量的数据, ft-index占用的存储空间更少。
  • ft-index支持在线修改DDL (Hot Schema Change)。 简单来讲,就是在做DDL操作的同时(例如添加索引),用户依然可以执行写入操作, 这个特点是ft-index树形结构天然支持的。 由于篇幅限制,本文并不对Hot Schema Change的实现做具体描述。

此外, ft-index还支持事务(ACID)以及事务的MVCC(Multiple Version Cocurrency Control 多版本并发控制), 支持崩溃恢复。

正因为上述特点, Percona公司宣称TokuDB一方面带给客户极大的性能提升, 另一方面还降低了客户的存储使用成本。

ft-index的磁盘存储结构

ft-index的索引结构图如下(在这里为了方便描述和理解,我对ft-index的二进制存储做了一定程度简化和抽象,【右击】->【在新标签打开】 可以查看大图):

在下图中, 灰色区域表示ft-index分形树的一个页,绿色区域表示一个键值,两格绿色区域之间表示一个儿子指针。 BlockNum表示儿子指针指向的页的偏移量。Fanout表示分形树的扇出,也就是儿子指针的个数。 NodeSize表示一个页占用的字节数。NonLeafNode表示当前页是一个非叶子节点,LeafNode表示当前页是一个叶子节点,叶子节点是最底层的存放Key-value键值对的节点, 非叶子节点不存放value。 Heigth表示树的高度, 根节点的高度为3, 根节点下一层节点的高度为2, 最底层叶子节点的高度为1。Depth表示树的深度,根节点的深度为0, 根节点的下一层节点深度为1。

分形树的树形结构非常类似于B+树, 它的树形结构由若干个节点组成(我们称之为Node或者Block,在InnoDB中,我们称之为Page或者页)。 每个节点由一组有序的键值组成。假设一个节点的键值序列为[3, 8], 那么这个键值将(-00, +00)整个区间划分为(-00, 3), [3, 8), [8, +00) 这样3个区间, 每一个区间就对应着一个儿子指针(Child指针)。 在B+树中, Child指针一般指向一个页, 而在分形树中,每一个Child指针除了需要指向一个Node的地址(BlockNum)之外,还会带有一个Message Buffer (msg_buffer), 这个Message Buffer 是一个先进先出(FIFO)的队列,用来存放Insert/Delete/Update/HotSchemaChange这样的更新操作。

按照ft-index源代码的实现, 对ft-index中分形树更为严谨的说法是这样的:

  • 节点(block或者node, 在InnoDB中我们称之为Page或者页)是由一组有序的键值组成, 第一个键值设置为null键值, 表示负无穷大。
  • 节点分为两种类型,一种是叶子节点, 一种是非叶子节点。 叶子节点的儿子指针指向的是BasementNode, 非叶子节点指向的是正常的Node 。 这里的BasementNode节点存放的是多个K-V键值对, 也就是说最后所有的查找操作都需要定位到BasementNode才能成功获取到数据(Value)。这一点也和B+树的LeafPage类似, 数据(Value)都是存放在叶子节点, 非叶子节点用来存放键值(Key)做索引。 当叶子节点加载到内存后,为了快速查找到BasementNode中的数据(Value), ft-index会把整个BasementNode中的key-value都转换为一棵弱平衡二叉树, 这棵平衡二叉树有一个很逗逼的名字,叫做替罪羊树, 这里不再展开。
  • 每个节点的键值区间对应着一个儿子指针(Child Pointer)。 非叶子节点的儿子指针携带着一个MessageBuffer, MessageBuffer是一个FIFO队列。用来存放Insert/Delete/Update/HotSchemaChange这样的更新操作。儿子指针以及MessageBuffer都会序列化存放在Node的磁盘文件中。
  • 每个非叶子节点(Non Leaf Node)儿子指针的个数必须在[fantout/4, fantout]这个区间之内。 这里fantout是分形树(B+树也有这个概念)的一个参数,这个参数主要用来维持树的高度。当一个非叶子节点的儿子指针个数小于fantout/4 , 那么我们认为这个节点的太空虚了,需要和其他节点合并为一个节点(Node Merge), 这样能减少整个树的高度。当一个非叶子节点的儿子指针个数超过fantout, 那么我们认为这个节点太饱满了, 需要将一个节点一拆为二(Node Split)。 通过这种约束控制,理论上就能将磁盘数据维持在一个正常的相对平衡的树形结构,这样可以控制插入和查询复杂度上限。

注意: 在ft-index实现中,控制树平衡的条件更加复杂, 例如除了考虑fantout之外,还要保证节点总字节数在[NodeSize/4, NodeSize]这个区间, NodeSize一般为4M ,当不在这个区间时, 需要做对应的合并(Merge)或者分裂(Split)操作。

分形树的Insert/Delete/Update实现

在前文中,我们说到分形树是一种写优化的数据结构, 它的写操作性能要优于B+树的写操作性能。 那么它究竟如何做到更优的写操作性能呢?

首先, 这里说的写操作性能,指的是随机写操作。 举个简单例子,假设我们在MySQL的InnoDB表中不断执行这个SQL语句: insert into sbtest set x = uuid(), 其中sbtest表中有一个唯一索引字段为x。 由于uuid()的随机性,将导致插入到sbtest表中的数据散落在各个不同的叶子节点(Leaf Node)中。 在B+树中, 大量的这种随机写操作将导致LRU-Cache中大量的热点数据页落在B+树的上层(如下图所示)。这样底层的叶子节点命中Cache的概率降低,从而造成大量的磁盘IO操作, 也就导致B+树的随机写性能瓶颈。但B+树的顺序写操作很快,因为顺序写操作充分利用了局部热点数据, 磁盘IO次数大大降低。

网易视频云:新一代列式存储格式Parquet_第13张图片

下面来说说分形树插入操作的流程。 为了方便后面描述,约定如下:

a. 我们以Insert操作为例, 假定插入的数据为(Key, Value);
b. 下文说的 加载节点(Load Page),都是先判断该节点是否命中LRU-Cache。仅当缓存不命中时, ft-index才会通过seed定位到偏移量读取数据页到内存;
c. 为体现核心流程, 我们暂时不考虑崩溃日志和事务处理。

详细流程如下:

  1. 加载Root节点;
  2. 判断Root节点是否需要分裂(或合并),如果满足分裂(或者合并)条件,则分裂(或者合并)Root节点。 具体分裂Root节点的流程,感兴趣的同学可以开开脑洞。
  3. 当Root节点height>0, 也就是Root是非叶子节点时, 通过二分搜索找到Key所在的键值区间Range,将(Key, Value)包装成一条消息(Insert, Key, Value) , 放入到键值区间Range对应的Child指针的Message Buffer中。
  4. 当Root节点height=0时,即Root是叶子节点时, 将消息(Insert, Key, Value) 应用(Apply)到BasementNode上, 也就是插入(Key, Value)到BasementNode中。

这里有一个非常诡异的地方,在大量的插入(包括随机和顺序插入)情况下, Root节点会经常性的被撑饱满,这将会导致Root节点做大量的分裂操作。然后,Root节点做了大量的分裂操作之后,产生大量的height=1的节点, 然后height=1的节点被撑爆满之后,又会产生大量height=2的节点, 最终树的高度越来越高。 这个诡异的之处就隐藏了分形树写操作性能比B+树高的秘诀: 每一次插入操作都落在Root节点就马上返回了, 每次写操作并不需要搜索树形结构最底层的BasementNode, 这样会导致大量的热点数据集中落在在Root节点的上层(此时的热点数据分布图类似于上图), 从而充分利用热点数据的局部性,大大减少了磁盘IO操作。

Update/Delete操作的情况和Insert操作的情况类似, 但是需要特别注意的地方在于,由于分形树随机读性能并不如InnoDB的B+树(后文会详细描述)。因此,Update/Delete操作需要细分为两种情况考虑,这两种情况测试性能可能差距巨大:

  • 覆盖式的Update/Delete (overwrite)。 也就是当key存在时, 执行Update/Delete; 当key不存在时,不做任何操作,也不需要报错。
  • 严格匹配的Update/Delete。 当key存在时, 执行update/delete ; 当key不存在时, 需要报错给上层应用方。 在这种情况下,我们需要先查询key是否存在于ft-index的basementnode中,于是Point-Query默默的拖了Update/Delete操作的性能后退。

此外,ft-index为了提升顺序写的性能,对顺序插入操作做了一些优化,例如顺序写加速, 这里不再展开。

分形树的Point-Query实现

在ft-index中, 类似select from table where id = ? (其中id是索引)的查询操作称之为Point-Query; 类似select from table where id >= ? and id <= ? (其中id是索引)的查询操作称之为Range-Query。 上文已经提到, Point-Query读操作性能并不如InnoDB的B+树, 这里详细描述Point-Query的相关流程。 (这里假设要查询的键值为Key)

  1. 加载Root节点,通过二分搜索确定Key落在Root节点的键值区间Range, 找到对应的Range的Child指针。
  2. 加载Child指针对应的的节点。 若该节点为非叶子节点,则继续沿着分形树一直往下查找,一直到叶子节点停止。 若当前节点为叶子节点,则停止查找。

查找到叶子节点后,我们并不能直接返回叶子节点中的BasementNode的Value给用户。 因为分形树的插入操作是通过消息(Message)的方式插入的, 此时需要把从Root节点到叶子节点这条路径上的所有消息依次apply到叶子节点的BasementNode。 待apply所有的消息完成之后,查找BasementNode中的key对应的value,就是用户需要查找的值。

分形树的查找流程基本和 InnoDB的B+树的查找流程类似, 区别在于分形树需要将从Root节点到叶子节点这条路径上的messge buffer都往下推(下推的具体流程请参考代码,这里不再展开),并将消息apply到BasementNode节点上。注意查找流程需要下推消息, 这可能会造成路径上的部分节点被撑饱满,但是ft-index在查询过程中并不会对叶子节点做分裂和合并操作, 因为ft-index的设计原则是: Insert/Update/Delete操作负责节点的Split和Merge, Select操作负责消息的延迟下推(Lazy Push)。 这样,分形树就将Insert/Delete/Update这类更新操作通过未来的Select操作应用到具体的数据节点,从而完成更新。

分形树的Range-Query实现

下面来介绍Range-Query的查询实现。简单来讲, 分形树的Range-Query基本等价于进行N次Point-Query操作,操作的代价也基本等价于N次Point-Query操作的代价。 由于分形树在非叶子节点的msg_buffer中存放着BasementNode的更新操作,因此我们在查找每一个Key的Value时,都需要从根节点查找到叶子节点, 然后将这条路径上的消息apply到basenmentNode的Value上。 这个流程可以用下图来表示。

网易视频云:新一代列式存储格式Parquet_第14张图片

但是在B+树中, 由于底层的各个叶子节点都通过指针组织成一个双向链表, 结构如下图所示。 因此,我们只需要从跟节点到叶子节点定位到第一个满足条件的Key, 然后不断在叶子节点迭代next指针,即可获取到Range-Query的所有Key-Value键值。因此,对于B+树的Range-Query操作来说,除了第一次需要从root节点遍历到叶子节点做随机写操作,后继数据读取基本可以看做是顺序IO。

网易视频云:新一代列式存储格式Parquet_第15张图片

通过比较分形树和B+树的Range-Query实现可以发现, 分形树的Range-Query查询代价明显比B+树代价高,因为分型树需要遍历Root节点的覆盖Range的整颗子树,而B+树只需要一次Seed到Range的起始Key,后续迭代基本等价于顺序IO。

总结

本文以分形树的树形结构为切入点,详细介绍分形树的增删改查操作。总体来说,分形树是一种写优化的数据结构,它的核心思想是利用节点的MessageBuffer缓存更新操作,充分利用数据局部性原理, 将随机写转换为顺序写,这样极大的提高了随机写的效率。Tokutek研发团队的iiBench测试结果显示: TokuDB的insert操作(随机写)的性能比InnoDB快很多,而Select操作(随机读)的性能低于InnoDB的性能,但是差距较小,同时由于TokuDB采用有4M的大页存储,使得压缩比较高。这也是Percona公司宣称TokuDB更高性能,更低成本的原因。

另外,在线更新表结构(Hot Schema Change)实现也是基于MessageBuffer来实现的, 但和Insert/Delete/Update操作不同的是, 前者的消息下推方式是广播式下推(父节点的一条消息,应用到所有的儿子节点), 后者的消息下推方式单播式下推(父节点的一条消息,应用到对应键值区间的儿子节点), 由于实现类似于Insert操作,所以不再展开描述。

最后,欢迎对ft-index感兴趣的同学一起交流讨论。

参考资料

  1. https://github.com/Tokutek/ft-index
  2. https://en.wikipedia.org/wiki/Fractal_tree_index
  3. https://www.percona.com/about-percona/newsroom/press-releases/percona-acquires-tokutek
  4. 《MySQL技术内部:InnoDB存储引擎》 by 姜承尧
  5. https://en.wikipedia.org/wiki/Scapegoat_tree
  6. https://en.wikipedia.org/wiki/Order-maintenance_problem
  7. Tokutek团队讲解Fractal Tree的相关PPT
Categories: InnoDB, MySQL, TokuDB, 存储 Tags: TokuDB, 分形树, 存储引擎, 数据库, 数据结构, 算法, 索引

优雅的Bitcask/BeansDB

September 22nd, 2015 dingkai 2 comments

概述

Bitcask是由Riak提出的海量小文件存储场景下的解决方案,着眼解决以下问题:

  • 读写低延迟
  • 随机写请求的磁盘高吞吐量
  • 故障时的快速恢复且不丢数据
  • 使用简单

Bitcask论文中主要定义了文件存储格式以及相关API,感兴趣可见参考资料。
BeansDB是Douban参考Bitcask论文开发的适用于Douban使用场景的小文件存储系统。作者Davies通过匠心独具的设计和深厚的技术水平将这一复杂的问题解决的比较优雅,值得仔细品味一番。
近期阅读了Bitcask论文和部分BeansDB源码,在此基础上分析BeansDB的设计和实现上的一些关键技术,包括数据文件组织、内存hash tree索引等,拿出来与大家交流探讨。
Read more…

Categories: 存储 Tags:

HBase原理和设计

September 16th, 2015 jiang hongxiang 2 comments

简介

HBase —— Hadoop Database的简称,Google BigTable的另一种开源实现方式,从问世之初,就为了解决用大量廉价的机器高速存取海量数据、实现数据分布式存储提供可靠的方案。从功能上来讲,HBase不折不扣是一个数据库,与我们熟悉的Oracle、MySQL、MSSQL等一样,对外提供数据的存储和读取服务。而从应用的角度来说,HBase与一般的数据库又有所区别,HBase本身的存取接口相当简单,不支持复杂的数据存取,更不支持SQL等结构化的查询语言;HBase也没有除了rowkey以外的索引,所有的数据分布和查询都依赖rowkey。所以,HBase在表的设计上会有很严格的要求。架构上,HBase是分布式数据库的典范,这点比较像MongoDB的sharding模式,能根据键值的大小,把数据分布到不同的存储节点上,MongoDB根据configserver来定位数据落在哪个分区上,HBase通过访问Zookeeper来获取-ROOT-表所在地址,通过-ROOT-表得到相应.META.表信息,从而获取数据存储的region位置。

Read more…

Categories: 分布式, 存储 Tags:

百度网盘背后的存储系统atlas

July 25th, 2015 yulihua No comments

百度网盘免费提供2TB存储, 它的存储量一定是惊人的, 支持它的存储系统atlas也是相当不错的。 atlas是一个KV存储, 支持GET/PUT/DELETE三个接口, 看起来接口简单, 但要做好这么一个大规模系统非常不易, 我们来看看atlas到底长啥样。

网易视频云:新一代列式存储格式Parquet_第16张图片

Read more…

Categories: 存储, 架构 Tags:

Tachyon超光速文件系统

April 17th, 2015 yulihua No comments

Tachyon文件系统诞生于著名的berkeley AMPLab(amplab.cs.berkeley.edu), 单词Tachyon意为超光速粒子, 彰显文件系统的卓越性能, 该项目近期获得硅谷风投A16Z 750万美元A轮投资。 系出名门, 名字霸气, 受到投资者青睐, Tachyon到底是怎么样一个文件系统?

Read more…

Categories: 存储 Tags:

分布式一致性

August 11th, 2014 yulihua 2 comments

分布式系统的一致性问题总是伴随数据复制而生, 数据复制技术在提高分布式系统的可用性、可靠性和性能的同时,却带来了不一致问题。 理想情况下, 多个副本应该是应用透明的, 从外界看来多副本如同单副本, 而事实上维护一致性非常困难。试想一下, 写入新数据时, 某副本所在的服务器宕机,或者突然发生了网络错误, 此时该如何处理? 是继续重试等待故障消失呢,还是放弃写入故障副本? 若继续重试写入, 则导致系统不可用,影响业务; 而放弃写入则副本数据不同步,产生了差异, 会不会对应用会造成影响? 这似乎是个两难问题, 看似无法抉择。 幸好一致性问题并不是非黑即白的二选一问题, 业界早就定义了多套适合于各种应用场景的一致性模型[1,2]:

假设有一个存储系统, 它底层是一个复杂的高可用、高可靠的分布式存储系统。一致性模型定义如下:

1. 强一致。 按照某一顺序串行执行存储对象读写操作, 更新存储对象之后, 后续访问总是读到最新值。 假如进程A先更新了存储对象,存储系统保证后续A,B,C的读取操作都将返回最新值。
2. 弱一致性。 更新存储对象之后, 后续访问可能读不到最新值。假如进程A先更新了存储对象,存储系统不能保证后续A,B,C的读取操作能读取到最新值。 从更新成功这一刻开始算起, 到所有访问者都能读到修改后对象为止, 这段时间称为”不一致性窗口”, 窗口内访问存储时无法保证一致性。
3. 最终一致性。 最终一致性是弱一致性的特例, 存储系统保证所有访问将最终读到对象最新值。 譬如, 进程A写一个存储对象, 如果对象上后续没有更新操作, 那么最终A,B,C的读取操作都会读取到A写入的值。 “不一致性窗口”的大小依赖于交互延迟,系统的负载,以及副本个数等。

最终一致性是最常见的一种弱一致性, 从单个客户端(用户)角度出发, 最终一致性可再细分为以下几种:
1 Causal consistency. A更新了对象之后通知B,那么B能读取A写入的值, 而与A没有因果关系的C则可以读不到最新值。 以微博为例, 用户甲发布微博之后, 系统通知了它的粉丝, 那么粉丝必须能看到甲发表的微博。
2 Read your wirte consistency. 客户端总是能读取到自己的写入最新值。 以博客为例, 用户必须能看到自己刚发表的博客。
3 Session consistency. 客户端与服务器维护一个会话, 会话有效期间, 满足read your write consistency。
4 monotonic read consistency. 如果客户端已经读取了对象, 那么后续读取操作不能读到该对象的旧值。 以邮件收件箱为例, 用户跑到上海的时候必须也能读到他在杭州已经读过的邮件。
5 monotonic write consistency. 单客户端发出的写操作串行执行。

设计系统时, 该如何选择一致性模型? 一般来说, 首选强一致性, 原因是理解起来简单, 使用起来也简单。 但CAP理论[3]告诉我们一致性、可用性和网络分区三者不可兼得, 因此弱一致性也有其适用场景: (1)网络不稳定的情况,譬如跨数据中心服务, 广域网服务(譬如DNS,CDN等); (2) 可用性要求非常高,并发更新非常少, 且冲突容易解决的场景, 譬如amazon购物车; (3) 一致性要求本来就不高的场景, 譬如缓存。 如果确实有必要选择弱一致性,那么尽量选择最终一致性, 并从用户角度角度出发选择一种合理的最终一致性。

强一致实现方法

强一致性模型有几种常见实现方法, 主从同步复制, 以及quorum复制等。

Quorum复制[10]

假设N是副本数, 写Quorum W是需要写操作成功之前必须更新的副本数目 , 写Quorum R是读操作返回之前必须读取的副本数目。 当W + R > N时, 读写操作必然有交集, 读操作必能读到最新数据, 所以Quorum复制能做到强一致。

然而, 何为“最新”数据, 往往挺难界定, 因此实际应用中, 一般给数据带一个版本号(或者时间), 用于标识数据的新旧。 仅凭数据版本号仍不足以保证正确性, quorum复制还得处理失败写操作带来的版本覆盖问题。 按照定义, 更新副本数目小于W的写操作算失败, 由于失败操作的版本可能大于最后一次有效写入操作的版本, 失败的残留可能会遮盖最新有效版本, 导致读Quorum达不到R, 也就是最新有效版本丢失! 为了解决版本覆盖问题, 支持quorum的存储系统一般需要保存历史版本, 读数据时一次性读多个版本, 剔除那些失败的写入数据,得到正确的数据。

Quorum复制的好处灵活选择W、R, 可用性比较好, 响应时间比较低(不必等待慢节点返回结果), 缺点是维护数据历史版本代价较高,
吞吐率也不高, 常见的应用场景是元数据存储。

主从同步复制

主副本或者全局协调者负责所有读写操作, 同时负责串行化写操作, 将写操作实时同步传播到其他副本, 副本全部写成功之后, 写操作才返回给用户。 采用主从同步复制的典型例子是 Ceph[4], InnoSQL[11] FSR。 其优点是实现简单, 写吞吐率较高, 也能容忍更多节点故障。 缺点有三个,一是可用性较差, 检测到主故障并重新选主需要一定时间, 一般是10s以上; 二是响应时间较Quorum方法长, 必须等待所有副本都响应了, 请求才算成功; 三是一般只能从主节点读取数据,读吞吐率较差。

主从强一致复制的典型方法如下:

  • 主从链式复制[5]。 副本按照一定顺序关系组织成复制链, 写操作必须发送到主副本, 串行化之后依次传播到其他副本, 读操发往最后一个副本。 原始的链式复制虽然能保证强一致,但是读取的吞吐率较差, CRAQ[6]针对这点进行优化, 通过维护一个脏标记CRAQ允许读取任意副本, 从而提高了读吞吐率。
  • PacificA复制[8]。 PacificA是微软提出的一种保证强一致的主从复制方法, 适用于基于日志的存储系统, 分布式日志系统kafka就是采用这种做法。 PacificA复制分为三个阶段。 第一阶段prepare, 主节点确定更新顺序, 发送数据到所有从节点, 从节点写入日志并返回响应给主节点。 主节点收到所有从节点的响应之后进入第二阶段“主commit”, 主节点在本地commit该数据, 紧接着返回客户端, 此后客户端能读到该数据。 第三阶段从commit, 主节点本地commit后,异步(或者通过后续prepare消息捎带)commit所有从节点。 该协议保证更新串行性, 确保数据一但commit就能被读到。

复制状态机

状态机复制[9]。 利用分布式一致性(consensus)协议,譬如Paxos/Raft[12], 构建一个分布式复制的强一致日志, 日志中记录副本上的读写操作, 每个副本从相同的初始状态出发, 执行相同的日志序列, 就得到一致的结果。 当然每个副本各执行日志, 所以日志进度不完全同步。 一般是选择一个副本作为master, 客户端的读写操作都发往master, master执行完写操作,并写入日志之后就返回给客户端。

弱一致实现方法

弱一致性实现方法可分为两类

  • 单副本更新, 异步扩散到其他副本。 好处是没有更新冲突, 缺点是可用性和性能不足。
  • 多副本支持更新, 好处是可用性和性能较好, 但要解决更新冲突问题。 更新冲突一般通过last write wins是解决, 由于分布式系统不存在绝对时间, last write win的难点是无法界定”最新的写操作“。 弱一致性系统无法为数据维护单调递增的数据版本号, 因此工程实现上一般使用vector clock[7]检测数据冲突。 如果vector clock还无法解决冲突问题,那么必须引入应用语义, 以购物车为例, 发生冲突时, 只要将两个版本的购物车的内容合并起来, 虽然会出现一些已经删除的商品, 但对用户体验影响不太大。

文献

[1] Eventual Consistent – Revisited, Werner Vogels. [http://www.allthingsdistributed.com/2008/12/eventually_consistent.html]
[2] 分布式系统工程, 杨传辉。
[3] cap twelve years later: how the rules have changed: [http://www.infoq.com/articles/cap-twelve-years-later-how-the-rules-have-changed]
[4] Sage A. Weil. Ceph: Reliable, Scalable, and High-Performance Distributed Storage. Ph.D. thesis, University of California, Santa Cruz, December, 2007.
[5] 分布式复制技术:链式复制。 [http://www.bitstech.net/2013/10/11/chain-replication/]
[6] Jeff Terrace and Michael J. Freedman. Object Storage on CRAQ, High-throughput chain replication for read-mostly workloads.
[7] Lamport, L.. “Time, clocks, and the ordering of events in a distributed system”
[8] Wei Lin, Mao Yang, et. PacificA: Replication in Log-Based Distributed Storage Systems.
[9] Tushar Chandra, Robert Griesemer,Joshua Redstone. Paxos Made Live — An Engineering Perspective.
[10] 莫华枫. 云存储的黑暗面:元数据保障(下). 程序员, 2015年4月刊. [http://www.csdn.net/article/2014-06-17/2820254]
[11] InnoSQL, [http://mysql.netease.com/]
[12] Diego Ongaro and John Ousterhout. In Search Of An Understandable Consensus Algorithm

Categories: 存储, 架构 Tags:

linux软raid的bitmap分析

January 27th, 2014 吴 东 No comments

在使用raid1,raid5等磁盘阵列的时候,对于数据的可靠性有很高的要求,raid5在写的时候需要计算校验并写入,raid1则写源和镜像来保证数据的一致性,在写的过程中,有可能存在不稳定的因素,比如磁盘损坏,系统故障等,这样导致写入失败,在系统恢复后,raid也需要进行恢复,传统的恢复方式就是全盘扫描计算校验或者全量同步,如果磁盘比较大,那同步恢复的过程会很长,有可能再发生其他故障,这样就会对业务有比较大的影响。以raid1来说,在发生故障时,其实两块盘的数据很多都是已经一致的了,可能只有少部分不一致,所以就没必要进行全盘扫描,但是系统并不知道两块盘哪些数据是一致的,这就需要在某个地方记录哪些是已同步的,为此,就诞生了bitmap,简单来说,bitmap就是记录raid中哪些数据是一致的,哪些是不一致的,这样在raid进行恢复的时候就不用全量同步,而是增量同步了,从而减少了恢复的时间。

1. bitmap的使用

    bitmap的使用比较简单,mdadm的帮助文档里有很详细的说明。bitmap分两种,一种是internal,一种是external。

    internal bitmap是存放在raid设备的成员盘的superblock附近(可以在之前也可以在之后),而external是单独指定一个文件用来存放bitmap。

    这里简单的介绍一下bitmap的使用。

# mdadm –create /dev/md/test_md1 –run –force –metadata=0.9 –assume-clean –bitmap=/mnt/test/bitmap_md1 –level=1 –raid-devices=2 /dev/sdb /dev/sdc

mdadm: /dev/sdb appears to be part of a raid array:

level=raid1 devices=2 ctime=Tue Dec 17 21:00:58 2013
mdadm: array /dev/md/test_md1 started.

查看md的状态

#cat /proc/mdstat

Personalities : [raid1]

md126 : active  raid1 sdc[1] sdb[0]
      2097216 blocks [2/2] [UU]
      bitmap: 1/257 pages [4KB], 4KB chunk, file: /mnt/test/bitmap_md1
可以看到最后一行就是md126的bitmap信息,这里的默认bitmap的chunksize是4KB,可以通过–bitmap-chunk来指定bitmap chunk大小,bitmap的chunk表示在bitmap中
1bit对应md设备的一个chunk(大小为bitmap的chunksize)。
这里对cat /proc/mdstat查看到的bitmap的信息进行说明。
  • 其中的4KB chunk表示bitmap的chunk大小是4KB;
  • 1/257 pages指的是bitmap所对应的内存位图(作为磁盘上的bitmap的缓存,提高对位图的操作效率),257是内存bitmap占的总page数,1表示已经分配的page数,内存bitmap是动态分配的,使用完后就可以回收。内存位图使用16bit来表征一个chunk,其中的14bit用来统计该chunk上正在进行的写io数(后面会有详细的介绍)。
  • [4KB]表示已经分配的内存位图page总大小。

总的chunk数=md设备大小/bitmap的chunk大小

内存bitmap一个page可以表示的chunk数=PAGE_SIZE*8/16

内存bitmap占的总page数=总的chunk数/内存bitmap一个page可以表示的chunk数

上面给出的例子中总的chunk数为2097216KB/4KB=524304

取页大小为4096,一个page可以表示的chunk数为4096*8/16=2048
总共需要524304/2048=256个page,看实际上是257,这是因为有可能不能整除的情况,最后一个page可能不是全部都使用。
2. bitmap的内存结构
    bitmap的结构体有比较多的字段,这里关注几个重要的字段,加以说明,便于后面的分析。
struct bitmap {
struct bitmap_page *bp;  /* 指向内存位图页的结构*/
……
unsigned long chunks; /* 阵列总的chunk数 */
        ……
struct file *file; /* bitmap文件 */
……
struct page **filemap; /* 位图文件的缓存页 */
unsigned long *filemap_attr; /* 位图文件缓存页的属性 */
……
};
其中struct bitmap_page结构如下:
struct bitmap_page {
char *map;   /* 指向实际分配的内存页*/
/*
 * in emergencies (when map cannot be alloced), hijack the map
 * pointer and use it as two counters itself
 */
unsigned int hijacked:1;
/*
 * count of dirty bits on the page
 */
unsigned int  count:31;  /* 该页上有多少脏的chunk,每16bit表示一个chunk*/
};
实际动态分配的每个内存页,每16bit对应bitmap file的一个bit,即表示md的一个chunk
这16个bit的作用如下:
15        14          13                                   0
+————-+———-+————————————-+
| needed  |resync |          counter               |
+————-+———-+————————————+

最高一位表示是否需要同步,后面一位表示是否正在同步,低14bit是counter,用来统计该chunk有多少正在进行的写io。

这14bit表示的counter记为bmc,方便后面描述。bmc的值0,1,2比较特殊,为0时表示对应chunk还未进行写操作,内存位图还未置位,bmc为1时表示内存位图已经置位,bmc为2表示所有写操作刚结束,真正的写io数是从2开始累加的。
    bitmap结构中的filemap表示bitmap file的对应缓存,bitmap file有多大,对应的这个filemap缓存就有多大,在初始化的时候就分配好的。
filemap_attr 表示位图文件缓存页的属性,使用4bit来表示一个缓存页的属性,
  • 第0bit是BITMAP_PAGE_DIRTY,该bit为1表示内存bitmap中为脏,但是bitmap file中的对应位不为脏,因此对于有这种标记的page需要同步刷到磁盘(实际上是异步调用write_page,但是等到写完成)
  • 第1bit是BITMAP_PAGE_PENDING,置位后表示内存bitmap中的脏位已经清0,但是此时外存bitmap file中的对应脏位没有清0,需要进行清0的操作,这是一个过渡状态,过渡到BITMAP_PAGE_NEEDWRITE。
  • 第2bit是BITMAP_PAGE_NEEDWRITE,置位后表示需要进行同步,把内存位图缓存中的数据刷到外部位图文件中,所对于这种标记的page只需要异步写,因为即使写失败,最多带来额外的同步,不会带来数据的危害。
  • 第3bit在代码中没有看到使用,猜测是预留的。
bp中的page和filemap对应的page以及bitmap file的关系如下:
网易视频云:新一代列式存储格式Parquet_第17张图片
    1个位图文件缓存页可以表示4096个chunk,而内存位图页则需要16个page。bp数组所对应的内存位图页的作用其实是控制bitmap的置位与复位,并且也控制一个chunk上的io不能超过最大值(14bit表示的最大整数),达到最大值的时候会进行io schedule。
3.bitmap的可靠刷新机制
     在进行写操作的时候,是先把bitmap的对应位置为脏,然后再进行写操作,写完成后再复位。那么如何保证每次写操作时,内存bitmap中的数据都被可靠刷到对应的磁盘bitmap file中?
     一般的逻辑是在写io到md设备前,先在bitmap中标记为dirty(成功刷到磁盘中),然后执行写io,io完成后需要清理dirty的标记这就需要在正常数据写操作之前,完成bitmap的刷新操作。那么bitmap如何实现呢?
    以raid1为例,md在make_request中,调用了bitmap_startwrite函数,但是这个函数并没有直接调用write_page刷新数据到磁盘,而是调用了bitmap_file_set_bit将bitmap位标记为BITMAP_PAGE_DIRTY。之所以不在bitmap_startwrite函数中调用write_page刷新,是因为块设备的io操作是通过queue队列进行的,不能保证每次io操作都能及时完成,而且io调度的顺序也可能调换,因此如果直接调用write_page进行写操作的话,就有可能存在bitmap的刷新和正常的数据写操作的顺序发生颠倒。

    真正处理BITMAP_PAGE_DIRTY是在bitmap_unplug中,对于raid1来说,bitmap_unplug是在raid1.c中的flush_pending_writes函数中调用的,而flush_pending_writes是由raid1的守护进程raid1d调用的。flush_pending_writes会调用bitmap_unplug刷新bitmap到磁盘,然后遍历conf->pending_bio_list,取出bio来处理正常挂起的写io。(在raid1的make_request中会把mbio加到conf->pending_bio_list中)

    从上面的分析可知,raid1在收到写io请求时,先把内存位图置为dirty,并把该写io加到pending_list,然后raid1d守护进程会把标记为dirty的内存位图页刷到外存的位图文件中,然后从pendling_list中取出之前挂起的写io进行处理。
    在刷脏页时,需要把位图文件缓存页的数据写到位图文件中,因为md是内核态的程序,在实现时并没有直接调用通常的写函数往外存的文件写数据,而是通过bmap机制,根据inode,把文件数据块和物理磁盘块映射起来,这样就可以透过文件系统,调用submit_bh进行bitmap的刷新。
    上面描述的可靠刷新机制也就是bitmap设置的过程,下面分析bitmap清理的逻辑。
4.bitmap位的清除
    前面说到从pending_list中取出写io进行处理,当io完成后需要清除dirty的标记,会把内存位图页的属性设置为BITMAP_PAGE_PENDING,表示正要去清除,BITMAP_PAGE_PENDING属性页并不会立即刷到外存的位图文件中,而是异步清0的过程。真正的清理流程在bitmap_daemon_work中实现。这是有raid的守护进程定期执行时调用的(比如raid1d),守护进程会定期调用md_check_recovery,然后md_check_recovery会调用bitmap_daemon_work根据各种状态进行清0的操作。
    bitmap_daemon_work的实现比较复杂,里面各种状态判断与转换,很容易把人绕晕,bitmap的清0(内存位图页的bit清0及刷到外存的位图文件)是需要经过3次调用bitmap_daemon_work。下面以1个bit的清理来阐述,在io完成后,在bitmap_endwrite中会把这边bit的计数器bmc会置为2(前提是这个bit对应的chunk上的写io都完成),并标记位为BITMAP_PAGE_PENDING。
    1)第一次进入bitmap_daemon_work,bmc=2,页属性为BITMAP_PAGE_PENDING。
    这里判断位是否为BITMAP_PAGE_PENDING,这时候bit所对应的确实是BITMAP_PAGE_PENDING,所以跳过这个判断里的处理逻辑
if (!test_page_attr(bitmap, page, BITMAP_PAGE_PENDING)) {
int need_write = test_page_attr(bitmap, page,
BITMAP_PAGE_NEEDWRITE);
if (need_write)
clear_page_attr(bitmap, page, BITMAP_PAGE_NEEDWRITE);spin_unlock_irqrestore(&bitmap->lock, flags);
if (need_write)
write_page(bitmap, page, 0);
spin_lock_irqsave(&bitmap->lock, flags);
j |= (PAGE_BITS – 1);
continue;
}

接着执行后续的,

这里会判断page是否为BITMAP_PAGE_NEEDWRITE,但是这个时候的page不是BITMAP_PAGE_NEEDWRITE,所以进入else的处理,

把页标记为BITMAP_PAGE_NEEDWRITE
if (lastpage != NULL) {
if (test_page_attr(bitmap, lastpage,
BITMAP_PAGE_NEEDWRITE)) {
clear_page_attr(bitmap, lastpage,
BITMAP_PAGE_NEEDWRITE);
spin_unlock_irqrestore(&bitmap->lock, flags);
write_page(bitmap, lastpage, 0);
} else {
set_page_attr(bitmap, lastpage,
BITMAP_PAGE_NEEDWRITE);
bitmap->allclean = 0;
spin_unlock_irqrestore(&bitmap->lock, flags);
}
}

继续执行,bmc为2,会把bmc设置为1,并且再设置一次BITMAP_PAGE_PENDING

if (*bmc) {

            if (*bmc == 1 && !bitmap->need_sync) {
                /* we can clear the bit */
                *bmc = 0;
                bitmap_count_page(bitmap,
                          (sector_t)j << CHUNK_BLOCK_SHIFT(bitmap),
                          -1);
                /* clear the bit */
                paddr = kmap_atomic(page, KM_USER0);
                if (bitmap->flags & BITMAP_HOSTENDIAN)
                    clear_bit(file_page_offset(bitmap, j),
                          paddr);
                else
                    __clear_bit_le(
                        file_page_offset(bitmap,
                                 j),
                        paddr);
                kunmap_atomic(paddr, KM_USER0);

            } else if (*bmc <= 2) {

                // 进入这里把bmc设置为bmc=1

*bmc = 1; /* maybe clear the bit next time */
set_page_attr(bitmap, page, BITMAP_PAGE_PENDING);
bitmap->allclean = 0;
}

第一次调用结束。
    2)第二次进入bitmap_daemon_work,bmc=1,页属性为BITMAP_PAGE_PENDING和BITMAP_PAGE_NEEDWRITE。

这样就会走到下面的流程,把BITMAP_PAGE_PENDING清掉

if (*bmc == 1 && !bitmap->need_sync) {

                /* we can clear the bit */
                *bmc = 0;
                bitmap_count_page(bitmap,
                          (sector_t)j << CHUNK_BLOCK_SHIFT(bitmap),
                          -1);

                /* clear the bit */

                // 这里才是真正的位图文件缓存页bit位清0的地方
                paddr = kmap_atomic(page, KM_USER0);
                if (bitmap->flags & BITMAP_HOSTENDIAN)
                    clear_bit(file_page_offset(bitmap, j),
                          paddr);
                else
                    __clear_bit_le(
                        file_page_offset(bitmap,
                                 j),
                        paddr);
                kunmap_atomic(paddr, KM_USER0);
            }
    2)第三次进入bitmap_daemon_work,bmc=1,页属性为BITMAP_PAGE_NEEDWRITE。

会走到下面的流程,清掉BITMAP_PAGE_NEEDWRITE,然后调用write_page刷到磁盘中,至此,清理操作才完成,

总共需要调用三次bitmap_daemon_work才能完成一个bit的清0操作。

if (!test_page_attr(bitmap, page, BITMAP_PAGE_PENDING)) { int need_write = test_page_attr(bitmap, page, BITMAP_PAGE_NEEDWRITE); if (need_write) clear_page_attr(bitmap, page, BITMAP_PAGE_NEEDWRITE);spin_unlock_irqrestore(&bitmap->lock, flags);
if (need_write)
write_page(bitmap, page, 0);
spin_lock_irqsave(&bitmap->lock, flags);
j |= (PAGE_BITS – 1);
continue;
}

    这种异步清零的机制好处在于,在还未清零或者内存位图清0但没有刷到磁盘的时候,又有对该页的写请求到来,就只用增加bmc计数器或者只是把内存位图置位,而不用再写到外存的位图文件中,从而减少了一次写外存位图的io。


    更多技术分享,请关注网易视频云进行咨询与交流哦!

你可能感兴趣的:(数据存储,技术开发,视频云,网易视频云)