一个简单的全序广播(totally ordered broadcast)协议
摘要
这是一个关于 Zookeeper 使用的全序广播协议的简短概述,这个协议称之为 Zab,它在概念上容易理解,易于实现,并具有高性能。在本文中,我们将介绍 Zookeeper 对 Zab 的需求,也会展示该协议的使用方式,并概述了该协议的工作原理。
1. 介绍
在 Yahoo,我们已经开发了一款高性能、高可用的协调服务 - Zookeeper,该服务允许大型应用程序执行诸如领导者选举(Leader Election)、状态传播(Status Propagation)和会和(Rendezvous)等协调任务。此服务实现了一个层级的数据节点空间,称之为 Znode,客户端可以使用它来自定义协调任务。我们发现该服务相当灵活,很容满足我们应用在生产需求中遇到的性能要求。Zookeeper 放弃使用锁,而是实现了无需等待(wait-free)的共享数据对象,并保证了这些对象的操作顺序。客户端利用这些保证来自定义协调任务。总体而言,Zookeeper 的主要前提之一是,对于应用程序来说,更新操作顺序比其他典型的协调技术(例如阻塞)更为重要。
Zookeeper 中集成的是一个全序广播协议:Zab。当保障客户端实现时,有序的广播至关重要,同时也需要在每个 Zookeeper 服务器上维护 Zookeeper 状态的副本,这些副本使用全序广播协议来保持一致(如状态机副本)。本文着重于 Zookeeper 对这种广播协议的需求及其实现的概述。
Zookeeper 服务通常包含三到七台机器,Zookeeper 实现可以支持更多的机器,但是三到七台计算机可以提供足够的性能和弹性。客户端可以连接到提供服务的任一台机器,并且始终可以获取一致的 Zookeeper 状态视图。该服务最多可以容忍 2f+1 台服务器 中 f 台发生崩溃故障。
应用程序中广泛使用 Zookeeper,并且成千上万的客户端同时访问它,因此我们需要高吞吐量。我们设计 Zookeeper 时采用了读写操作比高于 2:1 的工作场景,然而,我们发现 Zookeeper 的高写入吞吐量使其也适用于以写入为主的工作场景。Zookeeper 通过从每个服务器上读取 Zookeeper 状态的本地副本来提高读取吞吐量,这样,在服务中添加服务器可以扩展它的容器能力和提高读取吞吐量。写入吞吐量无法通过添加服务器来扩展,相反,它受到广播协议吞吐量的限制,因此,我们需要具有高吞吐量的广播协议。
图一显示了 Zookeeper 服务的逻辑组成,读取请求是从包含 Zookeeper 状态的本地数据库提供的,写请求经由 Zookeeper 请求转换为幂等事务,并在生成响应之前通过 Zab 发送。许多 Zookeeper 写请求本质上都是有条件的:
- 一个 Znode 节点仅在没有任何子节点的情况下才能删除;
- 创建 Znode 节点需要附加名称和序列号;
- 更改数据需要匹配预期的版本;
- 甚至无条件(non-conditional)写请求也以非幂等的方式修改元数据,例如版本号。
通过称为领导者(leader)的单个服务器发送所有更新,我们将非幂等请求转换为幂等事务。在本文中,我们使用事务(transaction)一词来表示幂等的请求。领导者可以执行转换,因为它对数据库副本的未来状态有充分的了解,并且可以计算出新纪录的状态,幂等事务是此新状态的一条记录。Zookeeper在许多方面都使用了幂等事务,这超出了本文的范围,但是事务的幂等性质也使我们可以在恢复过程中放宽对广播协议的顺序要求。
2. 需求
我们假设有一组实现并使用了原子广播协议的进程集,为了确保在 Zookeeper 中可以正确地转换幂等请求,在同一时间只能有一个领导者,因此我们实现协议时,强制规定只有这样一条进程,当我们提供协议的更多细节时,会进行更深入的讨论,Zookeeper 对广播协议提出以下需求:
- 可靠交付(Reliable delivery):假设一条消息 m,在一台服务器交付,最终它可以在所有正常的服务器交付;
- 完全有序(Total order):假设一台服务器先交付消息 a,再交付消息 b,那么最终所有的服务器都会按照顺序交付消息 a 和消息 b
- 因果有序(Causal order):假设消息 a 在因果关系上先于消息 b,那么在交付时,消息 a 必须在消息 b 之前;
为了正确起见,Zookeeper 还增加了前缀属性 - 前缀属性(Prefix property):假设消息 m 是领导者 L 交付的最后一条消息,则必须交付 L 在 m 消息之前提议的任何消息。
注意,一条进程可能会被多次选举,然而,根据前缀属性而言,每次都被视为不同的领导者。有了这三个保证,我们就可以维护 Zookeeper 数据库副本的正确定:
- 可靠性和全序性可以确保所有副本均具有一致的状态;
- 因果有序性可以确保从使用 Zab 的应用程序角度来看,副本都具有正确的状态;
- 领导者根据收到的请求建议对数据库进行更新。
需要重点观察 Zab 考虑了两种因果关系:
- 如果两个消息,a 和 b,是由同一台服务器发送的,并且 a 在 b 之前提出,则我们说 a 的因果关系在 b 之前;
- Zab 假设在同一时间只有一个领导者可以提交提案(proposal),如果领导者发生了变更,则任何先前提议的消息在因果关系上都先于新领导者提出的消息。
因果冲突示例
为了显示由于违反因果关系第2个维度而导致的问题,请考虑以下情形:
Zookeeper 客户端 C1 请求将 znode /a 设置为1,先将其转换为消息 w1("/a","1",1),这个元组中包含路径、值和 znode 的最终版本;
然后 C1 请求将 /a 设置为2,它转换成消息 w2("/a","2",2)
L1 提议并提交 w1 消息,但它只能在失败之前向其自身发送 w2 消息
新的领导者 L2 接任
客户端 C2 请求根据版本1将 /a 设置为3,该版本将转换为消息 w3("/a","3",2)
L2 提议并交付 w3
在这种情况下,客户端收到 w1 消息的成功响应,但是由于领导者失败而导致 w2 消息出现错误。如果最终 L1 恢复,重新获得领导权,并尝试为 w2 交付提议,则将违反客户端请求的因果顺序,并且副本状态将发生错误。
我们的故障模型是具有状态恢复的崩溃失败模型(crash-fail with stateful recovery),我们不需要时钟同步,但是我们需要服务器感知时间的速率大致相同(我们使用超时来检测故障)。组成 Zab 的进程具有持久化状态,因此进程可以在故障后重新启动并使用持久化状态进行恢复。这意味着进程可能只有部分有效的状态,例如丢失了最近的事务,更成问题的是,进程中可能包含了之前更早的还未交付的,但现在必须丢弃的事务。
我们有能力处理 f 个故障,同时我们还需要处理相关的可恢复故障,例如断电。为了从这些故障中恢复,我们要求在提交消息之前将消息记录磁盘介质上。
虽然我们假定不会发生拜占庭式故障(Byzantine failure),但我们还是使用摘要(digest)来检测数据是否损坏,我们还将额外的元数据添加到协议数据包中,用于完整性检查。如果检测到数据损坏或完整性检查失败,我们将终止服务器进程。
由于运行环境的独立性以及协议本身等实际问题,使得对于我们的应用来说,实现完全拜占庭容错的系统(Byzantine tolerant system)是不切实际的。这也表明,实现真正可靠的独立实现不仅需要编程资源,还需要更多其他资源。迄今为止,我们大多数生产问题都是由于实现问题影响到了所有副本,或者 Zab 实现范畴外的问题,但是同样会影响到 Zab 功能,例如网络配置错误。
Zookeeper 使用内存数据库,并将事务日志和定期快照存储在磁盘上。Zab 的事务日志也兼做了数据库写前(write-head)事务日志,因此事务将只写入磁盘一次。因为数据库是在内存中的,并且我们使用千兆网卡,因此写操作的性能瓶颈是磁盘 I/O,为了缓解磁盘 I/O 瓶颈,我们采用批处理写入事务,以便可以在一次写入磁盘时记录多个事务。这个批处理发生在副本实现层面而非协议层面,因此实现更接近于数据库的分组提交(group commit),而非消息打包(message packing)。我们不选择消息打包,这样可以最大程度减少延迟,同时仍然可以通过批量磁盘 I/O 获得打包操作的大部分好处。
除此之外我们还有其他操作需求:
- 低延迟(Low latency):Zookeeper 被应用程序广泛使用,用户期望响应时间足够短;
- 爆发性高吞吐量(Bursty high throughput):使用 Zookeeper 的应用程序通常具有以读为主的工作负载,但有时需要重整数据,会导致大的写吞吐量峰值;
- 平滑的故障处理(Smooth failure handling):如果不是领导服务器发生故障,并且仍然有足够数量的正确服务器,则不应该发生服务中断。
3. 为什么要选择新的协议
可靠的广播协议根据应用程序要求不同呈现不同的语义。例如 Birman 和 Joseph 提出了两个原语(primitives),ABCAST 和 CBCAST,它们分别满足完全有序和因果有序。Zab 同时满足了两者的需求。
在我们的案例中,有一个很好的候选协议 Paxos。Paxos具有许多重要的属性,比如当存在进程故障时仍能确保安全性;允许进程崩溃和恢复,以及允许在现实情况下在三个通信步骤内提交操作。我们看到有两个假设可以使我们简化 Paxos 并获取高吞吐量。首先,Paxos 可以容忍消息丢失和消息重新排序,通过使用 TCP 在成对的服务器之间进行通信,我们可以保证交付的消息都是以 FIFO 顺序提交的,这使我们即使在服务器进程中有多个未完成的提议消息时也能满足每个提议者间的因果关系。但是,Paxos 并不直接保证因果关系,因为它不需要 FIFO 通道。
在 Paxos 中,提议者(Proposer)是可以提交提议的代理,为了保证进度,只能有一个提议者提出提议,否则在特定情况下,提议者之间会永远保持竞争。处于激活状态的提议者称之为领导者,当 Paxos 从领导者故障中恢复后,新领导者将确保所有交付到一半的消息均已完全交付,然后从旧领导者遗留下来的实例序号恢复提议。由于多个领导者会为同一份实例提出提议,这会产生两个问题。首先,提案可能会发生冲突,Paxos 使用选票来派发和解决有冲突的提案。其次,仅知道已经提交的实例号还不够,进程还必须知道提交了哪个值。Zab 通过确保对一个给定的值只能有一条提议来避免这两个问题,这消除了投票的需要,同时简化了恢复过程,在 Paxos 中,如果服务器认为自己是领导者,它将使用更高的选票从先前的领导者手中接管领导职务。但是在 Zab 中,新的领导者无法接任领导权,直到大量的服务器放弃了先前的领导者。
获得高吞吐量的另一种方法是通过限制每个广播消息的协议消息复杂度,例如使用固定序列环(FSR),FSR 不会随着系统的增长而减少吞吐量,但是延迟会随着进程数量的增加而增加,这个不适用于我们的环境。虚拟同步(Virtual synchrony)在群组稳定了足够长时间后可以提供高吞吐量,但是,任一服务器故障都会导致服务重新配置,在重配置过程中会有短暂的服务中断。另外,在这个系统里,故障监视器(failure detector)需要监视所有服务器,这样,故障监视器的可靠性对重新配置的稳定性和速度显得尤为关键。基于领导者(Leader-based)的协议同样依赖故障监视器来保持活性,这个故障监视器一次只监视一台服务器,就是 Leader。在接下来章节的讨论中,当领导者没有发生故障时,我们就不为写入或者保持服务可用性使用固定数量的集群和群组。
根据 Defago 等人的分类,我们的协议有固定的计数器(sequencer)就是领导者。这个领导者是通过领导者选举算法选举出来的,并与其他服务器,称为追随者(follower)保持同步。由于领导者必须管理发给所有追随者的消息,所以使用固定计数器可能会造成系统的各个服务器之间负载不均衡。虽然如此,我们接受这个方法,主要有以下几个原因:
- 客户端可以连接到任何服务器,并且服务器必须在本地提供读取操作,并维护有关客户端会话的信息。追随者进程(而非领导者进程)的额外负载会使负载分布更加均匀;
- 涉及的服务器数量很少,这意味着网络通信开销不会成为影响固定计数器协议的瓶颈;
- 无需实现更复杂的方法,这种简单的方式可以提供足够的性能。
例如,使用移动计数器会增加实现的复杂性,因为我们必须处理诸如令牌丢失之类的问题。另外我们选择远离基于通信历史记录的模型,例如基于发送者(sender-based)的模型,以避免此类协议的二次消息复杂度(quadratic message complexity),最终一致性协议也有同样的问题。
使用领导者,需要我们从领导者的失败中恢复过来以保证进度,我们使用与视图变化相关的技术,例如 Keidar and Dolev 协议,与他们协议不同的是,我们不使用群组通信进行操作。如果新服务器加入或退出(可能是崩溃),那么我们不会引发视图变更,除非那是一个 Leader 崩溃的事件。
4. Zab协议
Zab 协议包括两个模式:恢复(recovery)和广播(broadcast)。服务启动时,或者领导者发生故障之后,Zab 会过渡到恢复模式。当领导者重新出现并且多数服务器与之进行同步后,恢复模式结束。状态同步保证领导者和新的服务器保持一致的状态。
当一个领导者有了多数同步的追随者,它就可以开始广播消息。就像我们在简介中提到的,Zookeeper 服务使用一个领导者处理请求。领导者就是那个通过初试话广播协议处理广播的服务器,其他服务器要发送消息的话需要将消息先发送给领导者。通过从恢复模式选出来的领导者同时担任处理写请求和协调广播协议的领导者,我们消除了从写请求领导者到广播协议领导者间的网络延迟。
如果一个 Zab 服务器在领导者正在广播的时候上线,这个服务器会以恢复模式启动,并会寻找领导者并与之同步,然后开始参与消息广播。服务会保持在广播模式,直到领导者发生故障或者它不再具有多数集合的追随者。任意多数集的追随者对领导者都是足够的,这样服务就能维持激活状态。比如一个 Zab 服务由三台服务器组成,其中一台是领导者,另外两台是追随者,然后系统进入广播模式。如果其中一台追随者死亡,也不会造成服务中断,因为领导者仍然具有一个多数集。如果这个追随者恢复而另一个死亡,那样也不会造成服务中断。
4.1 广播
在原子广播协议运行时,我们使用的协议为广播模式,类似一个简单的二段式提交(two-phase commit):领导者提出一个请求,收集投票,最后提交。图二描述了我们协议的信息流。我们可以简化二段式提交,因为我们没有中断(abort),追随者要么接受这个领导者的提案,要么放弃这个领导者。没有中断意味着我们可以在多数服务器回应(ack)时立即提交,而不用等到所有服务器响应。在这个简化的二段式提交中,系统不能自己处理领导者故障,所以我们添加了恢复模式去处理领导者故障。
这个广播协议使用 FIFO(TCP)通道进行所有通信,使用 FIFO 通道时,保证顺序一致就非常简单了,消息通过 FIFO 通道被顺序派发,只要消息以它们接收到的顺序被处理,顺序就能得到保证。
领导者为需要交付的消息广播一个提案,在发起提案之前,领导者会为它分配一个唯一的单调递增的Id,称为 zxid。因为 Zab 保证了因果有序,所以发送的消息也会根据 zxid 排序。包含信息的提案会附加到每个追随者的输出队列中,并通过 FIFO 通道发送给追随者。当追随者收到提案时,会将它写入磁盘,条件允许的话会批量写入,当提案写入到磁盘后会立即发送一个回应给领导者(acknowledgement)。当领导者从多数追随者哪里收到回应后,它会广播一条提交(commit)指令,然后在本地提交信息。当追随者从领导者那边收到提交(commit)指令时也会提交信息。
注意,如果追随者之间可以互相广播 ACK 的话,领导者并不一定要发送 COMMIT 指令,这个改动不仅会提升网络负载,它也需要比简单星状拓扑(start topology)结构更完整的通信图,星状拓扑从 TCP 连接的角度来看更容器管理。假设采用之前设想的图,并跟踪 ACK 信息,对于我们现实中的实现来说,是不可接受的复杂度。
4.2 恢复
这个简单的广播协议在领导者发生故障或失去多数的追随者前都能良好工作,为了保证进度,可以选举新领导者和使所有服务器都进入正确状态的恢复过程就显得很有必要了。对于领导者选举,我们需要一个高成功率的算法保障其处于激活态。这个领导者选举协议不仅要让领导者知道自己是领导者,也要让多数服务器赞成这个决定。如果选举在某个阶段发生错误,那么服务器不会进行下一步工作,它们最终会超时,然后重新开始领导者选举。领导者选举总共有两种实现,在大多数服务运行正常的情况下,领导者选举最快几百毫秒就能完成。
在恢复阶段完成的这段时间内,会有一些提案被交付,提案个数的最大值可以配置,默认值是1000,为了保证这个协议在领导者故障时也能正常工作,我们需要两个确切的保证:我们不会忽略任何已提交的消息,也不会保留已经丢弃的消息。
如果某条消息在一台机器交付,那么就应该在所有机器上都被交付,哪怕那台机器出现故障。这种情况很容易出现,如果领导者在提交了一条消息后,在 COMMIT 指令达到其他机器前出现故障,如图三所示,因为领导者已经提交(commit)了这条消息,客户端应该能在这条消息中看到事务的结果,所以该事务最终要发送给所有其他服务器,这样客户端才能看到一个一致的视图。
相反地,需要被丢弃的消息必须丢弃,同样这种情况也很容易出现,如果领导者生成了一个提案,然后在任何其他其他机器看到这个提案之前就出现了故障,如图三所示,没有其他服务器看到编号为3的提案,所以在图四中,当服务器1重新上线并重新集成到系统中,它需要保证把编号为3的提案丢弃。如果服务器1成为新的领导者,并在消息P1和P2被提交后提交消息3,就违反了我们的顺讯保证。
通过对领导者选举协议的简单调整就能解决记住已交付消息的问题,如果这个领导者选举协议保证新的领导者具有多数服务器中最高的提案编号,那么新选出来的领导者就会拥有所有已提交的消息。在提出新提案消息之前,新选出来的领导者要保证所有记录在它事务日志中的消息已经被交付,并被多数服务器通过。注意到新的领导者是处理最高 zxid 的服务器进程正好是一个优化,这样新选举出来的领导者不需要从追随者群组中去查找,哪个拥有最高的 zxid,并且去拉取(fetch)丢失的事务。
所有正常运行的服务器要么是一个领导者,要么就是这个领导者的追随者。领导者保证它的追随者可以看到所有提案,并且所有已通过的提案都被交付。它通过把新连接的追随者还没看到的提案(PROPOSAL)指令放到队列里,然后将从这些提案到最新提交信息的提交(COMMIT)指令记录也放到队列里来实现这个需求。当所有这些消息都放到队列中后,领导者将追随者添加到以后的 PROPOSAL 和 ACK 广播列表。
忽略那些被提出的,但是没有被交付的消息同样也很容易处理。在我们的实现中,zxid 是个64位数字,其中低32位当做一个简单的计数器,每一条提案都会增加那个计数。高32位代表轮次(epoch)。每次新的领导者选出,它会从它日志中最高的 zxid 中提取出轮次,增加轮次,并用新轮次和计数0组成新的 zxid。使用轮次去标记领导者的变更,并且让多数服务器可以感知到某台服务器是当前轮次的领导者,可以让我们避免多个领导者使用同一个 zxid 提出不同提案的情况。使用这个模式的另一个好处是我们能忽略当领导者故障时生产的实例,这样可以加速并简化恢复过程。如果一台服务器重启,并带着一条没被交付的上轮消息,它不能成为一个新的领导者,因为在任一多数服务集中,都有一个具有最新轮次的,即有最高 zxid 提案的服务器。当这个服务器以追随者的身份连接领导者时,领导者检查追随者的最大提案轮次中最后提交的消息,并让追随者清空它的事务日志直到当前轮次。在图四中,当服务器1连接上领导者时,领导者会告诉它从事务日志中清除提案3。
结束语
我们可以快速地实现这个协议,并且在生产环境上证明了它的健壮性。更为重要的是,我们也实现了高吞吐量低延迟的目标。在非饱和(non-saturated)系统中,延迟与服务器数量无关,是数据包传输延迟的4倍,通常情况以毫秒为单位。爆发性负载也得到了适当的处理,因为提案收到多数回应时,消息就会被提交。迟缓的服务器不会影响爆发性吞吐量,因为速率较快的多数服务器可以在不包含慢速服务器的前提下整体响应消息。最后,由于领导者收到多数追随者回应后就提交消息,所以只要大多数机器运行正常,追随者的故障就不会影响性能和吞吐量。
由于该协议的高效实现,我们有一个系统已经达到了每秒数万次的操作,读写负载比例达到2:1,甚至更高。这个系统已经在生产环境中使用,并且应用于大型应用,比如雅虎 crawler 和雅虎广告系统。