转自:http://blog.csdn.net/opennaive/article/details/
感谢博主fumin的奉献。
谷歌技术"三宝"之MapReduce
江湖传说永流传:谷歌技术有"三宝",GFS、MapReduce和大表(BigTable)!
谷歌在03到06年间连续发表了三篇很有影响力的文章,分别是03年SOSP的GFS,04年OSDI的MapReduce,和06年OSDI的BigTable。SOSP和OSDI都是操作系统领域的顶级会议,在计算机学会推荐会议里属于A类。SOSP在单数年举办,而OSDI在双数年举办。
那么这篇博客就来介绍一下MapReduce。
1. MapReduce是干啥的
GFS和BigTable已经为我们提供了高性能、高并发的服务,但是并行编程可不是所有程序员都玩得转的活儿,如果我们的应用本身不能并发,那GFS、BigTable也都是没有意义的。MapReduce的伟大之处就在于让不熟悉并行编程的程序员也能充分发挥分布式系统的威力。
简单概括的说,MapReduce是将一个大作业拆分为多个小作业的框架(大作业和小作业应该本质是一样的,只是规模不同),用户需要做的就是决定拆成多少份,以及定义作业本身。
下面用一个贯穿全文的例子来解释MapReduce是如何工作的。
2. 例子:统计词频
如果我想统计下过去10年计算机论文出现最多的几个单词,看看大家都在研究些什么,那我收集好论文后,该怎么办呢?
方法一:我可以写一个小程序,把所有论文按顺序遍历一遍,统计每一个遇到的单词的出现次数,最后就可以知道哪几个单词最热门了。
这种方法在数据集比较小时,是非常有效的,而且实现最简单,用来解决这个问题很合适。
方法二:写一个多线程程序,并发遍历论文。
这个问题理论上是可以高度并发的,因为统计一个文件时不会影响统计另一个文件。当我们的机器是多核或者多处理器,方法二肯定比方法一高效。但是写一个多线程程序要比方法一困难多了,我们必须自己同步共享数据,比如要防止两个线程重复统计文件。
方法三:把作业交给多个计算机去完成。
我们可以使用方法一的程序,部署到N台机器上去,然后把论文集分成N份,一台机器跑一个作业。这个方法跑得足够快,但是部署起来很麻烦,我们要人工把程序copy到别的机器,要人工把论文集分开,最痛苦的是还要把N个运行结果进行整合(当然我们也可以再写一个程序)。
方法四:让MapReduce来帮帮我们吧!
MapReduce本质上就是方法三,但是如何拆分文件集,如何copy程序,如何整合结果这些都是框架定义好的。我们只要定义好这个任务(用户程序),其它都交给MapReduce。
在介绍MapReduce如何工作之前,先讲讲两个核心函数map和reduce以及MapReduce的伪代码。
3. map函数和reduce函数
map函数和reduce函数是交给用户实现的,这两个函数定义了任务本身。
- map函数:接受一个键值对(key-value pair),产生一组中间键值对。MapReduce框架会将map函数产生的中间键值对里键相同的值传递给一个reduce函数。
- reduce函数:接受一个键,以及相关的一组值,将这组值进行合并产生一组规模更小的值(通常只有一个或零个值)。
统计词频的MapReduce函数的核心代码非常简短,主要就是实现这两个函数。
- map(String key, String value):
- // key: document name
- // value: document contents
- for each word w in value:
- EmitIntermediate(w, "1");
- reduce(String key, Iterator values):
- // key: a word
- // values: a list of counts
- int result = 0;
- for each v in values:
- result += ParseInt(v);
- Emit(AsString(result));
在统计词频的例子里,map函数接受的键是文件名,值是文件的内容,map逐个遍历单词,每遇到一个单词w,就产生一个中间键值对
4. MapReduce是如何工作的
上图是论文里给出的流程图。一切都是从最上方的user program开始的,user program链接了MapReduce库,实现了最基本的Map函数和Reduce函数。图中执行的顺序都用数字标记了。
- MapReduce库先把user program的输入文件划分为M份(M为用户定义),每一份通常有16MB到64MB,如图左方所示分成了split0~4;然后使用fork将用户进程拷贝到集群内其它机器上。
- user program的副本中有一个称为master,其余称为worker,master是负责调度的,为空闲worker分配作业(Map作业或者Reduce作业),worker的数量也是可以由用户指定的。
- 被分配了Map作业的worker,开始读取对应分片的输入数据,Map作业数量是由M决定的,和split一一对应;Map作业从输入数据中抽取出键值对,每一个键值对都作为参数传递给map函数,map函数产生的中间键值对被缓存在内存中。
- 缓存的中间键值对会被定期写入本地磁盘,而且被分为R个区,R的大小是由用户定义的,将来每个区会对应一个Reduce作业;这些中间键值对的位置会被通报给master,master负责将信息转发给Reduce worker。
- master通知分配了Reduce作业的worker它负责的分区在什么位置(肯定不止一个地方,每个Map作业产生的中间键值对都可能映射到所有R个不同分区),当Reduce worker把所有它负责的中间键值对都读过来后,先对它们进行排序,使得相同键的键值对聚集在一起。因为不同的键可能会映射到同一个分区也就是同一个Reduce作业(谁让分区少呢),所以排序是必须的。
- reduce worker遍历排序后的中间键值对,对于每个唯一的键,都将键与关联的值传递给reduce函数,reduce函数产生的输出会添加到这个分区的输出文件中。
- 当所有的Map和Reduce作业都完成了,master唤醒正版的user program,MapReduce函数调用返回user program的代码。
所有执行完毕后,MapReduce输出放在了R个分区的输出文件中(分别对应一个Reduce作业)。用户通常并不需要合并这R个文件,而是将其作为输入交给另一个MapReduce程序处理。整个过程中,输入数据是来自底层分布式文件系统(GFS)的,中间数据是放在本地文件系统的,最终输出数据是写入底层分布式文件系统(GFS)的。而且我们要注意Map/Reduce作业和map/reduce函数的区别:Map作业处理一个输入数据的分片,可能需要调用多次map函数来处理每个输入键值对;Reduce作业处理一个分区的中间键值对,期间要对每个不同的键调用一次reduce函数,Reduce作业最终也对应一个输出文件。
我更喜欢把流程分为三个阶段。第一阶段是准备阶段,包括1、2,主角是MapReduce库,完成拆分作业和拷贝用户程序等任务;第二阶段是运行阶段,包括3、4、5、6,主角是用户定义的map和reduce函数,每个小作业都独立运行着;第三阶段是扫尾阶段,这时作业已经完成,作业结果被放在输出文件里,就看用户想怎么处理这些输出了。
5. 词频是怎么统计出来的
结合第四节,我们就可以知道第三节的代码是如何工作的了。假设咱们定义M=5,R=3,并且有6台机器,一台master。
这幅图描述了MapReduce如何处理词频统计。由于map worker数量不够,首先处理了分片1、3、4,并产生中间键值对;当所有中间值都准备好了,Reduce作业就开始读取对应分区,并输出统计结果。
6. 用户的权利
- an input reader。这个函数会将输入分为M个部分,并且定义了如何从数据中抽取最初的键值对,比如词频的例子中定义文件名和文件内容是键值对。
- a partition function。这个函数用于将map函数产生的中间键值对映射到一个分区里去,最简单的实现就是将键求哈希再对R取模。
- a compare function。这个函数用于Reduce作业排序,这个函数定义了键的大小关系。
- an output writer。负责将结果写入底层分布式文件系统。
- a combiner function。实际就是reduce函数,这是用于前面提到的优化的,比如统计词频时,如果每个
要读一次,因为reduce和map通常不在一台机器,非常浪费时间,所以可以在map执行的地方先运行一次combiner,这样reduce只需要读一次 了。 - map和reduce函数就不多说了。
7. MapReduce的实现
参考文献
题记:初学分布式文件系统,写篇博客加深点印象。GFS的特点是使用一堆廉价的商用计算机支撑大规模数据处理。
虽然"The Google File System " 是03年发表的老文章了,但现在仍被广泛讨论,其对后来的分布式文件系统设计具有指导意义。然而,作者在设计GFS时,是基于过去很多实验观察的,并提出了很多假设作为前提,这等于给出了一个GFS的应用场景。所以我们自己在设计分布式系统时,一定要注意自己的应用场景是否和GFS相似,不能盲从GFS。
GFS的主要假设如下:
- GFS的服务器都是普通的商用计算机,并不那么可靠,集群出现结点故障是常态。因此必须时刻监控系统的结点状态,当结点失效时,必须能检测到,并恢复之。
- 系统存储适当数量的大文件。理想的负载是几百万个文件,文件一般都超过100MB,GB级别以上的文件是很常见的,必须进行有效管理。支持小文件,但不对其进行优化。
- 负载通常包含两种读:大型的流式读(顺序读),和小型的随机读。前者通常一次读数百KB以上,后者通常在随机位置读几个KB。
- 负载还包括很多连续的写操作,往文件追加数据(append)。文件很少会被修改,支持随机写操作,但不必进行优化。
- 系统必须实现良好定义的语义,用于多客户端并发写同一个文件。同步的开销必须保证最小。
- 高带宽比低延迟更重要,GFS的应用大多需要快速处理大量的数据,很少会严格要求单一操作的响应时间。
1 体系结构
GFS包括一个master结点(元数据服务器),多个chunkserver(数据服务器)和多个client(运行各种应用的客户端)。在可靠性要求不高的场景,client和chunkserver可以位于一个结点。图1是GFS的体系结构示意图,每一结点都是普通的Linux服务器,GFS的工作就是协调成百上千的服务器为各种应用提供服务。
- chunkserver提供存储。GFS会将文件划分为定长数据块,每个数据块都有一个全局唯一不可变的id(chunk_handle),数据块以普通Linux文件的形式存储在chunkserver上,出于可靠性考虑,每个数据块会存储多个副本,分布在不同chunkserver。
- GFS master就是GFS的元数据服务器,负责维护文件系统的元数据,包括命名空间、访问控制、文件-块映射、块地址等,以及控制系统级活动,如垃圾回收、负载均衡等。
- 应用需要链接client的代码,然后client作为代理与master和chunkserver交互。master会定期与chunkserver交流(心跳),以获取chunkserver的状态并发送指令。
2 数据的布局
GFS将文件条带化,按照类似RAID0的形式进行存储,可以提高聚合带宽。事实上,大多数分布式存储系统都会采取这种策略。GFS将文件按固定长度切分为数据块,master在创建一个新数据块时,会给每个数据块分配一个全局唯一且不可变的64位id。每个数据块以Linux文件的形式存储在chunkserver的本地文件系统里。
GFS为数据块设置了一个很大的长度,64MB,这比传统文件系统的块长要大多了。大块长会带来很多好处:1.减少client和master的交互次数,因为读写同一个块只需要一次交互,在GFS假设的顺序读写负载的场景下特别有用;2.同样也减少了client和chunkserver的交互次数,降低TCP/IP连接等网络开销;3.减少了元数据的规模,因此master可以将元数据完全放在内存,这对于集中式元数据模型的GFS尤为重要。
大数据块也有缺点。最大的缺点可能就是内部碎片了,不过考虑到文件一般都相当大,所以碎片也只存在于文件的最后一个数据块。还有一个缺点不是那么容易看出来,由于小文件可能只有少量数据块,极端情况只有一个,那么当这个小文件是热点文件时,存储该文件数据块的chunkserver可能会负载过重。不过正如前面所说,小文件不在GFS的优化范围。
为了提高数据的可靠性和并发性,每一个数据块都有多个副本。当客户端请求一个数据块时,master会将所有副本的地址都通知客户端,客户端再择优(距离最短等)选择一个副本。一个典型的GFS集群可能有数百台服务器,跨越多个子网,因此在考虑副本的放置时,不仅要考虑机器级别的错误,还要考虑整个子网瘫痪了该怎么办。将副本分布到多个子网去,还可以提高系统的聚合带宽。因此创建一个数据块时,主要考虑几个因素:1.优先考虑存储利用率低于平均水平的结点;2.限制单个结点同时创建副本的数量;3.副本尽量跨子网。
3 元数据服务
GFS是典型的集中式元数据服务,所有的元数据都存放在一个master结点内。元数据主要包括三种:文件和数据块的命名空间,文件-数据块映射表,数据块的副本位置。所有的元数据都是放在内存里的。
前两种元数据会被持久化到本地磁盘中,以操作日志的形式。操作日志会记录下这两种元数据的每一次关键变化,因此当master宕机,就可以根据日志恢复到某个时间点。日志的意义还在于,它提供了一个时间线,用于定义操作的先后顺序,文件、数据块的版本都依赖于这个时间顺序。
数据块的副本位置则没有持久化,因为动辄数以百计的chunkserver是很容易出错的,因此只有chunkserver对自己存储的数据块有绝对的话语权,而master上的位置信息很容易因为结点失效等原因而过时。取而代之的方法是,master启动时询问每个chunkserver的数据块情况,而且chunkserver在定期的心跳检查中也会汇报自己存储的部分数据块情况。
GFS物理上没有目录结构,也不支持链接操作,使用一张表来映射文件路径名和元数据。
4 缓存和预取
GFS的客户端和chunkserver都不会缓存任何数据,这是因为GFS的典型应用是顺序访问大文件,不存在时间局部性。空间局部性虽然存在,但是数据集一般很大,以致没有足够的空间缓存。
我们知道集中式元数据模型的元数据服务器容易成为瓶颈,应该尽量减少客户端与元数据服务器的交互。因此GFS设计了元数据缓存。client需要访问数据时,先询问master数据在哪儿,然后将这个数据地址信息缓存起来,之后client对该数据块的操作都只需直接与chunkserver联系了,当然缓存的时间是有限的,过期作废。
master还会元数据预取。因为空间局部性是存在,master可以将逻辑上连续的几个数据块的地址信息一并发给客户端,客户端缓存这些元数据,以后需要时就可以不用找master的麻烦了。
5 出错了肿么办
引用:“We treat component failures as the norm rather than the exception."
分布式系统整体的可靠性是至关重要的。GFS集群使用的都是普通的商用计算机,而且机器的数量众多,设备故障经常出现,如何处理结点失效的问题是GFS最大的挑战。
5.1 完整性
GFS使用数以千计的磁盘,磁盘出错导致数据被破坏时有发生,我们可以用其它副本来恢复数据,但首先必须能检测出错误。chunksever会使用校验和来检测错误数据。每一个块(chunk)都被划分为64KB的单元(block),每个block对应一个32位的校验和。校验和与数据分开存储,内存有一份,然后以日志的形式在磁盘备一份。
chunkserver在发送数据之前会核对数据的校验和,防止错误的数据传播出去。如果校验和与数据不匹配,就返回错误,并且向master反映情况。master会开始克隆副本的操作,完成后就命令该chunkserver删除非法副本。
5.2 一致性
一致性指的是master的元数据和chunkserver的数据是否一致,多个数据块副本之间是否一致,多个客户端看到的数据是否一致。
先来看看元数据一致性。GFS的命名空间操作是原子性的,并且用日志记录下操作顺序。虽然GFS没有目录结构,但是仍然有一颗逻辑的目录树,树的每个结点都有自己的读写锁,每个元数据操作都需要获得一系列的锁,应该是写锁会阻塞其它的锁,而读锁只阻塞写锁而不阻塞读锁。比如/home/user "目录" 正在创建快照,需要获得/home的读锁和/home/user的写锁,这时如果想创建文件/home/user/foo会被阻塞,因为需要获得/home、/home/user的读锁以及/home/user/foo的写锁,快照会阻塞创建操作获取/home/user的读锁。如果是在一个有传统目录树结构的文件系统里,创建一个文件需要修改父目录的数据,因此需要获得父目录的写锁。这种锁机制允许在一个目录里并发修改数据(如并发创建文件等),这在传统文件系统里是不允许的。
再来看看GFS是如何并发写(write)的,GFS必须将对数据块的修改同步到每一个副本。考虑一下多个应用同时修改同一数据块的情况,我们必须为修改操作定义统一的时序,不然多个副本会出现不一致的情况,那么定义时序由谁做呢?还记得前面提到的元数据缓存么,为了减少master的负担,client在获得副本位置后就不再和master交互,所以必然需要选出一个master代理来完成这个任务。事实上GFS采用了租约(lease)的机制,master会将租约授权给某个副本,称为primary,由这个primary来确定数据修改的顺序,其它副本照做就是。
图2是写操作的控制流和数据流:
- client需要更新一个数据块,询问master谁拥有该数据块的租约(谁是primary);
- master将持有租约的primary和其它副本的位置告知client,client缓存之;
- client向所有副本传输数据,这里副本没有先后顺序,根据网络拓扑情况找出最短路径,数据从client出发沿着路径流向各个chunkserver,这个过程采用流水线(网络和存储并行)。chunkserver将数据放到LRU缓存;
- 一旦所有的副本都确定接受数据,client向primary发送写请求,primary为这个前面接受到的数据分配序列号(primary为所有的写操作分配连续的序列号表示先后顺序),并且按照顺序执行数据更新;
- primary将写请求发送给其它副本,每个副本都按照primary确定的顺序执行更新;
- 其它副本向primary汇报操作情况;
- primary回复client操作情况,任何副本错误都导致此次请求失败,并且此时副本处于不一致状态(写操作完成情况不一样)。client会尝试几次3到7的步骤,实在不行就只能重头来过了。
GFS还提供另一种写操作append record,append只在文件的尾部以record为单位(为了避免内部碎片,record一般不会很大)写入数据。append是原子性的,GFS保证将数据顺序地写到文件尾部至少一次。append record的流程和图2类似,只是在primary有点区别,GFS必须保证一个record存储在一个chunk内,所以当primary判断当前chunk无足够空间时,就通知所有副本将chunk填充,然后汇报失败,client会申请创建新chunk并重新执行一次append record操作。如果该chunk大小合适,primary会将数据写到数据块的尾部,然后通知其它副本将数据写到一样的偏移。任何副本append失败,各个副本会处于不一致状态(完成或未完成),这时primary必然是成功写入的(不然就没有4以后的操作了)。客户端重试append record操作时,因为primary的chunk长度已经变化了,primary就必须在新的偏移写入数据,而其它副本也是照做。这就导致上一个失败的偏移位置,各个副本处于不一致状态,应用必须自己区分record正确与否,我称这为无效数据区。
表1说明了GFS的一致性保证,明白write和append操作后就容易理解了。consistent指的是多个副本之间是完全一致的;defined指的是副本数据修改后不仅一致而且client能看到其修改的数据全貌。
- 成功的连续write是已定义的,各个副本数据一致;
- 成功的并发write能保证一致性性,即各个副本是一样的,但数据并不一定如用户所期望,如前所述,多个用户的修改可能交错在一起;
- 失败的write操作,使得副本之间不一致,而且数据undefined,不同client可能看到不同的数据(注意区别defined、undefined数据的方法);
- 成功的append操作,不管是顺序还是并发都是defined,因为GFS保证了append是原子性的(atomically at least once)。有效数据区确实是defined的,但是失败append操作留下的无效数据区可能会有不一致的情况,所以中间可能零散分布着不一致的数据。
如上所述,在primary的协调下,能保证并发write的一致性。但还有一些可能会导致数据不一致,比如chunkserver宕机错过了数据更新,这时就会出现新旧版本的数据,GFS为每个数据块分配版本来解决这个问题。master每次授权数据块租约给primary之前,都会增加数据块的版本号,并且通知所有副本更新版本号。客户端需要读数据时当然会根据这个版本号来判断副本的数据是否最新。
5.3 可用性
为了保证数据的可用性,GFS为每个数据块存储了多个副本,在数据的布局里有介绍,这里主要关注下元数据的可用性。
GFS是典型的集中式元数据模型,一个元数据服务器承担了巨大的压力,不仅有性能的问题,还有单点故障问题。master为了能快速从故障中恢复过来,采用了log和checkpoint技术,log记录了元数据的每一次变化。用咱们备份的话来说,checkpoint就相当于一次全量备份,log相当于连续数据保护,master宕机后,就先恢复到checkpoint,然后根据log恢复到最新状态。每次创建一个新的checkpoint,log就可以清空,这有效控制了log的大小。
这还不够,如果master完全坏了肿么办?GFS设置了“影子”服务器,master将日志备份到影子上,影子按照日志把元数据与master同步。如果master悲剧了,我们还有影子。平时正常工作时,影子可以分担一部分元数据访问工作,当然不提供直接的写操作。
6 测试
6.1 模拟
实验的网络拓扑图大概就是这样。集群包括一个master和它的两个影子,16个chunkserver,16个client,和两个HP交换机。两个交换机之间是1Gbps的链路,结点与交换机的链路是100Mbps。聚合带宽的理论上限是125MB/s,而单个client的理论带宽上限是12.5MB/s。
实验一:N个client同时随机各自读取1GB数据。
图3(a),x轴是N,y轴是聚合带宽,上面一条线是理论值,下面一条线是实际值。当N=1时,吞吐率是10MB/s,达到了理论值的80%。当N=16时,吞吐率是94MB/s,达到理论值的75%。此时瓶颈可能是在chunkserver,因为client数量很多,同时读一个chunkserver的概率很大。
实验二:N个client同时写N个不同文件,各自连续写1GB。
图3(b)。理论值上限是67MB/s(The limit plateaus at 67 MB/s because we need to write each byte to 3 of the 16 chunkservers,
each with a 12.5 MB/s input connection),这个理论值我没有看明白是怎么算的。当N=1,吞吐率是6.3MB/s,达到极限的一半,主要的瓶颈是数据在副本之间传递。N=16时,聚合带宽为35MB/s(每个client有2.2MB/s),达到极限的一半,这里chunkserver同时接受多个请求的情况比读更严重,因为得写3个副本。写比期望的要慢。
实验三:N个client同时向一个文件append record。
图3(c)。理论上限值是一台chunkserver的带宽,即12.5MB/s。当N=1时,吞吐率有6.0MB/s。当N=16时,下降到4.8MB/s。这可能是因为拥塞。
6.2 现实
文章里还介绍了两个真实集群的使用情况,cluster A and B。
表2是两个集群的情况。A和B都有数百个chunkserver,存储利用率很高,冗余度是3,所以实际存储的数据是18TB和52TB。文件数相当,dead file是需要删除的文件,但还没被垃圾回收(GFS会为删除的文件保留三天才回收空间)。B的块数更多,意味着文件更大,不过A和B的文件平均都只有1到2个块。chunkserver的元数据主要是block(64KB)的校验和(4 Bytes),以及数据块的版本信息。master的元数据(文件和数据块的名字,文件-数据块映射,块位置等)相当小,平均每个文件只有100B元数据,大部分是用于存储文件名。平均下来,每个服务器有50~100MB的元数据。
7 胡说八道
画了个全分布式元数据模型。
这估计是最简单的,命名空间被划分为几个区域,master各管各的。为了可靠性,每个master做几个副本。映射方法可以是文件名计算哈希取模,好的哈希函数可以使文件随机分布,负载比较均衡。当然扩展性不是很好,加入新的结点,所有文件得重新映射。而且冗余度只能靠机器堆了,不能软件控制。
又构思了个复杂点的。将文件映射到r(<=N)个master,利用参数r控制冗余度,当r=N时就变成全对等元数据集群。这个模型需要r个不同的哈希函数,为了减少开销,可以用两个函数模拟多个函数。比如有随机性很好的f(x)和g(x)函数,我们用式子f(x)+i*g(x)来模拟,其中i为非负整数。当我们处理文件x时,就用前面式子求出r个不同的位置(有冲突的概率,实际可能不止计算r次)。
胡说八道,切勿当真;如有雷同,纯属巧合。
参考文献:
[1] The Google File System.
谷歌技术"三宝"之BigTable
2006年的OSDI有两篇google的论文,分别是BigTable和Chubby。Chubby是一个分布式锁服务,基于Paxos算法;BigTable是一个用于管理结构化数据的分布式存储系统,构建在GFS、Chubby、SSTable等google技术之上。相当多的google应用使用了BigTable,比如Google Earth和Google Analytics,因此它和GFS、MapReduce并称为谷歌技术"三宝"。
与GFS和MapReduce的论文相比,我觉得BigTable的论文难懂一些。一方面是因为自己对数据库不太了解,另一方面又是因为对数据库的理解局限于关系型数据库。尝试用关系型数据模型去理解BigTable就容易"走火入魔"。在这里推荐一篇文章(需要FQ):Understanding HBase and BigTable,相信这篇文章对理解BigTable/HBase的数据模型有很大帮助。
1 什么是BigTable
Bigtable是一个为管理大规模结构化数据而设计的分布式存储系统,可以扩展到PB级数据和上千台服务器。很多google的项目使用Bigtable存储数据,这些应用对Bigtable提出了不同的挑战,比如数据规模的要求、延迟的要求。Bigtable能满足这些多变的要求,为这些产品成功地提供了灵活、高性能的存储解决方案。
Bigtable看起来像一个数据库,采用了很多数据库的实现策略。但是Bigtable并不支持完整的关系型数据模型;而是为客户端提供了一种简单的数据模型,客户端可以动态地控制数据的布局和格式,并且利用底层数据存储的局部性特征。Bigtable将数据统统看成无意义的字节串,客户端需要将结构化和非结构化数据串行化再存入Bigtable。
下文对BigTable的数据模型和基本工作原理进行介绍,而各种优化技术(如压缩、Bloom Filter等)不在讨论范围。
2 BigTable的数据模型
Bigtable不是关系型数据库,但是却沿用了很多关系型数据库的术语,像table(表)、row(行)、column(列)等。这容易让读者误入歧途,将其与关系型数据库的概念对应起来,从而难以理解论文。Understanding HBase and BigTable是篇很优秀的文章,可以帮助读者从关系型数据模型的思维定势中走出来。
本质上说,Bigtable是一个键值(key-value)映射。按作者的说法,Bigtable是一个稀疏的,分布式的,持久化的,多维的排序映射。
先来看看多维、排序、映射。Bigtable的键有三维,分别是行键(row key)、列键(column key)和时间戳(timestamp),行键和列键都是字节串,时间戳是64位整型;而值是一个字节串。可以用 (row:string, column:string, time:int64)→string 来表示一条键值对记录。
行键可以是任意字节串,通常有10-100字节。行的读写都是原子性的。Bigtable按照行键的字典序存储数据。Bigtable的表会根据行键自动划分为片(tablet),片是负载均衡的单元。最初表都只有一个片,但随着表不断增大,片会自动分裂,片的大小控制在100-200MB。行是表的第一级索引,我们可以把该行的列、时间和值看成一个整体,简化为一维键值映射,类似于:
- table{
- "1" : {sth.},//一行
- "aaaaa" : {sth.},
- "aaaab" : {sth.},
- "xyz" : {sth.},
- "zzzzz" : {sth.}
- }
列是第二级索引,每行拥有的列是不受限制的,可以随时增加减少。为了方便管理,列被分为多个列族(column family,是访问控制的单元),一个列族里的列一般存储相同类型的数据。一行的列族很少变化,但是列族里的列可以随意添加删除。列键按照family:qualifier格式命名的。这次我们将列拿出来,将时间和值看成一个整体,简化为二维键值映射,类似于:
- table{
- // ...
- "aaaaa" : { //一行
- "A:foo" : {sth.},//一列
- "A:bar" : {sth.},//一列
- "B:" : {sth.} //一列,列族名为B,但是列名是空字串
- },
- "aaaab" : { //一行
- "A:foo" : {sth.},
- "B:" : {sth.}
- },
- // ...
- }
或者可以将列族当作一层新的索引,类似于:
- table{
- // ...
- "aaaaa" : { //一行
- "A" : { //列族A
- "foo" : {sth.}, //一列
- "bar" : {sth.}
- },
- "B" : { //列族B
- "" : {sth.}
- }
- },
- "aaaab" : { //一行
- "A" : {
- "foo" : {sth.},
- },
- "B" : {
- "" : "ocean"
- }
- },
- // ...
- }
时间戳是第三级索引。Bigtable允许保存数据的多个版本,版本区分的依据就是时间戳。时间戳可以由Bigtable赋值,代表数据进入Bigtable的准确时间,也可以由客户端赋值。数据的不同版本按照时间戳降序存储,因此先读到的是最新版本的数据。我们加入时间戳后,就得到了Bigtable的完整数据模型,类似于:
- table{
- // ...
- "aaaaa" : { //一行
- "A:foo" : { //一列
- 15 : "y", //一个版本
- 4 : "m"
- },
- "A:bar" : { //一列
- 15 : "d",
- },
- "B:" : { //一列
- 6 : "w"
- 3 : "o"
- 1 : "w"
- }
- },
- // ...
- }
图1是Bigtable论文里给出的例子,Webtable表存储了大量的网页和相关信息。在Webtable,每一行存储一个网页,其反转的url作为行键,比如maps.google.com/index.html的数据存储在键为com.google.maps/index.html的行里,反转的原因是为了让同一个域名下的子域名网页能聚集在一起。图1中的列族"anchor"保存了该网页的引用站点(比如引用了CNN主页的站点),qualifier是引用站点的名称,而数据是链接文本;列族"contents"保存的是网页的内容,这个列族只有一个空列"contents:"。图1中"contents:"列下保存了网页的三个版本,我们可以用("com.cnn.www", "contents:", t5)来找到CNN主页在t5时刻的内容。
再来看看作者说的其它特征:稀疏,分布式,持久化。持久化的意思很简单,Bigtable的数据最终会以文件的形式放到GFS去。Bigtable建立在GFS之上本身就意味着分布式,当然分布式的意义还不仅限于此。稀疏的意思是,一个表里不同的行,列可能完完全全不一样。
3 支撑技术
Bigtable依赖于google的几项技术。用GFS来存储日志和数据文件;按SSTable文件格式存储数据;用Chubby管理元数据。
GFS参见谷歌技术"三宝"之谷歌文件系统。BigTable的数据和日志都是写入GFS的。
SSTable的全称是Sorted Strings Table,是一种不可修改的有序的键值映射,提供了查询、遍历等功能。每个SSTable由一系列的块(block)组成,Bigtable将块默认设为64KB。在SSTable的尾部存储着块索引,在访问SSTable时,整个索引会被读入内存。BigTable论文没有提到SSTable的具体结构,LevelDb日知录之四: SSTable文件这篇文章对LevelDb的SSTable格式进行了介绍,因为LevelDB的作者JeffreyDean正是BigTable的设计师,所以极具参考价值。每一个片(tablet)在GFS里都是按照SSTable的格式存储的,每个片可能对应多个SSTable。
Chubby是一种高可用的分布式锁服务,Chubby有五个活跃副本,同时只有一个主副本提供服务,副本之间用Paxos算法维持一致性,Chubby提供了一个命名空间(包括一些目录和文件),每个目录和文件就是一个锁,Chubby的客户端必须和Chubby保持会话,客户端的会话若过期则会丢失所有的锁。关于Chubby的详细信息可以看google的另一篇论文:The Chubby lock service for loosely-coupled distributed systems。Chubby用于片定位,片服务器的状态监控,访问控制列表存储等任务。
4 Bigtable集群
Bigtable集群包括三个主要部分:一个供客户端使用的库,一个主服务器(master server),许多片服务器(tablet server)。
正如数据模型小节所说,Bigtable会将表(table)进行分片,片(tablet)的大小维持在100-200MB范围,一旦超出范围就将分裂成更小的片,或者合并成更大的片。每个片服务器负责一定量的片,处理对其片的读写请求,以及片的分裂或合并。片服务器可以根据负载随时添加和删除。这里片服务器并不真实存储数据,而相当于一个连接Bigtable和GFS的代理,客户端的一些数据操作都通过片服务器代理间接访问GFS。
主服务器负责将片分配给片服务器,监控片服务器的添加和删除,平衡片服务器的负载,处理表和列族的创建等。注意,主服务器不存储任何片,不提供任何数据服务,也不提供片的定位信息。
客户端需要读写数据时,直接与片服务器联系。因为客户端并不需要从主服务器获取片的位置信息,所以大多数客户端从来不需要访问主服务器,主服务器的负载一般很轻。
5 片的定位
前面提到主服务器不提供片的位置信息,那么客户端是如何访问片的呢?来看看论文给的示意图,Bigtable使用一个类似B+树的数据结构存储片的位置信息。
首先是第一层,Chubby file。这一层是一个Chubby文件,它保存着root tablet的位置。这个Chubby文件属于Chubby服务的一部分,一旦Chubby不可用,就意味着丢失了root tablet的位置,整个Bigtable也就不可用了。
第二层是root tablet。root tablet其实是元数据表(METADATA table)的第一个分片,它保存着元数据表其它片的位置。root tablet很特别,为了保证树的深度不变,root tablet从不分裂。
第三层是其它的元数据片,它们和root tablet一起组成完整的元数据表。每个元数据片都包含了许多用户片的位置信息。
可以看出整个定位系统其实只是两部分,一个Chubby文件,一个元数据表。注意元数据表虽然特殊,但也仍然服从前文的数据模型,每个分片也都是由专门的片服务器负责,这就是不需要主服务器提供位置信息的原因。客户端会缓存片的位置信息,如果在缓存里找不到一个片的位置信息,就需要查找这个三层结构了,包括访问一次Chubby服务,访问两次片服务器。
6 片的存储和访问
当片服务器收到一个写请求,片服务器首先检查请求是否合法。如果合法,先将写请求提交到日志去,然后将数据写入内存中的memtable。memtable相当于SSTable的缓存,当memtable成长到一定规模会被冻结,Bigtable随之创建一个新的memtable,并且将冻结的memtable转换为SSTable格式写入GFS,这个操作称为minor compaction。
当片服务器收到一个读请求,同样要检查请求是否合法。如果合法,这个读操作会查看所有SSTable文件和memtable的合并视图,因为SSTable和memtable本身都是已排序的,所以合并相当快。
每一次minor compaction都会产生一个新的SSTable文件,SSTable文件太多读操作的效率就降低了,所以Bigtable定期执行merging compaction操作,将几个SSTable和memtable合并为一个新的SSTable。BigTable还有个更厉害的叫major compaction,它将所有SSTable合并为一个新的SSTable。
遗憾的是,BigTable作者没有介绍memtable和SSTable的详细数据结构。
7 BigTable和GFS的关系
集群包括主服务器和片服务器,主服务器负责将片分配给片服务器,而具体的数据服务则全权由片服务器负责。但是不要误以为片服务器真的存储了数据(除了内存中memtable的数据),数据的真实位置只有GFS才知道,主服务器将片分配给片服务器的意思应该是,片服务器获取了片的所有SSTable文件名,片服务器通过一些索引机制可以知道所需要的数据在哪个SSTable文件,然后从GFS中读取SSTable文件的数据,这个SSTable文件可能分布在好几台chunkserver上。
8 元数据表的结构
元数据表(METADATA table)是一张特殊的表,它被用于数据的定位以及一些元数据服务,不可谓不重要。但是Bigtable论文里只给出了少量线索,而对表的具体结构没有说明。这里我试图根据论文的一些线索,猜测一下表的结构。首先列出论文中的线索:
- The METADATA table stores the location of a tablet under a row key that is an encoding of the tablet's table identifier and its end row.
- Each METADATA row stores approximately 1KB of data in memory(因为访问量比较大,元数据表是放在内存里的,这个优化在论文的locality groups中提到).This feature(将locality group放到内存中的特性) is useful for small pieces of data that are accessed frequently: we use it internally for the location column family in the METADATA table.
- We also store secondary information in the METADATA table, including a log of all events pertaining to each tablet(such as when a server begins
serving it).
第一条线索,元数据表的行键是由片所属表名的id和片最后一行编码而成,所以每个片在元数据表中占据一条记录(一行),而且行键既包含了其所属表的信息也包含了其所拥有的行的范围。譬如采取最简单的编码方式,元数据表的行键等于strcat(表名,片最后一行的行键)。
第二点线索,除了知道元数据表的地址部分是常驻内存以外,还可以发现元数据表有一个列族称为location,我们已经知道元数据表每一行代表一个片,那么为什么需要一个列族来存储地址呢?因为每个片都可能由多个SSTable文件组成,列族可以用来存储任意多个SSTable文件的位置。一个合理的假设就是每个SSTable文件的位置信息占据一列,列名为location:filename。当然不一定非得用列键存储完整文件名,更大的可能性是把SSTable文件名存在值里。获取了文件名就可以向GFS索要数据了。
第三个线索告诉我们元数据表不止存储位置信息,也就是说列族不止location,这些数据暂时不是咱们关心的。
通过以上信息,我画了一个简化的Bigtable结构图:
结构图以Webtable表为例,表中存储了网易、百度和豆瓣的几个网页。当我们想查找百度贴吧昨天的网页内容,可以向Bigtable发出查询Webtable表的(com.baidu.tieba, contents:, yesterday)。
假设客户端没有该缓存,那么Bigtable访问root tablet的片服务器,希望得到该网页所属的片的位置信息在哪个元数据片中。使用METADATA.Webtable.com.baidu.tieba为行键在root tablet中查找,定位到最后一个比它大的是METADATA.Webtable.com.baidu.www,于是确定需要的就是元数据表的片A。访问片A的片服务器,继续查找Webtable.com.baidu.tieba,定位到Webtable.com.baidu.www是比它大的,确定需要的是Webtable表的片B。访问片B的片服务器,获得数据。
参考文献
[1] Bigtable: A Distributed Storage System for Structured Data. In proceedings of OSDI'06.
[2] Understanding HBase and BigTable.