分布式系统

【google论文五】Chubby:面向松散耦合的分布式系统的锁服务

2011年5月24日 阅读(2,083)

作者:Mike Burrows Google Inc 2006

译者:phylips@bmy 2011-7-2

出处:http://duanple.blog.163.com/blog/static/7097176720112643946178/

 
摘要

本文描述了我们在Chubby锁服务系统上的相关经验。该系统旨在为松散耦合的分布式系统提供粗粒度的锁以及可靠性存储(低容量的)。Chubby提供了一个非常类似于具有建议性锁的分布式文件系统的接口,设计的侧重点在可用性及可靠性而不是高性能。该服务已有很多应用多年的运行实例,其中一些实例可能同时为成千上万个客户端提供服务。本论文描述了其最初的设计及期望的应用,并通过将其与实际应用情况进行对比,来说明设计应如何修正以容纳各种差异。

1. 导引

本文描述一个称为Chubby的锁服务。它被用在由通过高速网络连接的大量小型计算机组成的松耦合分布式系统中。比如一个Chubby实例(也称做一个Chubby 单元)可能服务于一个由1Gbit/s以太网连接的上万台4核计算机组成的集群。大多数的Chubby单元只是存在于一个计算中心或者一个机房,当然也会有些Chubby单元,它们的副本可能分布在相隔数千公里的地方。

 

提供锁服务的目的是为了允许客户端可以同步它们自己的行为或者在某些基本环境信息上达成一致。首要的设计目标包括可靠性,面对大量客户端集合时的可用性,以及易于理解的语义;而吞吐率和存储能力是一些次要的考虑因素。Chubby的客户端接口类似于一个支持整文件读写,具有建议性锁及事件(比如文件内容改变)通知机制的简单文件系统。{!建议锁(advisory lock),这种锁是用于协调工作的一种锁,系统只提供加锁及检测是否加锁的接口,系统本身不会参与锁的协调和控制,可能有用户不进行是否加锁的判断,就修改某项资源,这时系统是不会加以阻拦的。因此这种锁不能阻止用户对互斥资源的访问,只是提供给访问资源的用户们进行协调的一种手段,所以资源的访问控制是交给用户控制的。与此相对的则是强制锁(mandatory lock),此时,系统会参与锁的控制和协调,用户调用接口获得锁后,如果有用户不遵守锁的约定,系统会阻止这种行为}

 

我们希望Chubby可以帮助开发者处理他们系统中的粗粒度的同步问题,尤其是一类leader选举问题,即从一组等价的服务器集合中选择出一个领导者。比如,GFS使用Chubby锁来选出一个GFS master服务器,Bigtable通过如下几种方式使用Chubby:master选举,帮助master找到它控制的服务器集合,帮助客户端找到master。此外GFS和Bigtable都使用Chubby存储它们一部分的元数据,更进一步地说,实际上它们是使用Chubby作为它们的分布式数据结构的根节点。还有一些服务使用锁在多个服务器间划分任务(在比较粗的粒度上)。

 

在部署Chubby之前,Google的大多数分布式系统,使用一种自适应的方法来解决主从选举问题(如果可以进行一些重复性的工作而不会有什么害处),或者是需要人工干预(如果正确性至关重要)。对于前一种情况,Chubby可以节省一些计算能力,对于后一种情况,它可以让系统在出现错误时不再需要人的干预。

 

熟悉分布式计算的读者应该知道,在多个节点中的leader选举问题是分布式一致性问题的一个实例,而且应该意识到我们需要一个使用异步通信(该名词描述了大多数实际网络的行为,比如以太网和因特网,它们允许丢包,延时,以及乱序)方式的解决方案。异步一致性问题可以通过Paxos协议解决,Oki和Liskov在它们的论文(viewstamped replication)中提出了一个与之等价的协议。实际上,目前我们所看到的所有可工作的异步一致性协议都是以Paxos为核心。Paxos虽然不需要对时钟做任何假设就可以保证安全性,但是还是需要引入时钟以确保活性(即保证程序会成功结束);这就克服了Fisher等提出的FLP不可能性结论。

 

构建一个满足上面所提的各种需求的Chubby系统,本质上主要是一种工程性的努力,而算不上是新的研究成果,我们并没有提出新的算法或技术。本文的目的是描述下我们做了什么,以及为何那样做。在下面的章节里,我们会描述下Chubby的设计与实现,以及它的演变过程。我们会讲述一些Chubby令人意想不到的一些使用方式,以及一些已经证明是错误的特性。我们忽略了一些背景性的细节知识,比如一致性协议或者RPC系统的细节。

2. 设计
2.1原理

有人可能认为我们应该构建一个支持Paxos协议的库,而不是提供一个访问中央锁服务的库,即使该服务具有很高的可靠性。一个客户端Paxos库不会依赖于其他的服务端(除了命名服务器),而且也可以为程序员提供一个标准框架(如果他们的服务可以以状态机的形式实现)。实际上,我们也提供了这样的一个独立于Chubby的客户端库。

 

然而,与客户端库相比,锁服务的形式有如下一些优点:首先,开发者有时并不像我们想象的那样会考虑到程序的高可用性,通常他们的系统从一个只有很少负载及可用性保证的原型起步;代码也没有为使用一致性协议而进行一些特殊的结构化设计。伴随着服务的成熟及客户的增多,可用性变得越来越重要,replication及主从选举也会被加入到现有设计中。虽然这些可以通过一个提供分布式一致性的库来实现,但是锁服务器可以使得维护现有代码结构及通信模式更简单。比如,为了选举一个master并将选举结果写入一个文件服务器,只需要在现有系统中增加两个语句及一个RPC参数即可:一条语句负责获取锁变成master,同时传递一个额外的整数(锁的获取计数)给写RPC调用,然后为文件服务器添加一个if语句,如果该数小于当前值就拒绝该写请求{!获取锁计数的目的是为了防止之前的过期请求,首先master选择是一个持续的过程,也就是说该选举会进行多次,比如当第一次选举结束后,假设节点a被选为master,这样它就应该向文件服务器发起写RPC调用,但是该RPC调用可能因网络被延迟,这样可能会发起第二次选举,比如第二次b被选为了master,于是呢它也向文件服务器发起写RPC调用,在这个RPC调用结束之后,a的调用可能才到达,为了避免a的结果覆盖b的结果,通过锁的计数我们就能知道这个延迟到达的RPC调用不应被执行了}。我们发现这种方式要比为现有的服务器加入一致性协议要简单许多,尤其是在迁移期间还要求保留兼容性的时候。

 

第二,那些需要进行leader选择的服务,通常都需要一种机制将选举结果广而告之。这意味着我们需要允许客户端能够存储和获取少量数据—即需要读写小文件。这可以通过一个命名服务来解决,但是经验告诉我们锁服务本身很适合完成这种任务,这即减少了客户端需要依赖的服务器数,同时也让协议的一致性特性得到了共享。Chubby可以成功地作为一个命名服务器使用,主要归功于它使用了一个一致性的客户端缓存机制,而不是基于时间的缓存机制。尤其是,我们发现开发者很高兴看到不用再去确定一个类似于DNS ttl的一个缓存过期时间参数,而一个糟糕的设置可能导致高DNS负载或者过长的客户端故障恢复时间。

 

第三,基于锁的接口对于程序员来说更熟悉。具有多副本状态机的Paxos及带有互斥锁的临界区都可以为程序员提供串行编程的效果。但是,很多程序员以前就接触过锁,而且认为知道如何使用它们,然而讽刺的是,他们通常是错误的,尤其是当他们在分布式系统中使用锁的时候。很少有人会考虑机器失败对异步通信系统的锁带来的影响。虽然使用锁会阻碍程序员为分布式的决策使用可靠机制的思考,但是这种对于锁的熟悉性,还是战胜了这种副作用。

 

最后,分布式一致性算法使用quorums做决策,因此可以使用多个副本来达到高可用性。比如Chubby的一个单元通常具有5个副本,只要其中的3个正常,该单元就可以提供服务。与此同时,如果客户端系统使用锁服务,即使只有一个客户端可以获取锁就可以保证安全的往前推进。因此,锁服务降低了保证客户端系统可以正常进展的所需的服务器数目。更宽泛地说,可以认为锁服务提供了一种通用的选举机制,它允许客户端系统在其自身成员存活数小于半数时仍可以正确地做出决策{!如果采用客户端库,这样就不存在一个服务集合,而是依赖于客户端本身集合进行决策,这样在客户端系统存活数少于半数时,就无法进行决策了}。有人可能想,可以通过另一种方式解决这个问题:通过提供一个”一致性服务”,使用一组服务器组成Paxos协议中的acceptors集合。与锁服务类似,”一致性服务”也需要保证在即使只有一个客户端存活的情况下也能保证安全的向前推进;类似的技术已经被用来减少拜占庭容错情况下所需的状态机数目。然而,如果”一致性服务”不提供锁机制,那么它就没办法解决上面所提过的其他问题。

 

上面这些观点可以推出两个关键的设计决定:

l  我们选择了锁服务的形式,而不是客户端库或者”一致性服务”的形式

l  我们提供小文件来允许被选定的primaries来公布它们自身及一些参数,而不是再去创建和维护另一个服务

 

还有一些决定来自于我们所期望的应用及环境:

l  一个通过Chubby文件来发布其primary的服务可能有成千上万个客户端,因此,我们必须允许这些客户端能够查看这个文件,又不能需要太多的服务器。

l  客户端和一个具有备份机制的服务的副本们可能希望能够获知服务的primary的改变。这意味着需要使用一种事件通知机制避免轮询。

l  即使客户端不会周期性地去轮询文件,但是由于系统支持多个开发者也会导致文件的访问很频繁,因此文件的缓存是必要的。

l  开发者可能会被非直观的缓存语义搞糊涂,因此我们需要一致性缓存。

l  为了避免金钱上的损失及牢狱之灾,我们需要提供包括访问控制在内的一些安全机制。

 

一个可能让读者感到惊讶的选择是我们的锁并不适用于细粒度的应用场景,在该情况下,锁可能仅仅是在一个非常短的时间(秒级甚至更短)被持有,事实上它是为粗粒度的应用而诞生的。比如,一个应用可能使用锁来选举一个primary,该primary会在相当长的时间内处理所有的数据访问请求,可能是数小时或者数天。这两种不同风格的使用方式,对锁服务器提出了不同的需求。

 

粗粒度的锁带给锁服务器的负载很低。尤其是,锁的获取率与客户端应用程序的事务发生率通常只是弱相关的。粗粒度的锁很少产生获取需求,这样锁服务偶尔的不可用也很少会影响到客户端。另一方面,锁在客户端之间的传递可能会需要昂贵的恢复过程,这样我们就不希望锁服务器的故障恢复会导致锁的丢失。因此,最好在锁服务器出错时,能让粗粒度的锁仍然有效,这样做几乎没有什么开销,而且这样就允许由一些较低可用性的锁服务器就足以为很多客户端提供服务。

 

细粒度的锁会导致完全不同的结论。即使锁服务器在简短的时间内不可用,也可能导致很多客户端的失败。性能以及随意增加锁服务器的需求就需要着重考虑,因为锁服务器端的事务发生频率与客户端的事务发生频率完全相关联。当然它也有一些优点,比如由于不需要维护在锁服务器失败时的锁状态而可以降低锁的开销,而且由于锁的持有时间通常都很短,这样由频繁丢锁所造成的时间惩罚也不会那么严重了。(客户端的设计必须为网络分区发生期间的锁丢失做好应对,这样在锁服务器故障恢复过程中丢失的锁就不会引入新的恢复开销。–Clients must be prepared to lose locks during network partitions, so the loss of locks on lock server fail-over introduces no new recovery paths)。

 

Chubby只是提供粗粒度的锁。幸运的是,客户端根据自己的应用特点量身定制细粒度的锁是很简单的。一个应用可以将它的锁划分成多个组,然后使用Chubby的粗粒度锁服务为这些组分配一个应用级的锁服务器。为维护这些细粒度的锁只需要记录很少的状态,服务器只需要记录一个很少更新、非易失、严格递增的获取计数器。(Clients can learn of lost locks at unlock time)客户端在解锁的时候能够知道丢失的锁{?何意?},如果使用一个简单的定长租约机制,协议就能变得简单而有效。这种模式带来的最重要的好处是,客户端开发者现在可以自己提供那些支持它们自己的负载的应用级锁服务器,而且也简化了自己去实现一个一致性协议的复杂性。

2.2系统结构

Chubby有两个主要组件,它们之间通过RPC进行通信:一个服务器,一个客户端应用程序需要链接的库,如图1所示。客户端与服务端之间的所有通信都需要通过客户端库。还有一个可选组件:代理服务器,将在3.1节讨论。

 

【google论文五】Chubby:面向松散耦合的分布式系统的锁服务 - 星星 - 银河里的星星

 

一个Chubby单元由被称为副本的服务器(通常是5个)集合组成,同时采用特殊的放置策略以尽量降低关联失败的可能性(比如它们通常在不同的机柜中)。这些副本使用分布式一致性协议来选择一个master;master必须获得来自副本集合中的半数以上的选票,同时需要保证这些副本在给定的一段时间内(即master的租约有效期间内)不会再选举出另一个master。Master的租约会被周期性地更新只要它能够持续获得半数以上的选票。

 

每个副本维护一个简单数据库的一个拷贝,但是只有master会读写该数据库,所有其他的副本只是简单地复制master通过一致性协议传送的更新。

 

客户端通过向DNS中列出的各副本发送master定位请求来找到master。非master副本通过返回master标识符来响应这种请求。一旦客户端定位到master,它就会将自己的所有请求直接发送给master,直到要么它停止响应,要么它不再是master。写请求会通过一致性协议传送给所有副本,当写请求被一个Chubby单元中半数以上的副本收到后,就可以认为已成功完成。读请求只能通过master处理,只要master租约还未到期这就是安全的,因为此时不可能有其他master存在。如果一个master出错后,其他的副本就可以在它们的master租约过期后运行选举协议,通常几秒钟后就能选举出一个新的master。比如最近的两次选举花了6s和4s,当然我们也曾看到过这个时间有时会高达30s。

 

如果副本出错而且几个小时内都无法恢复,一个简单的替换系统会从一个空闲机器池内选择一个新机器来,然后在它上面运行锁服务器的二进制文件。然后它会更新DNS表,将出错的那台机器对应的IP地址更新为新的。当前的master会周期性地去检查DNS表,最终会发现该变化。然后它就会更新它所在的Chubby单元的成员列表,该列表通过普通的复制协议来维持在多个副本间的一致性。与此同时,这个新的副本会从存储在文件服务器上一组备份中选择一个数据库的最近的拷贝,同时从活动的那些副本中获取更新。一旦该新副本已经处理过当前master正在等待提交的请求后,该副本就被允许在新master的选举中投票了。

2.3 文件、目录和句柄

Chubby提供了一个类似于UNIX但是相对简单的文件系统接口。它由一系列文件和目录所组成的严格树状结构组成,不同的名字单元之间通过反斜杠分割。一个典型的名称如下:

/ls/foo/wombat/pouch

对于所有的Chubby单元来说,都有一个相同的前缀ls(lockservice)。第二个名字单元(foo)代表了Chubby单元的名称;通过DNS,它会被解析成一个或多个Chubby服务器。一个特殊的单元名称local,用于指定使用客户端本地的那个Chubby单元;通常来说这个本地单元都与客户端处于同一栋建筑里,因此也是最可能被访问的那个。剩下的名字单元/ wombat/pouch,将会由指定的那个Chubby单元自己进行解析。与UNIX类似,每个目录由一系列的子文件和目录组成,每个文件包含一系列字节串。 

 

因为Chubby的名字空间结构类似于文件系统,这样就使得可以为应用提供我们自己特定的API,也可以使用我们的其他文件系统的接口,比如GFS。这就显著降低了我们为编写名字空间浏览及操纵工具所需要付出的努力,同时也降低了培训Chubby用户的难度。

 

与UNIX不同的是,该设计旨在简化分布式。为允许不同目录下的文件由不同的Chubby master负责,我们禁止了文件和目录的mv操作,不再维护目录修改时间,同时避免了那些依赖于路径的权限语义(即文件访问是由文件本身的访问权限控制的,与其父目录无关)。为了更容易缓存文件元数据,系统也不再提供最后访问时间。

 

名字空间由文件和目录组成,统称为node。每个node在一个Chubby单元中只有一个名称与之关联;不存在符号连接或者硬连接。node要么是永久性的要么是临时的。所有的node都可以被显示地删除,但是临时节点在没有client端打开它们(对于目录来说,则是为空)的时候也会被删除。临时节点可以被用作中间文件,或者作为client是否存活的指示器。任何节点都可以作为建议性的读/写锁;关于锁的进一步的细节参考2.4节。

 

每个节点都包含一些元数据。包括三个访问控制列表(ACLs),用于控制读、写操作及修改节点的访问控制列表(ACL)。除非显式覆盖,否则节点在创建时会继承它父目录的访问控制列表。访问控制列表(ACLs)本身单独存放在一个特定的ACL目录下,该目录是Chubby单元本地名字空间的一部分。这些ACL文件由一些名字组成的简单列表构成,说到这里,读者可能会联想到Plan 9的groups。因此,如果文件F的写操作对应的ACL文件名称是foo,那么ACL目录下就会有一个文件foo,同时如果该文件内包含一个值bar,那就意味着允许用户bar 写文件F。用户通过一种内建于RPC系统的机制进行权限认证。Chubby的ACLs就是简单的文件,因此对于其他想使用类似的访问控制机制的服务可以直接使用它们。

 

每个节点的元数据还包含4个严格递增的64位数字,通过它们客户端可以很方便的检测出变化:

l  一个实例编号;它的值大于该节点之前的任何实例编号

l  一个内容世代号(只有文件才有);当文件内容改变时,它的值也随之增加

l  一个锁世代号;当节点的锁从free变为hold时,它的值会增加

l  一个ACL世代号;当节点的ACL名字列表被修改时,它的值会增加

 

Chubby也提供一个64位文件内容校验和,这样client就可以判断文件内容是否改变了。

 

Client通过open一个节点获取handle(类似于UNIX的文件描述符)。handle会包括如下一些东西:

l  检查位,用于防止client端伪造或者猜测handle,这样完整的访问控制检查只需要在handle被创建出来的时候执行即可(与UNIX相比,它只是在open的时候检查权限位,而不是每次读写都去检查,因为文件描述符是不可伪造的){!handle的创建与open是两个概念?根据2.6节可以知道句柄只会在open时创建。是说UNIX还是说Chubby每次读写都需要进行权限检查?应该是说chubby不需要每次读写都检查权限,因为有了检查位,它只需要检查这个检查位即可。同时参考linux的read和write实现,在vfs_read中可以看到,每次调用它都会做如下检查if (!(file->;f_mode & FMODE_READ)),而在vfs_write里则有if (!(file->;f_mode & FMODE_WRITE)),此外二者还都会调用security_file_permission ()做进一步的检查}

l  一个序列号,使得master可以判断该handle是由它还是之前的那个master生成的。

l  在open时所提供的模式信息,允许重启后的master可以重建一个被转移给它的旧handle的状态。

2.4 锁和序列号

每个Chubby文件和目录都可以作为一个读者-写者锁:要么是一个client以独占(writer)模式持有它,要么是任意数量的client以共享(reader)模式持有它。类似于我们所熟知的mutex,锁是建议性的。也就是说,当多个client同时尝试获得相同的锁时,它们会产生冲突:但是持有F的锁,既不是访问文件F的必要条件,也不能阻止其他client的访问。我们没有使用强制性锁(它会使得对于那些没有拿到锁的client无法访问被锁住的对象):

l  Chubby锁经常被用来保护其他服务的资源,而不仅是与锁关联的那个文件。要让强制性的锁真正有意义,还需要我们对这些服务本身做更多的修改才行。

l  我们不希望当用户因debug或者管理的需要而访问那些被锁住的文件时,必须要关闭应用程序才行。在一个复杂系统中,很难采用那种在pc上经常使用的策略,比如在pc上,系统管理软件可以简单地通过让用户关闭或者重启应用程序,就可以打破强制性锁。

l  我们的开发者通过很方便的方式就可以进行一些错误检查,比如通过写出一个诸如”lock X is held”的断言,因此他们从强制性锁中很少获益。而那些没有获得锁的恶意进程,有很多方式和机会去破坏数据,因此由强制性锁所提供的这种额外保证意义就更微弱了。

 

在Chubby里,获取任何模式的锁都需要写权限,因此一个无权限的读者无法阻止一个写者的操作{!为何reader无法阻止一个writer?首选因为锁是建议性的,因此一个读者可以不申请锁就去读,这样它就根本无法阻止其他人同时去写,即便是其他人在操作时首先尝试去获取锁,但是因为读者没有持有锁,这样其他访问者根本不知道有人在读。其中的因果关系在于,因为如果读者无权限,那么就无法获取锁,无法获取锁也就无法影响写者的操作}

 

在分布式系统中,锁是很复杂的,因为通信通常是不确定的,进程可能会fail,而相互之间却毫无所知。比如,一个持有锁L的进程可能发出一个请求R,然后fail了。另一个进程可能获得了锁L,同时在R到达目的地之前执行了一些操作。之后,R又到达了,这样它就可能在没有锁L的保护下进行一些操作,这样就可能产生一些不一致的数据。关于消息的乱序到达问题已经被研究了很多了;一些解决方案比如virtual time,virtual synchrony,通过保证消息以一个所有参与者一致的视图顺序下进行处理来避免这个问题。

 

在一个现有的复杂系统的所有交互中引入序列号会产生很大的开销。因此,Chubby提供了一种方式,使得只是在那些涉及到锁的交互中才需要引入序列号。锁的持有者可能在任意时刻去请求一个sequencer,它是一系列用于描述锁获取后的状态的不透明字节串。包含了锁的名称,占有模式(互斥或共享)以及锁的世代编号。如果client期望某个操作可以通过锁进行保护,它就将该sequencer传送给server(比如文件服务器)。接收端server需要检查该sequencer是否仍然合法及是否具有恰当的模式;如果不满足,它就拒绝该请求。{!那么到底是客户端是锁持有者还是服务器是持有者,保护的又是什么呢?client端是锁的持有者,它能够随时去得到一个sequencer(sequencer实际上对锁状态的一种描述),它希望这个锁可以保护它针对服务端进行的一个操作,因为可能有多个client去进行这个操作,这样呢为了避免上面提到的锁的乱序到达问题,因此必须提供一个机制避免这种情况,而sequencer就是为了解决这个问题,通过sequencer就可以知道一个锁是否依然有效,是否是一个过期的锁}sequencer的有效性可以通过与server的Chubby缓存进行验证,如果server不想维护一个与Chubby的会话,也可以与它最近观察到的那个sequencer对比。sequencer机制只需要给受影响的消息添加上一个附加的字符串,很容易解释给开发者。

 

尽管sequencer很容易使用,重要的协议也很少发生变化。但是仍存在一些不支持sequencer的servers{!sequencer机制应该是后来加入到Chubby系统中的,这样呢就可能存在一些老的servers不支持这种机制,而且由于成本或者维护者的原因,即使Chubby系统已经具有了sequencer的支持,因为这些servers无法更新,因此也就无法使用它,但是Chubby系统不能忽略这种情况,而是要提供一种不需要servers参与的机制来降低风险,即后面提到的lock-delay},Chubby为它们提供了一套不完美但是相对简单的机制来降低延时及请求的re-order带来的风险。如果client以正常的方式释放了一把锁,正如期望的那样,对于其他客户端来说它就是立即可用的了。然而如果一把锁变为free状态是因为持有者failed或者不可访问了,那么锁服务器必须在一个被称为lock-delay的给定期限内禁止其他client获取它。client可以设定一个任意上界的lock-delay,当前默认是一分钟;该限制可以避免一个故障的client无限期地占有一把锁。虽然并不完美,但是lock-delay策略还是使得那些未升级的servers和clients免受因日常的消息延迟和重启导致的影响。

2.5 事件

Chubby client在它们创建句柄时可能订阅一系列的事件。这些事件通过来自Chubby库的后续调用异步地传输到客户端。事件包括:

l  文件内容改变—通常用于监控通过文件公布的某个服务的位置信息。

l  子节点的添加,删除或者修改—用于实现Mirroring(2.12节)。(除了允许发现新加入的节点,向客户端返回事件也使得监控临时性的文件而不影响它们的引用计数成为可能)

l  Chubby master故障恢复—提醒client某些事件可能已丢失,因此数据必须重新扫描。

l  句柄(或者是它的锁)失效—这通常意味着一个通信问题。

l  锁的获取—可以用来确定主本(primary)何时被选举出来。

l  来自于另一个客户端的冲突的锁请求—允许锁的缓存。

 

当相应的动作发生之后,事件才会被传递。因此如果一个客户端被通知文件内容已经改变,那么它之后去读取这个文件能够保证它一定能看到新的数据(或者是比事件发生时还要新的数据)。

 

最后两个事件很少被使用,现在看来,实际上应该去掉它们。比如对于primary选举,client端通常需要与选出来的primary进行通信,而不是简单地知道primary存在与否就可以了;因此它们可以等待新的primary将地址写入文件所导致的一个文件内容改变事件即可。锁冲突事件理论上允许clients缓存其他servers持有的数据,通过Chubby锁来维护缓存一致性。当一个锁冲突事件发生时,实际就是告诉client结束使用与该锁相关的数据:它会结束那些等待中的操作,将修改刷新到原来的位置(home location){!实际上就是使用这种Chubby的锁冲突机制来实现缓存的一致性。应用场景就是这些客户端缓存来自服务端的数据,因此home location 实际上就是数据在server端的存放位置}。目前为止,还没有人这样使用。

2.6 API

对于客户端来说,Chubble句柄是一个支持各种操作的不透明结构。句柄只能通过open()操作创建,通过close()操作关闭。

 

Open()通过打开一个命名文件或目录来创建一个类似于Unix文件描述符的句柄。只有这个操作会使用节点名称,其他直接在句柄上进行操作。

 

节点名称通过相对于一个现有的目录句柄计算出来;库提供了一个一直有效的在”/”上的句柄。目录句柄避免了,因使用一个程序内部级别的当前目录,在包含多种抽象层次的多线程编程中的困难。{!正如我们所知,在linux中用户只需要使用文件名即可open一个文件,这就隐含了一个当前路径的概念,但是在Chubby中没有这种概念,名称都是相对于同一个根目录路径,库默认使用”/”。}

 

在调用open时,client可以用多种选项:

l  句柄如何使用(读,写以及锁;修改ACL);只有当客户端具有相对应的所需权限时句柄才会创建出来

l  需要传递的事件(参见2.5)

l  Lock-delay(参见2.4)

l  应该(或者必须)创建一个新的文件还是目录。如果创建的是文件,调用者必须提供初始内容及初始ACL列表。返回值表示文件是否创建成功。

 

Close()操作关闭打开的句柄。这样关于该句柄的后续使用就是不允许的了。该调用永远不会失败。一个与之相关的调用Poison(),能够不关闭句柄而使得后续的调用失败;这就允许client取消由某些线程Chubby调用,而不用担心会释放它们所访问的那些内存。

 

在一个句柄上的主要函数调用如下:

GetContentsAndStat()返回文件的内容和元数据。文件内容的读取是原子性的,同时必须整个读取。我们没有提供文件的部分读取和写入功能,以尽量防止生成大文件。一个相关的调用GetStat()仅仅返回元数据,而ReadDir()则会返回一个目录的子节点的名称及元数据。

 

SetContents()修改文件内容。可选择地,用户可以提供一个内容世代编号作为参数,以允许客户端模拟在文件上的compare-and-swap操作;只有当该编号等于当前值时内容才改变。文件内容的写入也是原子性的,同时也是整个写入地。一个相关的调用SetACL()可以针对节点上的ACL列表执行与之类似的操作。

 

Delete()删除没有子节点的节点。

 

Acquire(),TryAcquire(),Release()用于申请和释放锁。

 

GetSequencer()返回一个用于描述该句柄持有的锁的状态的sequencer。

 

SetSequencer()将一个sequencer与一个句柄关联。如果sequencer已经失效,那么在该句柄上的后续操作将会失败。

 

CheckSequencer()检查一个sequencer是否有效(见2.4节)。

 

如果在句柄创建之后节点被删除了,那么在句柄上的调用会失败,即使是该节点在后面又被重新创建出来。也就是说,句柄是与文件实例相关联的,而不是与文件名称。Chubby可以将访问控制应用到所有的调用上,但是只是在open调用中进行检查(见2.3节)。

 

上面的所有调用为满足调用本身的其他需要都可以使用一个operation参数。该参数可以用来持有数据以及与每次调用相关联的控制信息。尤其是通过该参数,client可以:

l  支持使得调用可以异步化的回调函数

l  等待调用的结束,同时/或者

l  获得额外的错误及诊断信息

 

Client可以使用这些API按照如下方式进行主本(primary)选举:所有的潜在主本打开锁文件,并尝试获取锁。最后只有一个会成功变成primary,其他的就作为副本{!当多个客户端请求成为primary时,如何保证最终只有一个会成功呢?因为消息的延迟,某些请求可能会滞后。如何解决这种问题?}。该primary将它的标识信息通过SetContents()写入到锁文件,这样客户端和其他副本就能通过GetContentsAndStat()获取到该信息了,可能是在一个文件改变事件(2.5节)的响应函数中。理想情况下,该primary通过GetSequencer()获取一个sequencer,然后将它传给它所通信的servers;这些servers通过CheckSequencer()来验证它是否仍然是primary。对于那些不能检查sequencer的servers则可能需要使用lock-delay(见2.4节)。

2.7 缓存

为了减少读的流量,Chubby客户端会在一个一致性的,写直达的内存缓存中缓存文件数据及节点元数据(包括文件缺失信息(file absence))。该缓存通过使用如下的租约机制来维护,通过master的失效通知来保证一致性,master会维护一个用于描述客户端缓存内容的列表。该协议保证客户端要么看到Chubby状态的一个一致性视图要么看到的是一个错误。

 

当文件数据或者元数据需要改变时,修改将会被阻塞到直到master已经向缓存了该数据的所有客户端发送了失效通知之后;该机制建立在下一节所描述的KeepAlive RPC基础上。收到一个失效通知后,客户端会刷新失效状态并在下一次KeepAlive调用中通知master。当master已经确认每个客户端的缓存都已失效后,才会执行该修改操作,要么是因为客户端成功的返回了失效响应,要么是等待客户端缓存租约过期。

 

只需要一轮的失效通知,因为master会将那些对缓存失效状态还无法确认的node(这个是指Chubby里的node,而不是网络节点)看做是不可缓存的。这种策略允许读操作总是无延时的得到处理,这点非常有用,因为读操作要远远多于写。另外一种选择是:在失效通知期间阻塞所有访问该node的调用,这可以避免在失效通知期间过度急切的客户端对master造成轰炸式的访问,但是代价是增加了延迟。如果这会成为问题,可以尝试采用一种混合模式,在检测到过载时切换处理方式。

 

缓存协议很简单:在变更发生时首先使缓存数据无效,同时永不再更新它。更新它而不是使它失效可能一样简单,但是单纯的更新协议可能令人意外的低效;访问文件的客户端可能无限制地收到更新,这可能导致无数的不必要的更新{!因为没有人去访问它,因此这种更新实际上就是不必要的}。

 

尽管提供严格的一致性带来了额外的开销,但我们依然没有采用弱一致性,因为我们感到程序员会觉得它们更难使用。类似的,诸如虚同步(virtual synchrony)这样需要客户端在所有的消息中交换sequence number的机制, 在一个已经具有各种不同的现存协议的环境中也是不适合的。

 

除了缓存数据和元数据,Chubby客户端还会缓存打开的句柄。因此,如果一个客户端打开了一个它之前已经打开的文件,只有第一次的open()调用会引起一个到master的RPC。缓存机制在某些方面进行了一些限制以保证不会影响客户端锁观察到的语义:临时文件的句柄在应用程序关闭它们之后就不能保持open;那些允许锁定的句柄(handles that permit locking)可以被重用,但是不能被多个应用程序句柄并发使用。最后一个限制存在的原因是,因为客户端为取消发送给master的Acquire()调用可能使用Close()或者Poison()操作。

 

Chubby的协议允许客户端缓存锁—这样锁的持有时间会比客户端所必需的期望持有时间要长。如果另一个客户端请求了一个冲突的锁,会产生一个事件通知给锁的持有者,这就允许持有者可以在需要的时候释放锁。

2.8 会话与KeepAlives

一个Chubby会话是在Chubby cell和Chubby客户端之间的一种关系,它存在于某个时间间隔内,通过周期性的称为KeepAlives的握手维护。除非Chubby客户端通知master,否则只要会话依然有效,那么客户端的句柄,锁及缓存数据就都是有效的。(然而会话维护协议为维护会话可能要求客户端回复一个缓存失效通知,如下)

 

在第一次与Chubby单元的master联系时,客户端会请求一个新的会话。在它正常结束或者会话空闲(在一分钟内没有打开的句柄或者相关调用)时,它会显式地结束该会话。

 

每个会话都有一个与之相关的租约—一个延伸向未来的时间间隔,在这个期间内master保证不单方面的结束会话。该期间的结束阶段被称为会话租约过期。Master可以自由的将过期时间向未来延迟,但是它不能将它在时间上前移。

 

Master有如下三种时机进行续约:会话创建时,master发生故障恢复时,当他

响应来自客户端的KeepAlive RPC调用时。在收到一个KeepAlive RPC时,master通常会阻塞该RPC(不允许它返回)到该client的前一个租约接近过期。然后master允许该RPC返回给客户端,同时告知客户端新的租约过期时间。Master可以任意的延长过期时间。默认的延长时间是12s,但是一个过载的master可能使用一个更高的值来减少它所需要处理的KeepAlives RPC调用。收到上一个KeepAlives调用的回复后,客户端会初始化一个新的KeepAlives调用。因此客户端可以保证通常只有一个KeepAlives调用阻塞在master端。

 

除了扩展客户端的租约外,KeepAlives调用的回复还会被用于传递事件及缓存失效通知给客户端。当有一个事件或者缓存失效通知需要传递时,master允许该KeepAlives调用尽早返回。在KeepAlives的回复上捎带事件信息,保证了客户端不响应缓存过期通知就不能维护一个会话,这也使得所有的RPC调用都是从客户端流向master。这简化了客户端设计,同时也允许协议运行可以穿越那些只允许单向发起连接的防火墙。

 

客户端维护了一个本地租约过期时间,它要比服务端眼中的租约过期时间相对保守{!理解这一块具体可以参考论文<<Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency>>}。之所以不同于服务端的租约过期时间,是因为客户端必须在两方面做保守的假设:一是KeepAlive响应消息的传输时间,一是master时钟的超前度;为了维护一致性,我们要求master比client的时钟超前度必须在一定的常数因子之下。

 

如果客户端的本地缓存过期了,此时它就无法确定master是否已经结束了该会话。客户端就需要清空并禁用它的缓存,此时我们认为该会话处于危险(jeopardy)状态。客户端会继续等待一个称为宽限期(grace period)的时间区间,默认是45秒。如果在grace period结束之前,客户端和master又完成了一次成功的KeepAlive交互,那么客户端就会再次使它的缓存有效。否则,客户端就假设会话已过期。这样做就使得当Chubby单元不可访问时,Chubby的API调用不会无限期的阻塞;在grace period结束,通信重新建立之前,这些调用会返回一个错误。

 

当grace period开始时,Chubby库可以通过一个jeopardy事件通知应用程序。当已经获知会话发生了通信问题时,会产生一个safe事件来通知客户端去处理。这些信息使得应用程序在无法确认会话状态时可以保持静默,而且如果这个问题只是一个瞬时性的,那么客户端不需要重启就可以恢复。这对于很多启动开销很大的服务避免服务的中断是很重要的。

 

如果客户端持有一个在某个节点上的句柄H,同时因为关联会话的过期导致任何在H上的操作都失败了,那么所有的在H上的后续操作(除Close()和Poison()之外)也都会以同样的方式失败。客户端可以通过这一点来保证网络和servers的中断之后导致一系列的后续操作的丢失,而非任意可能的子序列,因此这就允许在某些复杂的操作的最后将其标记为committed状态。

2.9 故障恢复

当一个master失败了或者失去了master身份时,它会丢掉它的关于会话,句柄及锁的所有内存状态。权威的会话租约计时器开始在master上运行{!如果master所在的机器挂了,如何运行它呢?实际上着可以看做是一种虚拟的租约计时器,因为master挂了,因此它不可能在接受任何修改操作,这样呢之前发出去的租约就可以认为是仍然有效的,因此这些租约是可以扩展到直到另一个master恢复的。虽然旧的master死了,新的master还未选出来,但是逻辑上我们可以认为有一个master存在,只是它无法支持对它的状态进行修改操作。},直到一个新的master出来,这个会话租约计时器才会停止;这是合法的,因为这种方式等价于扩展客户端租约。如果一个master选举很快完成,那么客户端就可以在他们的本地(近似的)租约计时器过期之前联系新的master。如果选举花了很长时间,在尝试寻找新master的同时客户端会刷新它们的缓存及等待进入grace period。因此grace period使得会话可以超越正常的租约过期时间而能够在故障恢复期间仍能得以维护。

 

【google论文五】Chubby:面向松散耦合的分布式系统的锁服务 - 星星 - 银河里的星星

 

图2展示了一个漫长的master故障恢复中的一系列事件序列,在这个过程中客户端必须通过宽限期来保留它的会话。时间从左到右依次递增,但是时间没有按照比例画出{!即上图中各个阶段左右距离的跨度并不代表真实的时间长度}。客户端的会话租约用粗箭头表示,既有新老master眼中的租期 (上方的M1-3)也有客户端眼中的租期(下方的C1-3)。向上倾斜的箭头代表KeepAlive请求,向下倾斜的箭头代表针对它们的响应。原始的master具有客户端的会话租约M1,与此同时客户端具有一个保守的估计C1。在通过KeepAlive应答2通知客户端之前,master允诺了一个新的租期M2;之后客户端将它的租期延至C2。在响应下一个KeepAlive请求之前,master挂了,在另一个master选举出之前中间需经历一段时间。最后,客户端的租期C2到期了。之后,客户端刷新缓存,开始启动一个针对grace period的计时器。

 

在此期间,客户端无法确认它的租约是否已经在master端过期。它并不关闭这个会话,而是阻塞所有的应用程序调用以防止它们看到不一致的数据。在grace period开始时,Chubby库向应用程序发送一个jeopardy事件,以使得应用程序在能够确认会话状态之前保持静默。

 

最终一个新的master成功的选举出来。一开始该master使用一个对于它的前任对客户端的租约期限的保守估计M3。新master收到的第一个来自客户端的KeepAlive请求(4)会被拒绝,因为它具有错误的master epoch编号(细节稍后描述)。重试请求(6)会成功,但是通常不会扩展master的租约期限,因为M3是一个保守值{!为何是保守值就不去扩展它呢?}。然而响应(7)允许客户端再一次扩展它的租约(C3),同时可以选择通知应用程序它的会话已经不再处于危险期(jeopardy)。因为grace period长的足以跨越C2的结束及C3的开始这段时间,对于客户端除延迟之外其他的都是透明的。假若grace period小于这个时间段,那么客户端会直接丢弃该会话并向应用程序报告错误。

 

一旦一个客户端联系上新的master,客户端库就会和master相互协作提供给应用程序没有故障发生的假象。为了实现这个,新的master就必须重建它的前任master内存状态的一个保守近似。一部分通过读取保存在硬盘上的数据(会通过普通的数据库备份协议来进行备份)来完成,一部分通过从客户端获取状态,一部分再通过保守的估计来完成。数据库会记录每个会话,持有的锁及临时文件。

 

新的选举出的master执行如下步骤:

1.      它首先选择一个新的epoch number,在每次调用中客户端都需要出示该编号。Master会拒绝那些使用老的epoch number的客户端调用,同时会提供出新的编号。这就保证了一个新的master不会响应原本发送给旧master的非常老的包,即使是新老master运行在同一台机器上。

2.      新的master响应master定位请求,但是最开始它并不处理收到的会话相关操作。

3.      为那些记录在内存和数据库中的会话和锁建立内存数据结构。会话租期被延至前一个master所曾经使用的最大值。

4.      Master开始让客户端执行KeepAlive,但是仍不允许其他的会话相关操作。

5.      为每个会话产生一个故障恢复事件,这会导致客户端刷新它们的缓存(因为它们可能曾经丢失了一些缓存失效通知),同时警告客户端某些事件可能已经丢失。

6.      Master等待每个会话已经对这个故障恢复事件做出响应,或者它的会话过期。

7.      Master开始允许各种操作的处理。

8.      如果客户端使用一个先于故障恢复点创建的句柄(通过句柄中的某个编号值可以判断出来),master会在内存中创建该句柄,并执行该调用。如果这样创建出的句柄被关闭了,那么master会在内存中记录下来,保证它不会在这个master存活期间被再次创建;这就保证了一个延迟或者重复的网络包不能意外地创建出一个已经关闭的句柄。一个失败的客户端可以在未来的世代中重新创建一个已经关闭的句柄,但是因为该客户端已经出错失败了因此这样是没有问题的。

9.      一段时间之后(比如一分钟),master删除那些没有打开的句柄的临时文件。在故障恢复之后的这个时间段内,客户端应该刷新它们在临时节点上的句柄。这种机制有一个问题,如果临时文件的最后一个客户端在故障恢复期间丢失了会话的话,那么它们可能不会及时的消失。

 

可能令读者吃惊的是,故障恢复代码虽然远比系统其他部分代码的执行机会要少,但是却是很多有趣bug的丰富来源。

2.10 数据库实现

第一版的Chubby使用带复制的Berkeley DB作为它的数据库。Berkeley DB提供一个用于将字节串映射到任意字节串的B树。我们提供一个key的比较函数,该函数会首先根据路径名称里的单元数进行排序;这就允许节点以它们的路径名称作为key,同时保证了排序后兄弟节点是相邻的。因为Chubby没有使用基于路径的权限,每个文件访问只需要在数据库中进行一次查找即可。

 

Berkeley DB使用一个分布式一致性协议来将它的数据库日志复制(replicate)到一个服务器集合上。只要再加上master租约,就跟Chubby的设计匹配了,这就使得我们的实现变得很直接。

 

虽然Berkeley DB的B树相关的代码已经被广泛使用而且很成熟了,但是复制(replication)相关的代码却是最近才加入的,而且很少有用户。软件维护者肯定优先维护改进那些最流行的产品特性。在Berkeley DB的维护者解决我们遇到的问题期间,我们感到使用其与复制(replication)相关的代码所承担的风险超过了我们的预期。最终,我们使用日志预写(write ahead log)和快照(snapshotting)实现了一个类似于Birrell的简单数据库。与之前相同,数据库日志通过一个分布式一致性协议在不同副本间分发。Chubby只使用了Berkeley DB很少的一些feature,因此这个改写大大简化了我们的系统;比如我们可能需要原子操作,但是我们并不需要通用的事务。

2.11 备份

每隔几个小时,每个Chubby单元的master就将它的数据库的一个快照写入到另一栋楼里的GFS文件服务器。让它们处在不同的楼里可以保证该备份可以在楼宇毁坏时仍得以保存,同时也避免了在系统中引入循环依赖;在同一栋楼里的GFS单元潜在的依赖于Chubby单元选择它的master。

 

备份既提供了灾难恢复,又提供了一种方式初始化那些新的被替换的副本而不会对服务中的那些副本带来负载。

2.12 Mirroring

Chubby允许将一组文件集合从一个单元镜像到另一个单元。Mirroring速度很快,因为文件很小而且事件机制会在文件加入,删除或修改时立即通知镜像处理代码。在不存在网络问题的情况下,变更可以在1秒内反映到世界范围内的多个镜像中。如果某个镜像是不可到达的,它会保持不变直到连接恢复。通过比较校验和就可以识别出发生更新的文件。

 

镜像机制通常被用来向分布在世界各地的计算集群拷贝配置文件。一个特殊的名为global的Chubby单元,包含一个子树/ls/global/master,该子树会被镜像到所有其他的Chubby单元的子树/ls/cell/slave中。该名为global的Chubby单元非常特殊,因为它的5个副本是分散在世界上相聚遥远的地方,因此大部分的国家和地区几乎都可以访问它。

 

在从名为global的Chubby单元镜像的那些文件,有Chubby自己的访问控制列表,有Chubby单元和其他系统用于向监控服务广播它们的存在的文件,有允许客户端定位大数据集合比如Bigtable单元的指针,以及很多其他系统的配置文件。

3. 扩展性机制

Chubby的客户端是独立的进程,因此Chubby必须处理比人们想象的还要多的客户端。我们曾观察到90000个客户端直接与Chubby master通信—这远远大于相关的机器数。因为一个Chubby单元只有一个master,而且它的机器配置与这些客户端一样,于是客户端能以巨大的优势将master压垮。因此,最有效的扩展性技术是最大程度地减少客户端与master的通信。假设master没有严重的性能问题,在master请求处理上的细微改进几乎不起作用。我们使用如下几种策略:

l  我们可以创建任意数量的Chubby单元,客户端总是使用一个就近的单元(通过DNS找到它)避免它使用一个远端的Chubby单元。我们的典型部署中,一个Chubby单元通常为一个数据中心的数千台机器服务。

l  在Master处于重负载的情况下,它可能将租约有效期从默认的12秒增加到60秒左右,这样就减少了它需要处理的KeepAlive PRC调用。(目前为止KeepAlive一直是最主要的请求类型,见4.1节,并且未能及时处理它们是一个超负载的服务器典型的失败模式;客户端对其他调用中的延迟变化相当不敏感)

l  Chubby客户端缓存文件数据,元数据,文件缺失信息及打开的句柄以降低它们在master上执行的调用。

l  我们使用协议转换服务器将Chubby协议转换为更简单的其他协议比如DNS。后面我们还会讨论这点。

 

下面我们描述两种很熟悉的机制代理和Partitioning,通过它们我们希望可以让Chubby更具扩展性。目前我们还未在产品系统中使用它们,但是它们已经设计出来,后面可能很快就会投入使用。我们没有必要考虑超过5倍的可扩展性:首先,能够放入一个数据中心的机器数目有限,依赖于单个服务实例的机器数也有限。其次,因为我们为Chubby的客户端和服务端使用了类似的机器,硬件升级能够增加单个机器上的客户端数目,同时也会增加单个服务端的处理能力。

3.1代理

Chubby协议可以由可信进程作为代理(在两边使用相同的协议),将请求从客户端传到Chubby单元。一个代理通过处理KeepAlive和读请求来降低服务端负载;它无法降低穿过代理缓存的写操作流量。即使具有很强悍的客户端缓存,写操作流量也远小于Chubby正常工作负载的1%(见4.1节){!客户端缓存会降低读操作的流量,即使在这种情况下写操作也占不到1%,这就说明写操作不是瓶颈,因此没必要为它而优化}。因此代理可以大大的增加客户端的数目。如果一个代理处理了Nproxy个客户端请求,KeepAlive流量会降低Nproxy倍,而Nproxy的值可能上万甚至更大。代理缓存最多可以将读流量降低到read-sharing的平均大小—大概是1/10(见4.1节)。但是因为读流量目前只占了Chubby负载的10%不到,因此远不如节省KeepAlive流量的影响大。

 

代理增加了一个额外的RPC用于写操作及第一次的读操作。人们可能觉得这使得Chubby单元的可用性与之前相比至少降低了2倍,因为每个被代理的客户端现在依赖于两台机器:它的代理和Chubby master。

 

读者可能注意到2.9节的故障恢复策略在代理机制下并不理想。我们会在4.4节描述这个问题。

3.2 Partitioning

正如2.3节提到的,Chubby的接口选择保证了一个Chubby单元的名字空间可以划分到不同的服务器。尽管目前我们还不需要这样做,但是目前的代码已经支持根据目录划分名字空间。如果开启划分,一个Chubby单元可能会由N个分区组成,每个分区具有一组副本和一个master。在目录D下的每个节点D/C将会被存储到分区P(D/C)=hash(D)mod N。需要注意的是D的元数据可能会存储在一个不同的分区P(D)=hash(D’) mod N,此处D’代表D的父亲。

 

分区目的在于可以启动很多的Chubby cell,并尽量让分区之间不需要通信。尽管Chubby没有硬链接,目录修改时间及跨目录重命名操作,有些操作仍然需要分区间的通信:

l  ACLs本身是文件,因此一个分区可能使用另一个分区做权限检查。但是ACL文件被缓存后,只要Open()和Delete()操作需要进行ACLs检查;而且大多数客户端只是读取那些不需要ACL的公共访问文件。

l  当目录被删除时,一个跨区的调用可能需要保证目录是空的

 

因为每个分区独立处理大部分的调用,我们预期这种通信不会对性能和可用性造成大的影响。

 

除非分区数目N很大,我们认为每个客户端将会与大部分分区进行联系。因此,分区将任意一个分区上的读写量降低到原来的1/N,但是并不会降低KeepAlive的流量。如果Chubby需要处理更多的客户端,我们的策略将是联合使用代理和分区。

4. 实际应用、意外(surprises)和设计错误
4.1 应用与行为统计(use and behaviour)

下面的表格给出了某个Chubby单元在某一时刻的统计信息;其中RPC的频率是通过一个10分钟的区间计算出来的。下面的这些数字对google的很多Chubby单元来说都很典型:

 

【google论文五】Chubby:面向松散耦合的分布式系统的锁服务 - 星星 - 银河里的星星

 

从表中可以看到如下几点:

l  很多文件被用于命名服务;见4.3节

l  配置,访问控制,及元数据文件(类似于文件系统的超级块)很普遍

l  Negative caching很明显

l  230k/24=10,平均看来大概每个缓存文件有10个客户端使用

l  很少有客户端持有锁,共享锁也很少见;这与锁主要用于primary选举及在副本间划分数据是一致的

l  RPC流量主要由会话KeepAlive组成;之后是一些读操作(缓存未命中的);很少有写操作或锁的申请。

现在可以简短描述下Chubby单元失效的典型原因。假设如果我们乐观的认为只要master还在提供服务就认为Chubby单元就是处于可工作状态,那么在我们的Chubby单元中,在几周的时间内总共记下了61次失效,总共大概是700个Cell-day的时间{!Cell-day实际上是类似于人月(人月神话中的人月)的概念,因为观察的是多个Cell的出错的总的情况,比如可能观察的是5个Chubby单元在10天内的情况,那么换算成Cell-day,就是50个Cell-day}。排除掉因数据中心停机维护引起的失效外,其他的失效原因如下:网络拥塞,维护,超载,操作错误,软件和硬件问题。大部分的失效持续15秒或者更少的时间,其中有52个低于30s;我们大部分的应用程序不会因为Chubby单元30s的失效而受到影响。剩余的9次失效,其中4个因为网络维护引起,2个怀疑是因为网络拥塞,2个是因为软件错误,还有1个是因为负载过重引起。

 

在很多cell-year的运行中,发生过6次数据丢失,其中4次是因为数据库软件错误,2次是因为操作失误;没有与硬件失败相关的。更讽刺的是,因升级引起操作错误却是为了避免软件的错误。我们曾有2次去纠正因非master副本引起的数据损坏。

 

Chubby的数据都可以放入内存中,因此大部分的操作代价都很小。我们的生产服务器的平均请求延迟通常不到1ms,在Chubby单元负载过重时,延时会显著增加,很多会话会被丢弃。负载过重是因为同时有很多(>90000)会话处于活动状态,但也可能是由异常情况引起的:比如大量客户端并发产生数百万的读请求(如4.3节所述),或者是因为客户端库的问题导致部分读操作的缓存无效而引起每秒上万次的请求。因为大部分的PRC调用都是KeepAlive,通过增加会话租约期限(第3节)服务端可以将多个活动客户端的平均请求延迟维护在较低的一个水平上。

 

客户端的RPC读延迟受限于RPC系统和网络;在一个本地Chbby单元中,它的值可能小于1ms,但是Chubby单元距离客户端极遥远时,该值可能就是250ms。写操作(包括锁操作)因为要更新数据库日志因此可能还要慢5-10毫秒,但是如果有一个最近发生故障的客户端缓存了该文件,这个时间可能要上升至数10秒{!要等待它的缓存租约过期}。但是这种写操作上的变化对于平均请求延迟具有很少的影响,因为写操作并不经常发生。

 

只要会话未被丢弃,客户端很少受延迟变化的影响。需要注意的一点,为制止恶意的客户端,我们在open()操作中增加了人为的延迟(见4.5节);只有当延迟超过10秒并且反复出现时,开发者才会注意到它们。我们发现提高Chubby扩展性的关键不在于服务端性能;降低与服务端所需的通信会有更好的效果。我们没有为读写服务器的代码进行特殊的优化,只是检查一下确保不会有严重的bug,然后注重于可扩展性机制上,这样会更有效。另一方面,开发者们确实发现过因本地的Chubby cache的一个性能bug,而导致客户端每秒可能进行了成千上万次的读操作。

4.2 java客户端

Google的基础架构设施大部分都是用C++实现的,但是用Java实现的的系统也在日益增长。这种趋势给Chubby带来了一些问题,而Chubby本身有一个复杂的客户端协议及一个非平凡的客户端库。

 

Java带来了很好的移植性,但是也因为要把它与其他语言进行连接而带来了一些额外的花费。通常Java访问非原生(no-native)库的机制是JNI,但是它通常被认为效率低下而繁琐。我们的Java程序员们并不喜欢使用JNI,为了避免使用它,他们更喜欢将库翻译成Java,并维护它们。

 

Chubby的C++客户端库大概7000行代码(与服务端差不多),而且客户端协议很精细。维护这样的一个Java实现的库,需要小心谨慎及额外的成本,而一个没有缓冲的实现甚至会搞垮服务器。因此,我们的Java用户运行了多个协议转换服务器,提供一个类似于Chubby客户端API的简单RPC协议。即使事后看来,如何避免去编写、运行并维护一个额外的服务器仍然不是那么明显的。

4.3 作为name service使用

尽管Chubby是为锁服务而设计的,我们发现它最流行的应用却是作为名字服务器。

 

在DNS(普通Internet名字解析系统)中,缓存机制是基于时间的。DNS记录有一个有效期(TTL:time-to-live),如果在这个时间段内DNS数据没有被刷新,它们就会被丢弃。通常很容易选择一个合适的TTL值,但是如果需要能快速替换失败的服务,此时需要设置较小的TTL,但TTL太小的话,有可能会使得DNS服务器超载。

 

比如,开发者运行一个具有数千个进程的作业(job)是很常见的,如果这些进程相互之间都需要通信,这将会导致一个平方级别的DNS查找。我们可能想使用一个60s的TTL,这既可以让那些异常的客户端能够在可以接受的延迟内被替换掉,同时在我们的环境里也不认为该时间过短。在这种情况下,为了维护一个具有3000个客户端的作业,可能需要每秒高达150,000次的查找(lookup){!总共3000*3000,除以60就是150,000}。(作为对比,一个双核2.6G的Xeon DNS服务器每秒可以处理大概50,000个请求)。Job规模越大,问题就会越严重,同时还可能有多个job同时在跑。在引入Chubby之前,这种DNS的负载波动对于Google来说一直是一个严重的问题。

 

与之相比,Chubby的缓冲使用显式的失效,这样在没有变化发生时,一个固定的会话KeepAlive请求就能维护客户端任意数目的缓冲记录。我们曾经观察到一个双核2.6G的Xeon Chubby master处理直接与它通信的90,000个客户端(没有代理的情况下);这些客户端包括在具有上文提到的通信模式的Job中。这种不用轮询每个名字就可以快速进行名字更新的能力{!何意?如何实现,DNS又是如何进行名字更新呢?对于DNS来说,名字信息更新后,真正反映到客户端,是需要客户端进行查询之后,而这个过程可能需要轮询多级DNS服务器,之后这些DNS服务器才会从权威的DNS服务器拥有该记录,而对于Chubby来说可以直接访问保存这些信息的那个文件即可},是如此吸引人,现在公司的大部分系统都使用由Chubby提供的名字服务。

 

尽管Chubby的缓存机制允许单个单元为大量的客户端提供服务,但是峰值负载仍可能是一个问题。在我们最初部署基于Chubby的名字服务时,启动一个3000个进程的job时(因此会产生9000,000个请求)可能会将Chubby master压垮。为解决这个问题,我们将名字条目分组进行批量处理,这样一次查找可能返回并缓存job里相关进程的大量(通常是100个)的名字映射。

 

Chubby提供了一个比普通名字服务更精确的缓存语义;但是名字解析只需要定期的通知而不需要完全一致。因此,这就可以通过特别为名字服务引入一个简单的协议转换服务器来降低Chubby的负载。如果我们之前能预见到将Chubby作为名字服务器使用,为了避免这样一个简单但是没有必要的额外的服务器,那么可能就会更快地去实现一个完整的代理。

 

现在有一种更进一步的协议转换服务器:Chubby DNS服务器。它使得存储在Chubby内的名字数据可以被DNS客户端访问。这种服务器在简化从DNS名称到Chubby名称的转换,以及兼容现有的应用程序(比如浏览器)这两个方面都很重要。

4.4 故障恢复的问题

master故障恢复的原始设计(2.9节)中需要master将新的会话在创建时写入数据库。在Berkeley DB版的锁服务器中,在很多进程同时启动时,这种会话创建带来的开销就成了一个问题。为了避免超载,可以对服务器进行修改,不再是在会话第一次创建时将它存入数据库,而是在它首次试图进行修改、获取锁或者打开一个临时文件时。另外,活动的会话在每个KeepAlive上以一定的概率存入数据库。这样对于只读性会话的写入就能均摊到一个时间段上。

 

尽管对于避免超载这是必要的,但是这种优化也带来了一些问题,那些年轻的只读会话可能没有被记录数据库中,因此在故障恢复发生时就可能会被丢失。尽管这些会话没有持有锁,但仍然是不安全的;如果所有已记录的会话在被丢弃的会话过期前被新的master接受到(check in),那些被丢弃的会话可能会在一段时间内读到脏数据。另外尽管实际中这很少发生;但是在一个大的系统中,几乎可以肯定总是会有会话check in失败,不管怎么样这都迫使master要等待最大租约过期。因此,为了避免这种影响,以及在当前机制引入代理后产生的复杂性,我们修改了故障恢复的设计。

 

在新的设计中,我们完全避免了在数据库中记录会话,取而代之的是在master重建句柄时(2.9节8)以同样的方式对会话进行重建。一个新的master现在必须等待一个完整的最坏情况下的租约过期{!2.9节中,master可能不需要等待这样的一个时间,只要所有的对话都被记录了,之后它又收到了所有会话的应答,那么就不需要等待这样一个时间,或者是它没有收到某些会话的应答,那么它就只需要等待这些会话},才能允许操作继续处理,因为它无法知道是否所有的会话都已经check in(2.9节6)。这在实际中影响也很小,因为可能并非所有的会话都需要check in{?如何解释?也就是说在实际中,通常都有些未check in的会话,这样实际上本来也是需要等待一段时间的,区别只是修改之后可能需要等待的时间稍微变长了一些}。

 

一旦会话可以在免磁盘状态的情况下就可以创建,代理服务器就可能会管理master不知道的会话。有一个只有代理可用的操作,即允许它们改变有锁所关联的会话。这就允许可以将客户端从一个失败的代理移到另一个。master端唯一需要做的修改是,在新的代理有机会声明它们之前,保证不会放弃一个与代理会话相关的锁或临时文件句柄。

4.5 客户端的滥用(abusive client)

Google的产品团队可以自由创建自己的Chubby单元,但是这样增加了维护负担及额外的硬件资源开销。由于很多服务使用共享的Chubby单元,这使得将正常的客户端与非正常客户端隔离变得很重要。Chubby只是用于公司内部,因此针对它的恶意的拒绝服务式的攻击很少会发生。但是开发者的失误,理解上的偏差以及不同的预期都可能会导致类似恶意攻击的后果。

 

某些补救方法可能过于繁重。比如,我们review产品团队计划使用Chubby的方式,在review通过前,拒绝它们对共享的Chubby单元的访问。这种方式的一个问题是开发者通常不能预测它们的服务在未来的使用方式及增长速度。读者可能注意到这样的一个有点讽刺性的事实,我们本身就没有预测到Chubby将会如何被使用。

 

我们review的最重要的一个方面是要判断对Chubby任何资源(RPC调用,磁盘空间,文件数)的使用是否是随项目所处理的用户数或者数据量的增加成线性增长(或者更糟糕的增长关系)。任何的线性增长率必须能通过一个补偿参数将Chubby的负载降低到合理的边界上。不过,我们早期的review并不彻底。

 

一个相关的问题是在大多数的软件说明文档中缺乏性能方面的建议。由一个团队所写的模块可能会在一年后被另一个团队重用,就可能产生灾难性的后果。有时很难向接口设计者解释,告诉他们必须要修改他们的接口,不是因为他们接口设计地很差,而是因为这可能让其他开发者很难意识到RPC的开销。

 

下面我们列出一些我们遇到的问题:

缺乏有效的缓存机制。起初我们并没有意识到缓存文件缺失信息(the absence of files)的重要性,也没有重用已打开的句柄。尽管在培训时已经说明,但是我们的开发者还是经常在写循环中当某个文件不存在时会进行无限制的重试,或者是通过重复的打开关闭一个文件对它进行轮询(实际上他只需要打开一次即可)。

 

起初,我们通过在应用程序在短时间内做多次open()操作的尝试时,给它引入一个指数性增长的延迟,来解决这个重试问题。在某些情况下,这能暴露出开发者这种已知性的bug,{!即在短时间内重复打开文件,根据设计是要避免的,但是开发者虽然知道这点但在实现中可能又忽略了这点。实际上通过修改设计,使得开发者无需记忆这种原则,而是允许这种使用方式,是更廉价的方式。}但是这需要我们花更多是时间去培训。最后让重复性的open()调用变得廉价(即通过缓存缺失文件或重用打开的句柄)会更简单些。

 

Quota的缺乏。Chubby并不是一个为大数据量而设计的一个存储系统,因此没有存储限额(Quota)。现在看来,这种想法太天真了。

 

Google曾经有一个项目,该项目有一个负载追踪数据上传的模块,会在Chubby中存储某些元数据。起初上传行为很少发生,而且限制在某几个人中,因此所用的空间有限。但是,后来另外两个服务开始使用该模块作为监控大量用户的上传行为的一种方式。最终,随着他们服务的增长,Chubby到达了极限:追踪一个用户行为就需要去写一个1.5M的文件,而由这项服务所用的空间也超过了所有其他服务使用空间的总和。

 

我们引入了一个对于文件大小的限制(256k字节),同时鼓励这项服务迁移到更合适的存储系统上去。但是很难去推动那些维护产品系统的繁忙的人们去做一个很大的变更—为了把这些数据迁移到别处花了大概一年时间。

 

发布/订阅。曾经有几次尝试使用Chubby的事件机制作为一个类Zephyr的发布/订阅机制。Chubby重量级的一致性保证以及为维护一致性使用的失效机制(而没有用更新机制)使得它在大多数使用场景中都很慢而且效率很低。幸运的是,所有这样类似的应用,都在重新设计应用程序的代价变得太大之前得以叫停。

4.6 吸取的经验教训

这里我们列出一些经验教训以及一些如果有机会可能会做的一些设计方面的变更:

 

开发者很少考虑可用性。我们发现我们的开发者很少考虑失败情况,同时他们倾向于将Chubby看做是一个总是处于可用状态的服务。比如,开发者曾经建立过一个使用了数百台机器的系统,在Chubby选举出新的master后就启动了一个数十分钟的恢复处理过程。这使得一个单点失败产生的影响,在机器数及时间上都扩大了数百倍。我们更希望开发者为短暂的Chubby失效做好应对计划,这样它们的应用就几乎不受此类事件的影响。这也是使用粗粒度锁的一个原因,在2.1节讨论过这个问题。

 

另外开发者对于服务在线(being up)以及服务可用(available)之间的区别缺乏认知。比如global的Chubby单元(见2.12节)几乎总是处于在线(up)状态,因为很少会出现相距遥远的两个数据中心同时down掉。然而,客户端所感受到的它的可用性往往要低于本地的Chubby单元的可用性。首先,本地Chubby单元很少与client隔离,第二,尽管本地Chubby单元可能因为机房维护而停机,但是这种维护也同样会影响到client,这样此时Chubby单元的不可用对于client是不可见的。

 

我们在API上的选择也会影响到开发者对Chubby失效的处理方式。比如,Chubby提供一个事件允许客户端检测到master故障恢复的发生。本来这是为了让客户端检查某些可能的变化,因为某些事件可能会被丢失。不幸的是,很多开发者会选择在收到此类事件时,停掉他们的应用程序,因此这大大降低了他们系统的可用性。如果只是发送一个”文件内容”事件或者是保证在故障恢复期间不丢失事件可能会更好一些。

 

目前我们使用了三种机制来防止开发者对Chubby的可用性过分乐观,尤其是对global的Chubby单元。首先,像前面提到的(4.5节),我们会review产品团队计划如何使用Chubby,并建议他们不要将他们产品的可用性与Chubby绑定地太紧。第二,我们现在提供一些执行某些高级任务的库,使得开发者可以自动地从Chubby失效中隔离。第三,我们利用每次Chubby失效的事后分析作为一种手段,不仅是消除Chubby及操作过程中的bug,还要降低应用程序对Chubby可用性的敏感性—这都能帮助提高我们系统整体的可用性。

 

细粒度的锁是可以被忽略的。在2.1节的最后,我们提出了一个提供细粒度锁机制的方案。令人吃惊的是,直到目前为止,我们还没有需要去实现这样一个机制;开发者通常发现为了优化应用程序,他们必须移除不必要的通信,这通常意味着需要寻找一种粗粒度的锁机制。

 

糟糕的API选择可能产生无法预料的影响。我们大部分的API都演化的很好,但是有一个错误很突出。我们取消长时间运行的调用是Close()和Poison() 这两个RPC调用,它们同时也会丢弃句柄的服务端状态。这就使得能够获取锁的句柄不能被共享,比如被多个线程。我们可能要增加一个Cancel() RPC来允许对打开的句柄的更多的共享。

 

RPC使用影响了传输协议。KeepAlive既被用于刷新客户端的会话租约,也用于传递事件及缓存从master到客户端的失效通知。这个设计有一个影响:客户端在没有应答缓存失效通知的情况下不能刷新它的会话租约。

 

这看起来还算合理,除了在我们的协议选择中引入一些限制。TCP的拥塞回退不关心更高层的超时(比如Chubby租约),这样基于TCP的 KeepAlive在网络拥塞时可能会导致大量会话的丢失。这样我们不得不通过UDP发送KeepAlive RPC而不是TCP;UDP本身没有拥塞避免机制,因此我们更希望只有当更高级的时间边界必须满足时才使用UDP。

 

我们觉得可以为协议提供一个额外的基于TCP的GetEvent() RPC,可以用它在正常情况下进行事件和失效通知,采用与KeepAlive相同的使用方式。KeepAlive仍然会包含一个未回复的事件列表,因此事件最终必须得到回复。

5. 与相关工作的对比

Chubby是基于一些早已成熟的想法之上。Chubby的缓存设计基于在分布式文件系统上的工作[10]。会话和缓存token类似于Echo里的行为[17];会话减少了V系统中的租约[9]开销。关于通用锁服务的想法可以在VMS[23]里找到,尽管该系统起初使用了一个具有低延迟交互的专用高速互联网络。与它的缓存模型类似,Chubby的API基于一个文件系统模型,包括这种比纯文件更方便的类文件系统名字空间的想法也是来自[8,21,22]。

 

在性能及存储能力上,Chubby不同于诸如Echo或者AFS这样的分布式文件系统:客户端不会读、写及存储大量数据,也不期望高吞吐率,也不要求缓存数据的低延迟。但是它希望具有一致性,可用性及可靠性,在性能不是那么重要的情况下这些属性就比较容易达到。因为Chubby的数据库很小,因此我们能在线存储它的多个拷贝(通常有5个副本及一些备份)。每天我们会进行多次备份,并且每隔几个小时就会通过数据库状态的checksum在副本之间进行比较。通过对普通文件系统所需的性能及存储需求上的弱化,允许我们可以通过一个Chubby master为成千上万的客户端提供服务。通过提供一个很多客户端可以共享信息及协调工作的中央节点,我们解决了我们的系统开发者所面对的一类问题。

 

关于文件系统和锁服务器方面有大量的可参考文献,我们无法提供一个详尽的比较,因此我们只是选择了其中的一个Boxwood的锁服务器来进行比较,因为它是最近设计的,同时也旨在用于松耦合的环境中,而且它的很多设计都与Chubby不同,有些很有趣,有些并不是很重要。

 

Chubby实现了锁,一个可靠的小文件存储系统,一个会话/租约机制。与之相比,Boxwood将这些分成了三块:锁服务,Paxos服务(可靠的状态存储机制),一个独立的失败检测服务。Boxwood系统使用到了这全部的三个模块,但是某些系统可能只需要其中的某个模块。我们认为这种设计上的不同源于目标用户的不同。Chubby计划用于各种不同的用户及应用程序;它的用户可能从创建分布式系统的专家到那些写管理脚本的初学者。在我们的环境里,一个具有令人熟悉的API的大规模锁服务看起来更具吸引力。与之相比,在我们看来Boxwood主要提供了一个工具集,更适合于那些工作在需要共享代码但无需一块使用的项目上的少数高级开发者。

 

很多情况下,Chubby都提供了一个比Boxwood更顶层的接口。比如,Chubby合并了锁和文件名字空间,而Boxwood的锁名称就是简单的字节序列。Chubby客户端默认缓存文件状态;Boxwood的Paxos服务的一个客户端可以通过锁服务实现缓存机制,可以使用Boxwood本身提供的锁服务。

 

因为是面向不同的期望应用,这两个系统的默认参数有着显著的不同:每个Boxwood失败检测器每隔200ms会与客户端通信一次,超时时间是1s;Chubby的默认租约时间是12s,KeepAlive每7s进行一次。Boxwood的子部件使用2个或者3个副本来实现可用性,而我们通常在每个单元中使用5个副本。然而,这些选择并没有暗示着设计方面的更深度的差别,而只是说明了系统如何为容纳更多的客户端,或者适应与其他项目共享机柜的不确定性,而必须如何去调整这些参数。

 

一个更有趣的区别是Chubby引入的宽限期(grace period),而Boxwood没有这个。(回忆一下,宽限期可以让客户端平稳地度过Master的失效期而不用丢失会话或者是锁,Boxwood的”grace period”等价于Chubby的”session lease”,是另外一个概念)。而且,这种区别是两种系统关于规模及出错概率的不同期望所导致的。尽管master的故障恢复很少发生,但是一个Chubby锁的丢失对于客户端来说是很昂贵的。

 

最后,两种系统中的锁是为不同的目的而设计。Chubby锁是重量级的,需要sequencer来保证外部资源的安全,而Boxwood的锁是轻量级的,主要是为了在Boxwood内部使用。

6. 总结

Chubby是一个为那些google内部的分布式系统的粗粒度同步行为设计的分布式锁服务。它已经广泛用于名字服务及存储配置信息。

 

它的设计基于很多已经相互结合地很好的人们所熟知的想法:用于多副本容错的分布式一致性算法,保持了简单语义的用于降低服务端负载的客户端一致性缓存机制,实时性的更新通知,类文件系统接口。我们通过使用缓存机制、协议转换服务器简化负载,来使得单个Chubby实例可以扩展到数万个客户端的规模。未来我们还希望通过代理和partitioning来增加扩展性。

 

Chubby已经成为Google首要的内部名字服务;它为很多系统(比如MapReduce)提供了一种通用的协调机制;存储系统GFS和Bigtable使用Chubby来从多个冗余副本中选举一个primary;而且它也是那些需要高度可用性的文件的标准存储设施,比如访问控制列表。

7. 致谢

很多人为Chubby系统做出了贡献:Sharon Perl编写了基于Berkeley DB的副本层;Tushar Chandra和Rober Griesemer编写了取代Berkeley DB的可复制数据库;Ramsey Haddad将API连接到GFS接口;Dave Presotto,Sean Owen,Doug Zongker及Praveen Tamara分别编写了Chubby DNS,Java,以及命名协议转换器,以及完整的Chubby代理;Vadim Furman增加了打开句柄及文件缺失信息的缓存;Rob Pike,Sean Quinlan和Sanjay Ghemawat给了很多有价值的设计建议;很多google开发者帮助发现了早期的缺点。

 

译考文献:

http://blog.xiping.me/2010/12/google-chubby-in-chinese.html#more-854

中文版Chubby论文

http://net.pku.edu.cn/~course/cs501/2008/assign/prj/CNDS/CS501-Paper-CNDS.doc

英文版Chubby论文

http://www.ibm.com/developerworks/cn/opensource/os-cn-zookeeper/

分布式服务框架 Zookeeper

http://zookeeper.apache.org/doc/r3.3.2/zookeeperOver.html

ZooKeeper: A Distributed Coordination Service for Distributed Applications

http://zookeeper.apache.org/doc/r3.3.2/recipes.html

ZooKeeper Recipes and Solutions

You Might Also Like