TFS与其他分布式存储系统的对比分析

来源:互联网 发布:淘宝绑定支付宝账号 编辑:程序博客网 时间:2024/05/05 14:57

TFS与其他分布式存储系统的对比分析

 

1 概述

TFS(Taobao File System),作为目前淘宝内部使用并开源的分布式文件系统,为淘宝提供海量小文件存储以及其他一些功能,被广泛地应用在淘宝各项应用中。其他分布式存储系统,这里主要指的是最近我通过读论文以及网络上的技术文档和分享所了解到的一些大公司所采用的存储系统,其中包括Google的GFS,BigTable(BT),Amazon的Dynamo以及Facebook的Cassandra。当然,市场上还存在着其他分布式存储系统以及相应的产品。而前面提到的这些产品,正是这些分布式存储系统的优秀代表。

在这五个分布式存储系统中,从数据是否结构化的角度划分,TFS、GFS和Dynamo均属于非结构化的存储系统,而剩下的BT和Cassandra则是典型的结构化Key-Value存储系统。由于它们和传统的关系数据库存储系统的差别,我们一般称之为NOSQL存储系统。另一方面,从系统的整体架构上来划分,TFS和GFS以及BT均属于星型(因为类似于网络中的星型拓扑设计)的架构设计,也就是采用中心服务节点控制数据服务节点的方式来对客户提供服务的。而剩下的Dynamo和Cassandra则是属于去中心化的P2P架构设计。正是由于它们在整体架构设计上的区别,很多分布式系统中所涉及的技术在实现上也存在着比较大的差异。

下面,我将从设计需求,具体架构,技术实现细节等这几方面对上述这些存储系统进行对比阐述。由于本人在分布式系统以及存储系统上刚刚起步,认识尚浅,水平有限,如有错误和认识不足的地方,请大家悉心指教,不胜感激。

2 设计需求

我们都知道,需求分析在软件工程中所处的地位是举足轻重的,如果需求没有分析到位,在软件的实施过程中往往会出现返工的现象,那样就会给软件的实施方造成巨大的损失,甚至是无法弥补的。对于分布式存储系统来说,我认为真正的需求包括两个方面,一个是功能上的需求,另一个则是性能上的需求。不管是功能上的需求还是性能上的需求,都有可能对系统的整个实施产生影响。

2.1 功能需求分析

从功能需求这一方面来看,TFS最初是为了满足淘宝内部对于小文件的存储需要而设计的。后面又加入了对大文件存储的支持,再到后来,又加入了对于各种特定应用的自定义文件名的支持,在读写方面,需要满足大规模的随机读写。这些功能上的需求,TFS在设计的最初应该是有所考虑的。至于其他几个存储系统,据我所知,GFS最初的设计是为了支持大文件的存储,并且在读方面需要满足大规模的流式读和小规模的随机读,在写方面需要满足大规模的追加写和小规模的随机写,BT则需要在Google的各项应用中均能灵活表现,Dynamo是为了支持Amazon的购物车而设计的,并且要求时刻不能停写服务,而Cassandra最初是为了解决收件箱搜索的存储需要而设计的。

2.2 性能需求分析

再从性能需求这一方面看,这几个分布式存储系统的一个最主要的共同点就是可扩展性(即伸缩性)比较好,这也是目前几乎所有的分布式存储系统所支持的特性。另一方面,可用性也是大部分分布式存储系统所支持的特性。如果一个分布式系统在读写性能上和单机的相差不大,那么这个分布式系统的设计无疑是失败的,或者说设计是有问题的。同时,很多分布式存储系统都是布置在普通的机器上的,某一台机器的不可用对于整个系统来说是很正常的事情,于是保证系统的可靠性也是几乎所有分布式存储系统所要考虑的问题。在可靠性的设计中,会涉及到包括复本(replication),负载均衡,数据一致性等很多复杂的问题,这些会在第四部分进行详细的分析。

另外,在分布式系统中存在一个有名的理论,即CAP理论。代表含义如下:

Consistency(一致性):任何一个读操作总是能读到之前完成的写操作结果。

Availability(可用性):每一个操作总是能够在确定的时间内返回。

Tolerance of network Partition(分区容忍性):在出现网络分区的情况下,仍然能够满足一致性和可用性。

并且这个理论认为,CAP三者无法同时满足。事实上,对于分布式系统来说,CAP理论其实是在给我们设计的时候提供了一个思路,那就是既然鱼与熊掌不可兼得,想要C和A,往往就需要牺牲P,那么如何在这三者之间做一个平衡考虑呢?针对不同的应用需求和功能需求,就需要设计不同的系统。

对于星型的系统,我们可以将CAP理论的定义做出适当的调整如下[1]

一致性:读操作总是能读取到之前完成的写操作结果,且不需要依赖于操作合并;

可用性:读写操作总是能够在很短的时间内返回,即使某台机器发生了故障,也能够通过其它副本正常执行,而不需要等到机器重启或者机器上的服务分配给其它机器以后才能成功;

分区可容忍性:能够处理机器宕机,机房停电或者出现机房之间网络故障等异常情况;

而对于P2P类型的系统,在一致性方面需要依赖于操作的合并,但它们在P方面是做的比较好的。因此,我们认为大部分星型的系统选择的是CA,而P2P的系统选择了AP。但并不是所有的系统都这么做的,关键还在于平衡把握这三者对应用的影响。

3 具体架构

无疑,在系统的架构和很多细节的设计上,TFS参考了GFS的设计思路。所以在描述TFS的具体架构之前,我们可以先来看看GFS的具体架构。

3.1  GFS架构

一个GFS集群[2]包括一个Master节点(这里一般是两台物理机器,外面来看仅仅是一个逻辑节点)和多个ChunkServer节点以及多个客户端访问。存储在GFS中的文件都被分割成固定大小(GFS中是64MB)的Chunk,并且给每一个Chunk分配一个不变的,全球唯一的64位的Chunk 标识。ChunkServer节点以Linux文件的形式将Chunk保存在本地磁盘中。所有的机器均是普通的Linux机器并运行着用户级别(user-level)的进程。下面我将从Master节点,ChunkServer节点,客户端以及缓存这四个方面描述下GFS的设计架构。

Master节点的主要功能是管理文件系统的所有元数据(为了找到真实数据所需要的数据,类似于Linux文件系统中的inode),并且这些元数据均是放在内存中进行管理的。这里的元数据包括Chunk和文件的名字空间,访问控制信息(是否可读写等权限),文件和Chunk的映射关系以及当前Chunk的位置信息(包含所有复本的地址)。除了管理元数据之外,Master还负责管理系统范围内的活动,比如Chunk租约的管理,孤儿Chunk的回收,以及Chunk在各个ChunkServer节点之间的迁移工作(负载均衡)等。

GFS将元数据放在内存中进行管理,一是为了让整个系统高效运行,二是因为一个64字节的数据可以管理一个64MB大小的Chunk信息,内存的容量是可以承受的。但这样一来,如果Master由于某种原因退出服务,那么所有的元数据信息不就丢失了吗?因此,GFS需要对大部分元数据(最后一部分元数据因为可以通过Master和ChunkServer之间的心跳包进行传递,所以没必要持久化)进行持久化保存,为了使Master的恢复过程快一些,GFS采用了Checkpoint和Log的形式对Master进行持久化操作(这里让我想到了Linux文件系统中的dump分级拷贝的思想)。为了使Checkpoint的写入(GFS一般是在log增长到一定程度的时候采用压缩B+树进行Checkpoint文件的生成)不阻塞对Master元数据的修改,GFS设计了独立的线程来创建Checkpoint。

ChunkServer节点的主要功能则是负责实际数据的存储和读写。除此之外,ChunkServer还负责检测自身的Chunk信息,通过心跳形式定期地向Master进行汇报。

将客户端以库的形式链接到程序中也是GFS所提倡的,客户端与Master的通信只是为了获取元数据信息,真正的读写是直接和ChunkServer进行的。

对于数据缓存的设计,GFS考虑到一般的读取是以流的方式进行的,并且GFS存储的文件以大文件为主,再加上ChunkServer本地的Linux文件系统会将经常访问到的数据进行缓存,因此GFS在客户端和ChunkServer上都没有设计缓存,这也简化了客户端的设计。当然,对于元数据,客户端是有做缓存的。

3.2 TFS架构

TFS从内部应用和自身的需求出发,在重点借鉴了GFS设计的基础上,产生了自己的具体架构设计。和GFS类似,一个TFS集群[3]包含了两台NameServer节点(一主一备,互为热备)和多台DataServer节点。NameServer负责管理文件系统的元数据以及Block在DataServer中的各项活动,而DataServer则主要负责实际数据的存储和读写。在TFS中,各个小文件均以Block的形式存放在DataServer中,Block的大小可以通过配置项进行配置。客户端也是通过库的形式链接到程序中进行读写操作的。

与GFS有所不同的是,TFS在一台DataServer中一般会有多个独立的DataServer进程存在,每个进程负责管理一个挂载点,而每个挂载点一般是一个独立的磁盘目录,这样做可以降低单个磁盘损坏所带来的影响。另外,TFS的元数据中并不包含Block和文件的映射信息,因为TFS采用扁平化的数据组织方式,直接从TFS的文件名中解析出Block ID和File ID的信息,将文件名映射到文件的物理地址,简化了文件的访问流程,一定程度上为TFS提供了良好的读写性能。并且,由于TFS在读写的可用性方面要求比较高,并且考虑TFS存储的大部分是小文件,因此TFS在客户端本地和远程(目前用的是Tair)都设计了读缓存,以提高读数据的响应时间。后来,为了支持前端应用的发展和自定义文件名的功能,TFS又设计了RcServer节点,RootServer节点以及MetaServer节点作为补充,完善了TFS的功能。

3.3 BT架构

在NOSQL的发展过程中,BT属于星型NOSQL中的代表。BT[4]是一个能解决PB级别数据的分布式存储系统。从宏观的角度来看,BT是一个稀疏的,多维度,排序的Map结构。即(row:string,column:string,time:int64)->string的一种结构。

图3.1 key-value映射图[4]

其中存放着多个应用表(UserTable),每个这样的表又包含十到数百个Tablet(连续范围的多行数据,每个Tablet大概是100MB-200MB)。BT系统包含三个部分,一个是链接到客户程序中的库,一个是Master服务器以及多个Tablet服务器。

Master服务器的主要工作是为Tablet服务器分配Tablets;检测新加入的或者过期的Tablet服务器并对Tablet服务器进行负载均衡;对保存在GFS的文件进行垃圾回收;处理模式的相关修改,比如建立表和列簇。

Tablet服务器的主要工作是管理一个Tablets集合(十到上千个不等);负责它所加载的Tablet读写操作并在Tablet过大时切割操作。BT在每台Tablet 服务器上使用一个Commit Log用来对Tablet进行记录并利用它来进行恢复。BT将真实的数据组织成Google SSTable格式(在提Cassandra的时候我们会详细描述下该结构以及如何通过该结构进行数据的操作)的文件持久化到磁盘中。而所有的Log和SSTable数据均是存放于GFS中的。另外,BT还依赖于一个高效的分布式锁服务组件Chubby来保证读写的原子性操作。

3.4 Dynamo和Cassandra架构

Cassandra在数据的模型设计和内部存储方式上借鉴了BT的设计,但在系统的架构上却并没有采用BT的星型设计方案,而是采用P2P型的设计,而Dynamo正是这一个类型的代表,所以在架构上,我将这两者放在一起进行描述。

这两个系统在去中心化的前提下,在扩展性方面真正做到了水平扩展,各个服务节点地位完全相同,那没有了类似Master的中心节点,各个服务节点的加入和退出系统又是如何获知呢?Dynamo和Cassandra均是采用Gossip协议让节点与节点之间通信,获取各个节点的状态并存储的。如果每台机器仅存储它的上一台机器和下一台机器的位置信息,那么定位一台机器所需要的时间复杂度最大为O(N),但如果我们让每一台机器存储所有机器的位置信息的话,那么找到任意一台机器的时间就只需要一步了,所以在这里有一个时间和空间的权衡问题。在谈到该类型机器的加入和退出的过程中,我将会再次提到这个问题。

前面已经提到,在存在Master节点或NameServer的分布式系统中,负载均衡的过程都是它们负责的。而事实上,真正的分布式系统中的确是不需要这些中心节点的,这样才体现真正的平等分布。如何保证数据在去中心化分布式系统的负载均衡以及处理负载均衡过程中的数据迁移问题,Dynamo和Cassandra既有相同点,又有稍许的不同。

另外,在数据一致性方面,Dynamo首次提出了NWR模型来保证数据的最终一致性,而Cassandra也借用了该做法,但在一致性的维护方法上又做了一些调整,并且Google也将该思想用于下一代的存储基础设施中。虽然该思想无法提供像关系数据库一样的强一致性,但它充分灵活,可以在不同的应用中进行不同的配置。有了这个思想,Cassandra就可以在任何地方任何时间集中读或写任何数据,并且不会由于故障或并发写入而导致更新操作被拒绝。

4 技术实现

4.1 数据一致性和租约

对任何的存储系统或数据库系统来说,数据的一致性问题都是该系统在设计的时候需要认真考虑的问题。首先我们需要对数据的一致性进行清晰的认识。什么是数据的一致性,我们可以认为是存储系统和数据使用者之间(客户端)的一个约定。在分布式存储系统中往往会涉及三种一致性模型:强一致性,弱一致性和最终一致性。

所谓强一致性就是系统中的某个数据被成功更新(事务成功返回)后,后续任何对该数据的读取操作都能得到更新后的值。这也是传统的关系数据库所提供的一致性模型。所谓弱一致性则是指系统中的某个数据被更新后,后续对该数据的读取操作得到的不一定是更新后的值,在这种情况下有个“不一致性时间窗口”存在,在过了这个“不一致性窗口”之后,后续的读取才能得到更新后的值。而最终一致性是弱一致性的一种情况,指的是某个数据更新后没有被再次更新,那么最终所有的读取操作都会返回更新后的数据。

实际上,数据更新操作后region的状态取决于数据操作的类型(随机写还是追加写,是否支持并发操作)、是否成功、是否同步修改等。GFS对数据的一致性进行了重新阐释,将数据修改后region的状态分“一致的”和“已定义”两种状态分别进行描述。如果所有客户端,无论从哪个副本读取,读到的数据都一样,那么我们认为文件region是“一致的”;如果对文件的数据修改之后,region是一致的,并且客户端能够看到写入操作全部的内容,那么这个region是“已定义的”。在大部分写操作为追加写的前提下,GFS支持客户端的并发更新操作并通过一系列技术保证GFS能够支持一个宽松的已定义的一致性模型(GFS无法做到字节级别的完全一致,只保证数据作为整体原子被至少写入一次,当出现写失败的时候,region处于一种不一致的状态,GFS可以通过Checksum以及应用上的过滤来处理少量的不一致数据)。其中租约和数据版本号的设计是其中的关键。

先来看一下GFS是怎么做的?图4.1展示了这个过程,总体上表示为7个步骤。我简单描述下整个过程。

1 客户端向Master节点询问哪一个ChunkServer持有租约,以及询问其他复本的位置。

2 Master将主Chunk信息以及其他复本的位置返回给客户端,客户端缓存这些信息以便后续的操作。

3 客户端将数据推向所有的复本上。客户端可以以任意的顺序推送数据,ChunkServer缓存这些数据。

4 当所有的复本都确认接受到数据之后,客户端向主ChunkServer发起写请求,并分配连续的序列号给这些操作,保证执行的顺序。

5 主Chunk将这些序列号的操作发给次Chunk并让它们执行这些操作。

6 次Chunk将执行的结果返回给主Chunk。

7 主Chunk将最终的结果返回给客户端。

图4.1 同步写的整个过程[2]

当Master和Chunk签订一个新的租约时,它就会增加Chunk的版本号并通知最新的复本,所有的复本Chunk会把新的版本号记录在它们持久化的状态信息中。并且这个过程是在Master告知客户端之前进行的。当ChunkServer在失效后重启时,会向Master报告它所拥有的Chunk以及版本号,当发现版本号落后时,Master便不会将租约分配给该Chunk而转向版本号更高的Chunk复本。Master在向客户端回复持有租约的Chunk信息时,会附带上版本号的信息,而客户端在执行操作的时候也会利用这个最新的版本号对Chunk进行验证。并且利用版本号Master可以在垃圾回收的时候移除所有的过期失效复本。

为了保证读的一致性以及考虑到TFS存在大量的随机写,TFS对所有的写操作采用的是同步写,并且不允许对同一个Block进行并发更新操作。与GFS不同的是,目前的租约是由客户端向NameServer申请并持有的,客户端只有持有租约时才能对该Block进行修改操作(实际上是通过租约对该数据进行加锁)。除此之外,租约的设计并没有考虑续租的情况。在后面的自定义文件目录的设计中,TFS为MetaServer重新设计了租约并使该租约拥有续租的功能。

由于在分布式存储系统中同一份数据往往存有多个拷贝,在考虑数据备份的时候,就需要考虑各个节点间的备份是同步还是异步。如果是异步方式,势必会存在一定的时间窗,在该时间窗内读数据有可能会读到未更新的数据,而如果是同步方式,那么写请求需要花费较长的时间甚至有可能不能完成(在主机不可达的情况下)。Dynamo和Cassandra将数据一致性的选择权交给用户,让他们根据应用的需求设计一致性模型,这就是有名的NWR模型。其中N代表N个备份,W代表至少写入W份才算写成功,R代表至少读取R个备份,并且在配置的过程中要求W+R>N,因为这样的配置可以保证读取的时候能读到更新过的数据。按照这个模型,GFS和TFS均是采用W=N,R=1的方式进行的。

当Dynamo和Cassandra采用NWR模型进行配置时,系统会产生一个版本号的问题。举个简单的例子,假设我们设置了N=3,W=1。现在x写入key1 值3,这个请求被节点A处理,生成了v1版本的数据。然后x用户又在版本v1上进行了一次key1值4的写操作,这个请求这次是节点C处理的。但是节点C还没有收到上一个A接收的版本(数据备份是异步进行的)如果按照正常情况,他应该拒绝这个请求,因为他不了解版本v1的信息。但是实际上是不可以拒绝的,因为如果C拒绝了写请求,实际上W=1这个配置,这个服务器向客户做出的承诺将被打破,从而使得系统的行为退化成W=N的形式。那么C接收了这个请求,就可能产生前面提到的不一致性。

对于数据的不一致性冲突,Dynamo的做法是保留所有这些版本号,用Lamport的Vector Clock的方式记录版本信息,如图4.2所示。Vector Clock的原理大致是这样的,每个节点都记录自己的版本信息,而一个数据,包含所有这些版本信息,也就是记录在该节点的数据版本更新过程,只有在判断过期的数据或者可以更新的时候才进行更新。而Cassandra则是采用客户端的Timestamp来进行冲突的解决。通过这种合并更新的方式,Dynamo和Cassandra实现了所谓的最终一致性。

图4.2 vector clock解决过程[5]

4.2 负载均衡和DHT

负载均衡是保证分布式系统可用性的前提。从简单意义上理解,好的分布式系统能够让不同的数据合理地进入分布式系统中的各个节点来分别进行处理,以高出单机系统数倍(视包含多少机器节点而言)的效率返回给客户端,而这一切对于客户来说却是透明的。

对于带有总控节点的分布式存储系统来说,当新的机器节点加入或者有节点退出系统时,总控节点总能与其他节点的通信获知相应的数据块(Chunk,Block或者Tablet)信息(一般来说通过心跳包的形式,或者在更新时及时汇报),以数据块为单位安排数据块的复制以及负载均衡的处理。以TFS为例,大致的过程就是根据集群内的Block的信息,计算总的Block数量和总的数据容量,得到平均Block的占用量;对集群内的DataServer做遍历,得出该DataServer的Block数量以及应得的Block数量,并以此判断是否将该DataServer作为源Server还是目的Server;判断Block的状态,将满足状态的Block从源Server移动到目的Server。

而对于没有总控节点的系统来说,我们需要利用DHT(DistributeHash Table)来对给定的key定位服务节点,如何使得当系统加入机器或者机器退出的时候保证负载均衡并且使得移动的数据尽可能地少,则是这类系统需要考虑的问题。比如当我们采用Hash的方法(简单来说对总的机器数量取模运算,然后根据取模的结果将访问进入对应的机器节点进行处理),那么当机器加入或者退出的时候,为了考虑负载的均衡就需要将Hash的方法进行改变,这样势必会导致大量的数据进行迁入迁出,影响系统的性能。而Consistent Hash则解决了这类问题并第一个成为成功运用DHT的算法。Dynamo和Cassandra也正是采用了这个算法。

图4.3 ConsistentHash

如图4.3所示,一致性哈希将输出区间看作首尾相接的一个环,每个点管理一个区间,key在hash之后沿着环的顺时针顺序找对应的服务节点。这样一来,服务节点的退出和加入就只会影响该点相邻区间的点了。但是简单的一致性哈希会存在哈希分布不均匀以及物理机器节点性能不能合理利用的问题,于是Dynamo采用虚节点(逻辑节点)的概念,让每个物理节点根据自己的性能分配1个或多个虚节点来管理数据,因为当系统内的虚节点个数远远超过物理节点个数的时候,我们可以大致认为系统是均衡的。而Cassandra则是采用允许负载轻的节点在环上移动的方法来达到负载均衡。另外,在Cassandra中,我们可以根据应用的需要采用随机分区(无法支持key的范围查询)和有序分区方式(可以支持key的范围查询),而有序分区方式中又有不同规则的分区方式(不同的排序标准)可以选择。

前面已经提到,Dynamo和Cassandra采用Gossip协议进行通信来获取集群中其他节点的状态。具体表现为,通过引入种子(Seed)节点,服务器定期向种子节点轮询整个机群的机器信息。假设每台机器只维护它的少量前驱和后继的信息,那么机器节点加入和删除的过程大致是这样的:

a当新的机器加入时,首先通过一次查找找到它的下一台机器,并记录下一台机器的信息。 b机器内的每台机器都向它的后继发送心跳信息,如果该后继的前驱机器编号在两者之间,则说明有新机器加入。(如果原来是3 <—>7,然后加入一台机器为5,那么5先找到7,并且使5—>7,然后当3发心跳信息给7时,它发现7的前一个机器编号在自己和7之间了,于是3这个机器需要将自己的后继变成这个新加入的机器5了。而对于7来说,当它的前驱节点5向它发送心跳信息时,那么它也要做出判断,如果是比它当前的前驱更接近自身的编号,那么就更新。)c当节点从中删除或者断开的时候,当发现该节点的后继不可达时,则用它的第二个后继替换。

4.3 复制和容错

为了节省成本,大部分分布式存储系统都是架设在普通的廉价服务器上的。因此,机器出现故障的机率是相当大的。对于整个系统的容错的设计和考虑则是必不可少的。对于GFS来说,快速恢复(Checkpoint和Log保证了Master的快速恢复)和复制给GFS的高可用性提供了保证。

TFS的容错包括集群容错,NameServer容错以及DataServer的容错。TFS一般会配置两个集群,一主一备放在不同的机房内。当主集群出现异常时,备集群会接管对外提供服务,从而保证了不会中断服务或者丢失数据。NameServer管理了Block和DataServer之间的关系,NameServer采用HA结构,一主一备互为热备,当主NameServer出现问题时,TFS会定时切换到备NameServer,DataServer会通过定时的心跳信息汇报给NameServer,备NameServer会根据这些信息重建DataServer和Block的关系。TFS采用Block存储多份的方式来实现对DataServer的容错。每一个Block一般会保存3份,存放在不同网段的不同DataServer上。当出现磁盘损坏DataServer宕机的时候,TFS启动复制流程,把备份数量未达到最小备份数的Block尽快复制到其他DataServer上去。TFS对每一个文件会记录校验CRC,当客户端发现CRC和文件内容不匹配时,会自动切换到一个好的Block上进行读取。此后客户端将会实现自动修复单个文件损坏的情况。至于选择哪些DataServer来进行Block的复制,则取决于Master在考虑负载均衡的前提下的选择算法了。

去中心化系统在选择复本节点时,往往会根据应用的配置需求来进行复本的安排。一般来说,如果系统需要存放K个复本,那么系统会在第N个节点下面的连续K个节点中存放这些复本数据。为了解决网络分区问题,需要保证这些机器节点属于不同的数据中心,系统需要合理地设计获取数据节点的Hash算法。Dynamo则是直接手工配置每台机器的编号来解决的,看似不可取不过很实用。而Cassandra则在复本策略上提供了几种可供选择的策略(包括是否考虑机柜因素,是否需要复制策略属性文件等)使得用户可以灵活配置,以应对网络分区问题,降低容灾的风险。

Dynamo将异常分为临时性的异常和永久性的异常。一般我们认为由于程序等问题的异常为临时性异常,而由于机器硬件等问题的异常为永久性异常,而区分的关键仅仅是时间T的选择。为避免麻烦,Dynamo将所有的异常都当作永久性异常进行对待。由于数据被存储了K份,一台机器下线将导致后续的K台机器出现数据不一致的情况。这是因为原本属于机器N的数据由于机器下线可能被临时写入机器N+1, ... N+K。如果机器N出现永久性异常,后续的K台机器都需要服务它的部分数据,这时它们都需要选择冗余机器中较为空闲的一台进行同步。同步的过程中Dynamo用到了Merkle Tree(即Hash Tree)。Merkle Tree同步的原理很简单,每个非叶子节点对应多个文件,为其所有子节点值组合后的Hash值,叶子节点对应单个数据文件,为文件内容的Hash值(在实际应用中我们往往会选择文件内容的修改时间作为Hash值并向上传播)。这样,任何一个数据文件不匹配都将导致从该文件对应的叶子节点到根节点的所有节点值不同。每台机器维护K棵Merkle Tree,机器同步时首先传输Merkle Tree信息,并且只需要同步从根到叶子的所有节点值均不相同的文件。

而在Cassandra中,当出现由于节点宕机而无法写入的情况时,Cassandra会采用Hinted Handoff(提示移交,将带额外信息的数据写入随机的另一个节点并在原来节点恢复后复制数据,能处理临时性的异常),当出现读数据不一致时,会根据读的配置级别进行read repair后返回结果(修复的过程中同样需要采用Merkle Tree技术),以上这些的措施能减少最终一致性所需要的时间窗。一般的节点停机在Cassandra中被认为是临时性的,这并不会导致指派分区的重新平衡和数据复本的修复。在数据同步的过程中,Cassandra考虑通过让多个最新副本来参与并行化引导传输,类似于Bittorrent技术来加快同步。

4.4 数据的存储以及读写删

Cassandra在数据存储的方式上借鉴了BT的数据模型,所以我们先来了解下BT对于数据的存储以及查找是如何实现的。从BT的论文中了解到,BT是以Tablet(大表中的多行数据)为单位进行分发的,每个Tablet大概为100-200MB,但是BT内部的存储又是以Google SSTable为单位存放的,每个SSTable块大概为64KB(具体可以配置),而每个块的最后通常包含数据的索引,在加载数据的时候,索引通常是被加载到内存中的。

在BT中,当我们如果需要找某一个单元的数据时,首先肯定需要知道某个Tablet的位置。BT采用的是三层的B+树存放Tablet的索引信息。第一层存储在Chubby文件中,该文件中包含了Root Tablet的位置信息。第二层,通过Root Tablet,可以找到一个特殊的MetaData表里所有的Tablets位置信息。事实上,Root Tablet是MetaData Tablets里面第一个MetaData,只是因为它不能被分割,所以保证了三层的存储结构。第三层中,在MetaData中,每个Tablet的位置信息都存放在一个行关键字下面,这个行关键字是由Tablet所在的表的标识符和Tablet的最后一行编码而成的。假设这个行关键字为1kB,大小容量为128MB的MetaData Tablet,按这种结构可以存储2^34个Tablet,而每个Tablet又是128MB来计算的话,一共可以存储2^61 byte,可以应付大规模的数据量了。

图4.4 Tablet的查找过程[4]

Cassandra以Column(name,value,timestamp)为基本单位,又通过Super Column以及Column Family的形式将数据组织起来(在我看来就像是表中有表的感觉)。如图4.5所示,Cassandra中的数据首先会根据key将表分成很多行,每个行同时会包含多个Column Family,而每个Column Family又会包含多个Super Column,每个Super Column包含多个Column,通过这样一层一层的索引定位才可以找到相应的Column数据。

图4.5 数据表示

因为Cassandra不依赖于任何存储组件,它是基于 Row (hash key) 来分布式分发至存储该行的节点上,对于该行中的每个 Column Family都会存在 SSTable 格式的文件,有可能是一个,也有可能是多个(因为会涉及到Compaction)。既然BT和Cassandra都提到了这个SSTable格式,那么我们就有必要去了解下这究竟是一个什么样的格式?

一般来说,SSTable会包含以下四个部分,一个是Data,用于存储所有的Column Family的信息,即包含的Column和Column Index信息,二是包含Row的基于Row Key的索引,三是用于对基于 Row Key索引访问的Row Key BloomFilter,四是统计文件用于统计一个 SSTable包含的RowCount,ColumnCount。一般情况下会以柱状图的形式出现。

和BT一样,Cassandra的写一般是先写入CommitLog中,写成功后再写入内存的MemTable,当MemTable的数据超过一定数量时,再持久化地写入SSTable。那么读的时候,Cassandra会综合考虑SSTable的数据和MemTable的数据一起反馈给客户的。

在分布式系统中,删除数据会存在这样的问题:客户端执行这样一个删除操作,并且还有一个复本没有收到删除的操作(并不是同步更新),那么这个复本依然可用并认为那些已经执行删除操作的节点丢失了复本并会去修复。所以一般来说,对于删除的数据,我们并不会直接删除它,而是给它们设一个删除标志并设置过期时间,当客户再一次读这个数据发现数据被标志删除标志并过期时,这个时候才会真正去删除这个数据。

5 总结

至此,本文的主要内容将论述完毕。本人认为,分布式存储系统其实是云计算的基础。而真正的云计算平台应该需要包含分布式文件系统(比如TFS,GFS等),以及分布式数据库系统(比如BT,Cassandra等),还有一个很重要的分布式计算系统(我所知道的仅仅有Google的MapReduce)。由于本来在该方面刚刚起步,主要是通过阅读一些论文和其他专家对于这些系统的分析再加上自己对此的稍稍理解而完成的(将来需要通过读代码和实际操作来加深对分布式存储系统的理解),价值并不是很大,如果能帮到一些和我一样在分布式存储系统刚起步的朋友们,将是我最大的欣慰。希望有更多的朋友能够和我交流,指出问题,热忱欢迎并不甚感激。

联系方式:hy05190134@gmail.com

参考文献

[1] http://rdc.taobao.com/blog/cs/?p=631

[2]《The Google File System》

[3] http://code.taobao.org/p/tfs/wiki/index/

[4]《Bigtable: A Distributed Storage System for Structured Data》

[5]《Dynamo: Amazon’s Highly Available Key-value Store》

[6]《Cassandra - A Decentralized Structured Storage System》