三、Paxos 的工程实践

前章提要:
主要从理论上讲解了Paxos算法,如何在保证数据一致性的情况下兼顾稳定性和性能也是一个巨大的挑战。从本章开始,我们将结合实际工程实际中的Paxos实现, 来讲解如何真正地使用Paxos算法来解决分布式一致性问题。

1. Chubby

Google Chubby是一个大名鼎鼎的分布式锁服务,GFS和Big Table等大型系统多用它来解决分布式协作、元数据存储和Master选举等一系列与分布式锁服务相关的问题。 Chubby的底层一致性实现就是以Paxos算法为基础的,这给Paxos算法的学习者提供了一个理论联系的范例,从而可以了解到Paxos算法是如何在实际工程中得到应用的。

1.1 概述

Chubby是一个面向松耦合分布式系统的锁服务,通常用于为一个由大量小型计算机构成的松耦合分布式系统提供高可用的分布式锁服务。一个分布式锁服务的目的是 允许它的客户端进程同步彼此的操作,并对当前所处环境的基本状态信息达成一致。针对这个目的,Chubby提供了粗粒度的分布式锁服务,开发人员不需要使用复杂的同步协议。 而是直接调用Chubby的锁服务接口即可实现分布式系统中多个进程之间粗粒度的同步控制,从而保证分布式数据的一致性。

Chubby的客户端接口设计非常类似于UNIX文件系统结构,应用程序通过Chubby的客户端接口,不仅能够对Chubby服务器上的整个文件进行读写操作,还能够添加对文件节点的锁控制, 并且能够订阅Chubby服务端发出的一些列文件变动的事件通知。

1.2 应用场景

在Chubby的众多应用场景中,最为典型的就是集群中服务器的Master选举。例如在Google文件系统(Google File System,GFS)中使用Chubby锁服务来实现对 GFS Master服务器的选举。而在BigTable(用于结构化数据存储与管理的大型分布式存储系统)中,同样被用于Master选举,并且借助Chubby, Master能够非常方便地的感知到其所控制的那些服务器。同时,通过Chubby,BigTable的哭护短还能够方便地定位到当前BitTable集群的Master服务器。 此外,在GFS和BigTable中,都使用Chubby来进行系统运行时元数据的存储。

1.3 设计目标

对于Chubby的设计,有的开发人员觉得作为Paxos算法的实现者,Chubby应该构建成一个包含Paxos算法的协议库,从而使应用程序能够便捷地使用Paxos算法。 但是,Chubby的最初设计者并没有选择这么做,而是将Chubby设计成一个需要访问中心化节点的分布式锁服务。

Chubby之所以设计成这样一个完整的分布式锁服务,是因为锁服务具有以下4个传统算法库所不具有的优点。

1. 对上层应用程序的侵入性更小

对于应用程序开发初期,开发人员都是从一个只需要支撑较小的负载,并且只需要保证大体可用的原型开始的,往往并没有在代码层面为分布式一致性协议的实现留有余地。 于是,集群中副本复制和Master选举等一系列提高分布式系统可用性的措施,就通过一个封装了分布式一致性协议的客户端来完成,但相比之下, 使用一个分布式锁服务的接口方式对上层应用程序的侵入性会更小。

2. 便于提供数据的发布与订阅

几乎在所有使用Chubby进行Master选举的应用场景中,都需要一种广播结果的机制,用来向所有的客户端公布当前的Master服务器。这就意味着Chubby应该 允许其客户端在服务器上进行少量数据的存储与读取————也就是对小文件的读写操作。虽然这个特性也能够通过分布式命名服务来实现,但是根据实际的经验来看, 分布式锁服务本身也非常适合提供这个功能,这一方面能够大大减少客户端依赖的外部服务,另一方面,数据的发布与订阅功能和锁服务在分布式一致性特性上是想通的。

3. 开发人员对基于锁的接口更为熟悉

对于绝大部分的开发人员来说,Chubby为其提供了一套近乎和单机锁机制一致的分布式锁服务接口,比提供一个一致性协议的库来得更为友好。

4. 更便捷地构建更可靠的服务

通常一个分布式一致性算法都需要使用Quorum机制来进行数据项值的选定。Quorum机制是分布式系统中实现数据一致性的一个比较特殊的策略,它指的是在 一个由若干个机器组成的急群众,在一个数据项值的选定过程中,要求急群众存在过半的机器达成一致,因此Quorum机制也被称作“过半机制”。 在Chubby中通常使用5台服务器来组成一个集群单元,根据Quorum机制,只要整个急群众有3台服务器是正常运行的,那么整个集群就可以对外提供正常的服务。 相反的,如果仅提供一个分布式一致性协议的客户端库,那么这些高可用性的系统部署都将交给开发人员自己来处理,提高了成本。

因此,Chubby被设计成了一个需要访问中心化节点的分布式锁服务。同时,在Chubby的设计过程中,提出了以下几个设计目标。

  1. 提供一个完整的、独立的分布式锁服务,而非仅仅是一个一致性协议的客户端库:
    例如,对于Master选举同时将Master信息登记并广播的场景,应用程序只需要向Chubby请求一个锁,并且在获得锁之后向相应的锁文件写入Master信息即可, 其余的客户端就可以通过读取这个锁文件来获取Master信息。

  2. 提供粗粒度的锁服务
    Chubby锁服务针对的应用场景是客户端获得锁之后会进行长时间的持有(数小时或数天),而非用于短暂获取锁的场景。针对这种应用场景,当锁服务短暂失效时 (例如服务器宕机),Chubby需要保持所有锁的持有状态,以避免持有锁的客户端出现问题。这和细粒度锁的设计方式有很大的区别,细粒度锁通常设计为锁 服务一旦失效就释放所有锁,因为细粒度锁的持有时间很短,相比而言放弃锁带来的代价较小。

  3. 在提供锁服务的同时提供对小文件的读写功能
    Chubby提供对小文件的读写服务,以使得被选举出来的Master可以在不依赖额外服务的情况下,非常方便地向所有客户端发布自己的状态信息。具体的, 当一个客户端成功获取到一个Chubby文件锁而成为Master之后,就可以继续向这个文件里写入Master信息,其他客户端就可以通过读取这个文件得知当前的Master信息。

  4. 高可用、高可靠
    在Chubby的架构设计中,允许运维人员通过部署多台机器(一般是5台机器)来组成一个Chubby集群,从而保证集群的高可用,基于Paxos算法的实现, 只要保证在3台正常运行的机器,整个集群对外服务就能保持可用。

  5. 提供事件通知机制
    Chubby客户单需要实时地感知到Master的变化情况,当然这可以通过让客户端反复轮询来实现,但是在客户端规模不断增大的情况下,客户端主动轮询的实时性效果并不理想, 且对服务器性能和网络带宽压力都非常大。因此,Chubby需要由能力将服务端的数据变化情况(如文件内容变更)以事件的形式通知到所有订阅的客户端。

1.4 Chubby技术架构

1.4.1 系统结构

Chubby的整个系统结构主要由服务端和客户端两部分组成,客户端通过RPC调用与服务端进行通信

一个典型的Chubby集群,或称为Chubby cell,通常由5台服务器组成。这些副本服务器采用Paxos协议,通过投票的方式来选举产生一个获得过半投票的 服务器作为Master。一旦某台服务器成为了Master,Chubby就会保证在一段时期内不会再有其他服务器成为Master————这段时期称为Master租期(Master lease)。 在运行过程中,Master服务器会通过不断续租的方式来延长Master租期,而如果Master服务器出现故障,那么余下的服务器就会进行新一轮的Master选举, 最终产生新的Master服务器,开始新的Master租期。

集群中的每个服务器都维护着一份服务端数据库的副本,但在实际运行过程中,只有Master服务器才能对数据库进行写操作,而其它服务器都是使用Paxos协议从 Master服务器上同步数据库数据的更新。

现在,我们再来看下Chubby的客户端是如何定位到Master服务器的。Chubby客户端通过向记录有Chubby服务端机器列表的DNS来请求获取所有的Chubby服务器列表, 然后逐个发起请求询问该服务器是否是Master。在这个询问过程中,那些非Master的服务器,则会将当前Master所在的服务器标识反馈给客户端,这样 客户端就能够非常快速地定位到Master服务器了。

一旦客户端定位到Master服务器之后,只要该Master正常运行,那么客户端就会将所有的请求都发送到该Master服务器上。针对写请求,Chubby Master 会采用一致性协议将其广播给集群中所有的副本服务器,并且在过半的服务器接受了该写请求之后,再响应给客户端正确的应答。而对于读请求, 则不需要在集群内部进行广播处理,直接由Master服务器单独处理即可。

在Chubby运行过程中,服务器难免会发生故障。如果当前的Master服务器崩溃了,那么集群中的其他服务器会在Master租期到期后,重新开启新一轮的Master 选举。通常,进行一次Master选举大概需要花费几秒钟的时间。而如果是集群中任意一台非Master服务器崩溃,那么整个集群是不会停止工作的, 这个崩溃的服务器会在恢复之后自动加入到Chubby集群中去。新加入的服务器首先需要同步Chubby最新的数据库数据,完成数据同步之后,新的服务器就可以 加入到正常的Paxos运作流程中与其它服务器副本一起协同工作。

如果集群中的一个服务器发生崩溃并在几个小时后仍无法恢复正常,那么就需要加入新的机器,并同时更新DNS列表。Chubby服务器的更换方式非常简单, 只需要启动Chubby服务端程序,然后更新DNS上的机器列表(即使用新机器的IP地址替换老机器的IP地址)即可。在Chubby运行过程中, Master服务器会周期性地轮询DNS列表因此其很快就会感知服务器地址列表的变更,然后Master就会将集群数据库中的地址列表做相应的变更, 集群内部的其他副本服务器通过复制方式就可以获取到最新的服务器地址列表了。

1.4.2 目录与文件

Chubby对外提供了一套与Unix文件系统非常相近但是更简单的访问接口。Chubby的数据结构可以看作是一个由文件和目录组成的树,其中每一个节点都可以表示为一个 使用斜杠分割的字符串,典型的节点路径表示如下:

/ls/foo/wombat/pouch
其中,ls是所有Chubby节点所共有的前缀,代表着锁服务,是Lock Service的缩写;foo则指定了Chubby集群的名字,从DNS可以查询到由一个或多个 服务器组成该Chubby集群;剩余部分的路径wombat/pouch则是一个真正包含业务含义的节点名字,由Chubby服务器内部解析并定位到数据节点。

Chubby的命名空间,包括文件和目录,我们称之为节点(nodes,在本书后面的内容中,我们以数据节点来泛指Chubby的文件或目录)。在同一个Chubby 集群数据库中,每一个节点都是全局唯一的。和Unix系统一样,每个目录都可以包含一系列的子文件和子目录列表,而每个文件中则会包含文件内容。当然, Chubby并非模拟一个完整的文件系统,因此没有符号链接和硬连接的概念。

由于Chubby的命名结构组成了一个近似标准文件系统的视图,因此Chubby的客户端应用程序也可以通过自定义的文件系统访问接口来访问Chubby服务端数据, 比如可以使用GFS的文件系统访问接口,这就大大减少了用户使用Chubby的成本。

Chubby上的每个数据节点都分为持久节点和临时节点两大类,其中持久节点需要显式地调用接口API来进行删除,而临时节点则会在其对应的客户端会话失效后被自动删除。 (zk中的EPHEMERAL)也就是说,临时节点的生命周期和客户端会话绑定,如果该临时节点对应的文件没有被任何客户端打开的话,那么它就会被删除掉。 因此,临时节点通常可以用来进行客户端会话有效性的判断依据。

另外,Chubby上的每个数据节点都包含了少量的元数据信息,其中包括用于权限控制的访问控制列表(ACL)信息。同时,每个节点的元数据中还包括4个单调 递增的64编号,分别如下。

实例编号:实例编号用于标识Chubby创建该数据节点的顺序,节点的创建顺序不同,其实例编号也不同,因此,通过实例编号,即使针对两个名字相同的数据节点, 客户端也能够非常方便地识别出是否是同一个数据节点————因此创建时间晚的数据节点,其实例编号必定大于任意先前创建的同名节点。
文件内容编号(只针对文件):文件内容编号用于标识文件内容的变化情况,该编号会在文件内容被写入时增加。
锁编号:锁编号用于标识节点锁状态变更情况,该编号会在节点锁从自由(free)状态转换到被持有(held)状态时增加。
ACL编号:ACL编号用于标识节点的ACL信息变更情况,该编号会在节点的ACL配置信息被写入时增加。
同时,Chubby还会标识一个64位的文件内容校验码,以便客户端能够识别出文件是否变更。

1.4.3 锁与锁序列器

在分布式系统中,锁是一个非常复杂的问题,由于网络通信的不确定性,导致在分布式系统中锁机制变得非常复杂,消息的延迟或是乱序都有可能会引起锁的失效。 一个典型的分布式锁错乱案例是,一个客户端C1获取到了互斥锁L,并且在锁L的保护下发出请求R,但请求R迟迟没有到达服务端(可能出现网络延时或 反复重发等),这时应用程序会认为该客户端进程已经失败,于是便会为另一个客户端C2分配锁L,然后再重新发起之前的请求R,并成功地应用到了服务器 上。此时,不幸的事情发生了,客户端C1发起的请求R在经过一波三折之后也到达了服务端,此时,它有可能会在不受任何锁控制的情况下被服务端处理, 从而覆盖了客户端C2的操作,于是导致系统数据出现不一致。当然,诸如此类消息接收顺序紊乱引起的数据不一致问题已经在人们对分布式计算的长期 研究过程中得到了很好的解决,典型的解决方案包括虚拟时间和虚拟同步。

在Chubby中,任意一个数据节点都可以充当一个读写锁来使用:一种是单个客户端以排他(写)模式持有这个锁,另一种则是任意数目的客户端以共享(读)模式 持有这个锁。同时,在Chubby的锁机制中需要注意的一点是,Chubby舍弃了严格的强制锁,客户端可以在没有获取任何锁的情况下访问Chubby的文件,也就是说, 持有锁F既不是访问文件F的必要条件,也不会阻止其它客户端访问文件F。

.1 锁延迟

在Chubby中,主要采用锁延迟和锁序列器两种策略来解决上面我们提到的由于消息延迟和重排序引起的分布式锁问题。其中锁延迟是一种比较简单的策略, 使用Chubby的应用几乎不需要进行任何的代码修改。具体的,如果一个客户端以正常的方式主动释放了一个锁,那么Chubby服务端将会允许其它客户端能够 立即获得该锁。而如果一个锁是因为客户端的异常情况(如客户端无响应)而被释放的话,那么Chubby服务器会为该锁保留一定的时间,我们称之为“锁延迟”(lock-delay) 在这段时间内,其它客户端无法获取这个锁。锁延迟措施能够很好地防止一些客户端由于网络闪断等原因而与服务器暂时断开的场景出现。总的来说, 该方案尽管不完美,但是锁延时能够有效地保护在出现消息延时情况下发生的数据不一致现象。

.2 锁序列器

Chubby提供的另一种方式是使用锁序列器,当然该策略需要Chubby的上层应用配合在代码中加入相应的修改逻辑。任何时候,锁的持有者都可以向Chubby请求一个锁 序列器,其包括锁的名字、锁模式(排他或共享模式),以及锁序号。当客户端应用程序在进行一些需要锁机制保护的操作时,可以将该锁序列器一并发送给服务端。 Chubby服务端接收到这样的请求后,会首先检测该序列器是否有效,以及检查客户端是否处于恰当的锁模式;如果没有通过检查,那么服务端就会拒绝该客户端请求。

1.4.4 Chubby中的事件通知机制

为了避免大量客户端轮询Chubby服务端状态所带来的压力,Chubby提供了事件通知机制。Chubby的客户端可以向服务端注册事件通知,当触发这些事件的时候, 服务端就会向客户端发送对应的事件通知。在Chubby的事件通知机制中,消息通知都是通过异步的方式发送给客户端的,常见的Chubby事件如下。

.1 文件内容变更

例如,BigTable集群使用Chubby锁来确定集群中的哪台BitTable机器是Master;获得锁的BitTable Master会将自身信息写入Chubby上对应的文件中。 BitTable集群中的其他客户端可以通过监视这个Chubby文件的变化来确定新的BitTable Master机器。

.2 节点删除

当Chubby上指定节点被删除的时候,会产生“节点删除”事件,这通常在临时节点中比较常见,可以利用该特性来间接判断该临时节点对应的客户端会话是否有效。

.3 子节点新增、删除

当Chubby上指定的节点的子节点新增或是删除时,会产生“子节点新增、删除”事件。(还有更新)

.4 Master服务器转移

当Chubby服务器发生Master转移时,会以事件的形式通知客户端。

1.4.5 Chubby中的缓存

为了提高Chubby的性能,同时也是为了减少客户端和服务端之间频繁的读请求对服务端的压力,Chubby除了提供事件通知机制之外,还在客户端中实现了缓存, 会在客户端对文件内容和元数据信息进行缓存。使用缓存机制在提高系统整体性能的同时,也为系统带来了一定的复杂性,其中最主要的问题就是应该如何保证缓存的一致性。 在Chubby中,通过租期机制来保证缓存的一致性。

Chubby缓存的生命周期和Master租期机制紧密相关,Master会维护每个客户端的数据缓存情况,并通过向客户端发送过期信息的方式来保证客户端数据的一致性。 在这种机制下,Chubby就能够保证客户端要么能够从缓存中访问到一致的数据,要么访问出错,而一定不会访问到不一致的数据。具体的,每个客户端的缓存 都有一个租期,一旦该租期到期,客户端就需要向服务端续订租期以继续维持缓存的有效性。当文件数据或元数据被修改时,Chubby服务端首先会阻塞该修改操作, 然后由Master向所有可能缓存了该数据的客户端发送缓存过期信号,以使其缓存失效,等到Master在接收到所有相关客户端针对该过期信息的应答(应答包括两类, 一类是客户端明确要求更新缓存,另一类则是客户端允许缓存租期过期)后,再继续进行之前的修改操作。

通过上面这个缓存机制的介绍,相信读者都已经明白了,Chubby的缓存数据保证了强一致性。尽管要保证严格的数据一致性对于性能的开销和系统的吞吐影响很大, 但由于弱一致性模式在实际使用过程中极容易出现问题,因此Chubby在设计之初就决定了强一致性模型。

1.4.6 会话和会话激活(KeepAlive)

Chubby客户端和服务端之间通过创建一个TCP连接来进行所有的网络通信操作,我们将这一连接称为会话(Session)。会话是有生命周期的,存在一个超时时间, 在超时时间内,Chubby客户端和服务端之间可以通过心跳检测来保持会话的活性,以使会话周期得到延续,我们将这个过程称为KeepAlive(会话激活)。如果 能够成功地通过KeepAlive过程将Chubby会话一直延续下去,那么客户端创建的句柄(引用)、锁和缓存数据等都依然有效。

1.4.7 KeepAlive请求

下面我们就重点来看看Chubby Master是如何处理客户端的KeepAlive请求的。Master在接收到客户端的KeepAlive请求时,首先会将该请求阻塞住,并等到 该客户端的当前会话租期即将过期时,才为其续租该客户端的会话租期,之后再向客户端响应这个KeepAlive请求,并同时将最新的会话租期超时时间反馈给客户端。 Master对于会话续租时间的设置,默认是12秒,但这不是一个固定的值,Chubby会根据实际的运行情况,自行调节该周期的长短。举个例子来说, 如果当前Master处于高负载运行状态的话,那么Master会适当地延长会话租期的长度,以减少客户端KeepAlive请求的发送频率。客户端在接收到来自Master的续租 响应后,会立即发起一个新的KeepAlive请求,再由Master进行阻塞。因此我们可以看出,在正常运行过程中,每一个Chubby客户端总是会有一个KeepAlive 请求阻塞在Master服务器上。

除了为客户端进行会话续租外,Master还将通过KeepAlive响应来传递Chubby事件通知和缓存过期通知给客户端。具体的,如果Master发现服务端已经触发了 针对该客户端的事件通知或缓存过期通知,那么会提前将KeepAlive响应反馈给客户端。

1.4.8 会话超时

谈到会话租期,Chubby的客户端也会维持一个和Master端近似相同的会话租期。为什么是近似相同呢?这是因为客户端必须考虑两方面的因素:一方面,KeepAlive 响应在网络传输过程中会花费一定的时间;另一方面,Master服务端和Chubby客户端存在时钟不一致性现象。因此在Chubby会话中,存在Master端会话租期和客户端本地 会话租期。

如果Chubby客户端在运行过程中,按照本地的会话租期超时时间,检测到期会话租期已经过期却尚未接收到Master的KeepAlive响应,那么这个时候,它将无法确定Master 服务端是否已经中止了当前会话,我们称这个时候客户端处于“危险状态”。此时,Chubby客户端会清空其本地缓存,并将其标记为不可用。同时,客户端还会等待一个被 称作“宽限期”的时间周期,这个宽限期默认是45秒。如果在宽限期到期前,客户端和服务端成功地进行了KeepAlive,那么客户端就会再次开启本地缓存,否则,客户端就会 认为当前会话已经过期了,从而中止本次会话。

我们再着重来看看上面提到的“危险状态”。当客户端进入上述提到的危险状态时,Chubby的客户端库会通过一个“jeopardy”事件来通知上层应用程序。如果 恢复正常,客户端同样会以一个“safe”事件来通知应用程序可以继续正常运行了。但如果客户端最终没能从危险状态中恢复过来,那么客户端会以一个“expired” 事件来通知应用程序当前Chubby会话已经超时。Chubby通过这些不同的事件类型通知,能够很好地辅助上层应用程序在不明确Chubby会话状态的情况下, 根据不同的事件类型来做出不同的处理:等待或重启。有了这样的机制保证之后,对于那些在短时间内Chubby服务不可用的场景下,客户端应用程序可以选择等待,而不是重启, 这对于那些重启整个应用程序需要花费较大代价的系统来说非常有帮助。

1.4.9 Chubby Master故障恢复

Chubby的Master服务器上运行着会话租期计时器,用来管理所有会话的生命周期。如果在运行过程中Master出现了故障,那么该计时器会停止,直到新的Master选举 产生后,计时器才会继续计时,也就是说,从旧的Master崩溃到新的Master选举产生所花费的时间将不计入会话超时的计算中,这等价于延长了客户端的会话租期。 如果新的Master在短时间内就选举产生了,那么客户端就可以在本地会话租期过期前与其创建连接。而如果Master的选举花费了较长的时间,就会导致客户端只能情况本地的缓存, 并进入宽限期进行等待。从这里我们可以看出,由于宽限期的存在,使得会话能够很好地在服务端Master转换额过程中得到维持。整个Chubby Master故障恢复过程中 服务端和客户端的交互情况:

展示了一个完整的Chubby服务端Master故障恢复过程中所触发的所有事件序列。在这整个故障恢复过程中,客户端必须使用宽限期来保证在Master转换过程完成之后, 其会话依然有效。

一开始在旧的Master服务器上维持了会话租期“lease M1”,在客户端上维持了对应的“lease C1”,同时客户端的KeepAlive请求1一直被Master阻塞着。在一段时间之后, Master向客户端反馈了KeepAlive响应2,同时开始了新的会话租期“lease M2”,而客户端在接收到该KeepAlive响应之后,立即发送了新的KeepAlive请求3,并 同时也开始了新的会话租期“lease C2”。至此,客户端和服务吨Master之间的所有交互都是正常的。但是随后,Master发生了故障,从而无法反馈客户端的KeepAlive 请求3。在这个过程中,客户端检测到会话租期“lease C2”已经过期,它会清空本地缓存,并进入宽限期。在这顿时间内,客户端无法确定Master上的会话周期 是否也已经过期,因此,它不会销毁它的本地会话,而是将所有应用程序对它的API调用都阻塞主,以避免在这个期间进行的API调用导致数据不一致现象。 同时,在客户端宽限期开始时,Chubby客户端会向上层应用程序发送一个“jeopardy”事件。一段时间后,CHubby服务端选举产生了新的Master,并为该客户端初始化 了新的会话租期“lease M3”。当客户端向新的Master发送KeepAlive请求4时,Master检测到该客户端的Master周期号已经过期,因此会在KeepAlive响应5 中拒绝这个客户端请求,并将最新的Master周期号发送给客户端。之后,客户端会携带上新的Master周期号,再次发送KeepAlive请求6给Master,最终,整个 客户端和服务端之间的会话就会再次恢复正常。

通过上面的详细介绍,不难看出,在Master转换的这段时间内,只要客户端的宽限期是够长的,那么客户端应用程序可以在没有任何察觉的情况下,实现Chubby的故障恢复, 但如果客户端的宽限期设置得比较短,那么Chubby客户端就会丢弃当前会话,并将这个异常情况通知给上层应用程序。

一旦客户端与新的Master建立上连接之后,客户端和Master之间会通过互相配合来实现对故障的平滑恢复。新的Master会设法将上一个Master服务器的内存状态构造出来。 具体的,由于本地数据库记录了每个客户端的会话信息,以及其持有的锁和临时文件等信息,因此Chubby会通过读取本地磁盘上的数据来恢复一部分状态。 总的来讲,一个新的Chubby Master服务器选举产之后,会进行如下几个主要处理。

.1 确定Master周期

Master周期用来唯一标识一个Chubby集群的Master统治轮次,以便区分不同的Master。一旦新的Master周期确定下来之后,Master就会拒绝所有携带其他Master 周期编号的客户端请求,同时告知其最新的Master周期编号,例如上述提到的KeepAlive请求4。需要注意的一点是,只要发生Master重新选举,就一定会产生新的 Master周期,即使是在选举前后Master都是同一台机器的情况下也是如此。

.2 新Master能够立即对客户端的Master寻址请求进行响应,但是不会立即开始处理客户端会话相关的请求操作。

.3 Master根据本地数据库中存储的会话和锁信息,来构建服务器的内存状态。

.4 到现在为止,Master已经能够处理客户端的KeepAlive请求了,但依然无法处理其他会话相关的操作。

.5 Master会发送一个“Master故障切换”事件给每一个会话。

客户端接收到这个事件后,会清空它的本地缓存,并警告上层应用程序可能已经丢失了别的事件,之后再向Master反馈应答。

.6 此时,Master会一直等待客户端的应答,知道每一个会话都应答了这个切换事件。

.7 在Master接收到了所有客户端的应答之后,就能够开始处理所有的请求操作了。

.8 如果客户端使用了一个在故障切换之前创建的引用,Master会重新为其创建这个引用的内存对象,并执行调用。

而如果该引用在之前的Master周期中已经被关闭了,那么它聚不能在这个Master周期内再次被重建了————这一机制就确保了即使由于网络原因使得Master接收到那些延迟或重发的网络数据包, 也不会错误地重建一个已经关闭的引用。

1.5 Paxos协议实现

Chubby服务端的基本架构大致分为三层:

最底层是容错日志系统(Fault-Tolerant Log),通过Paxos算法能够保证集群中所有机器上的日志完全一致,同时具有较好的容错性。
日志层之上是Key-value类型的容错数据库(Fault-Tolerant DB),其通过下层的日志来保证一致性和容错性。
存储层之上就是Chubby对外提供的分布式锁服务和小文件存储服务。

Paxos算法的作用就在于保证集群内各个副本节点的日志能够保持一致。Chubby事务日志中的每一个Value对应Paxos算法中的一个Instance,由于Chubby需要对外提供 不间断的服务,因此事务日志无限增长,于是在整个Chubby巡行过程中,会存在多个Paxos Instance。同时,Chubby会为每一个Paxos Instance都按序分配一个全局唯一 的Instance编号,并将其顺序写入到事务日志中去。
在多Paxos Instance的模式下,为了提升算法执行的性能,就必须选举出一个副本节点作为Paxos算法的主节点,以避免因为每一个Paxos Instance都提出提案而陷入多个Paxos Round 并存的情况。同时,Paxos会保证在Master重启或出现故障而进行切换的时候,允许出现短暂的多个Master共存却不影响副本之间的一致性。

在Paxos中,每一个Paxos Instance都需要进行一轮或多轮“Prepare->Promise->Propose->Accept”这样完整的二阶段请求过程来完成对一个提案值的选定, 而多个Instance之间是完全独立的,每个Instance可以自己决定每一个Round的序号,仅仅只需要保证在Instance内部不会出现序号重复即可。为了在保证正确性的前提下尽可能 地的提高算法运行性能,可以让多个Instance共用一套序号分配机制,并将“Prepare->Promise”合并为一个阶段,具体做法如下。

当某个副本节点通过选举成为Master后,就会使用新分配的编号N来广播一个Prepare消息,该Prepare消息会被所有未达成一致的Instance和目前还未开始的Instance共用。
当Acceptor接收到Prepare消息后,必须对多个Instance同时做出回应,这通常可以通过将反馈信息封装在一个数据包中来实现。假设最多允许K个Instance同时进行提案值的选定,那么:

当前至多存在K个未达成一致的Instance,将这些未决的Instance各自最后接收的提案值(若该提案尚未接收任何值。则使用null来代替)封装进一个数据包,并作为Promise消息返回。 同时,判断N是否大于当前Acceptor的highestPromisedNum值(当前已经接受的最大提案编号值),如果大于该值的话,那么就标记这些未决Instance和 所有未来的Instance的highestPromisedNum值为N————这样,这些未决Instance和所有未来Instance都不能再接受任何编号小于N的提案。

然后Master就可以对所有未决Instance和所有未来Instance分别执行“Propose->Accept”阶段的处理。值得注意的是,如果当前Master能够一直稳定运行的话, 那么在接下来的算法运行过程中,就不再需要进行“Prepare->Promise”的处理了。但是,一但Master发现Acceptor返回了一个Reject消息,说明集群中存在另一个Master, 并且试图使用更大的编号发送了Prepare消息。碰到这种情况,当前Master就需要重新分配新的提案编号,并再次进行“Prepare->Promise”阶段的逻辑处理。

利用上述改进的Paxos算法,在Master稳定运行的情况下,只需要使用同一个编号来依次执行每一个Instance的“Promise->Accept”阶段逻辑处理。在每个Instance 的运行过程中,一旦接收到多数派的Accept反馈后,就可以将对应的提案值写入本地事务日志并广播COMMIT消息给集群中的其他副本节点,其他副本节点在接收到这个COMMIT消息之后也会 将提案值写入到事务日志中。如果某个副本节点因为宕机或者网络原因没有接收到COMMIT消息,可以主动向集群中的其他副本节点进行查询。因此,我们可以看到,在Chubby的Paxos 算法的实现中,只要维持集群中存在多数派的机器能够正常运行,即使其他机器在任意时刻发生宕机,也能保证已经提交的提案的安全性。

至此,我们已经实现了一套满足一致性的日志副本,在此基础上就可以在上层实现一个一致的状态机副本,即容错数据库层。初期,使用Berkeley DB作为容错数据库, 这个数据库底层实现了B树数据结构,即存储大量数据的HashMap,将每一个数据节点的节点路径名作为键,同时按照节点路径名进行排序,这就能够使得兄弟节点在排序顺序中相邻, 方便对数据节点的检索。

后来,Chubby自己实现了一套更为简单的、基于日志预写和数据快照技术的底层数据复制组件。

数据快照和事务日志回放机制:集群中的某台机器在宕机重启以后,为了恢复状态机的状态,最简单的方法就是将已经记录的所有事务日志重新执行一遍。但这 会有一个明显的问题,就是如果机器上的事务日志已经积累了很多,那么恢复的时间就会非常长,因此需要定期对状态机数据做一个数据快照并将其存入磁盘, 然后就可以将数据快照点之前的事务日志清除。

通常副本节点在进行宕机后的恢复过程中,会出现磁盘未损坏和损坏两种情况。前者最为常见,一般通过磁盘上保存的数据库快照和事务日志就可以恢复到之前某个时间点的状态, 之后再向集群中其他正常运行的副本节点索取宕机后缺失的部分数据变更记录,这样即可实现宕机后的数据恢复。另外一种则是磁盘损坏,无法直接从本地数据恢复的情况, 需要从其它副本节点索取全部的状态数据。

副本节点在完成宕机重启之后,为了安全起见,不会立即参与Paxos Instance流程,而是需要等待检测到K个Paxos Instance流程陈宫完成之后才能开始参与————这样就能够保证 新分配的提案编号不会和自己以前发过的重复。

最后,为了提高整个集群的性能,还有一个改进之处在于:得益于Paxos算法的容错机制,只要任意时刻保证多数派的机器能够正常运行,那么在宕机瞬间未能真正写入到 磁盘上(只有当真正调用操作系统Flush接口后,数据才能被真正写入物理磁盘中)的那一小部分事务日志也可以通过从其它正常运行的副本上复制来进行获取,因此 不需要实时地进行事务日志的Flush操作,这可以极大地提高事务写入的效率。

1.6 Hypertable

1.6.1 概述

使用C++开发的开源、高性能、可伸缩的数据库。只支持增删改查,不支持事务。

支持对大量并发请求的处理。
支持对海量数据的管理。
扩展性良好,在保证可用性的前提下,能够通过随意添加集群中的机器来实现水平扩容。
可用性极高,具有非常好的容错性,任何节点的失效,既不会造成系统瘫痪也不会影响数据的完整性。

1.6.2 算法实现

选举Master是根据所有服务器上事务日志的更新时间来确定哪个服务器的数据最新,那么被选举的可能性就越大。

3 小结

列举了使用Paxos算法的工业实践应用,更好的理解Paxos算法。