ZAB协议:一个简单的全序广播协议

介绍

  • 在雅虎,我们开发了一个高性能高可靠的分布式协调服务,叫做zookeeper,它允许大规模应用执行协调任务,比如:leader推举、状态传播以及约会机制(rendezvous在交互的过程中,被协调的各方不需要事先彼此了解,甚至不必同时存在 )。这个服务实现了一个数据节点的层次空间,被称之为znodes,客户端使用它们来实现协调任务。我们发现这个服务在灵活性和性能上都非常符合雅虎公司web-scale,关键应用的产品需求。zookeeper放弃(foregoes)了锁,转而实现了零等待的共享数据对象,并且在这些数据对象上实现了操作顺序的强保证。Client库可以利用这些保证来实现它们自己的协调任务。一般来说,Zookeeper一个最主要的使用场景是应用对更新顺序比其它协调技术更重要(比如:阻塞blocking)。【这句翻译不通。。。】
  • 嵌入到Zookeeper中的协议是ZAB,它是一个有序广播协议。当需要实现客户端保证的时候,有序广播非常关键。在每一个Zookeeperserver上维护一个zookeeper状态的副本也是很有必要的。这些副本使用我们的全序广播协议来保持一致,比如:使用复制状态机。本文集中在zookeeper如何使用这个广播协议来满足需求的,并对实现进行概述。
  • 一个zookeeper服务通常包括3~7台机器;我们的实现可以支持更多的机器,但是3~7台就可以提供很好的性能和可恢复能力。client连接到任何一台机器来提供服务,并拥有zookeeper状态的一致性视图。这个服务能够在2f+1台机器的情况下,允许最多f台机器挂掉。
  • 应用广泛地使用zookeeper,它允许有几十到几千台机器同时访问,所以需要很高的吞吐量。我们已经设计zookeeper可以很好的在读写比在21之上的负载下进行工作;不过,我们也发现zookeeper的高写吞吐量也让它适合用于写多的应用当中。zookeeper通过在本地的zookeeper状态副本来提供高的读吞吐量。因此,容错和都吞吐量都会随着服务器的加入而得到水平扩展。写吞吐量不会随着服务器的加入而得到水平扩展,它的吞吐量受限于广播协议的吞吐量,因此我们需要一个高吞吐量的广播协议。(下图为zookeeper的逻辑组件)

  • 上图显示了zookeeper的逻辑组件;读请求在包含zookeeper状态的本地数据库进行处理;写请求被zookeeper从一般请求转换为幂等事务,并在生成response之前,通过zab协议进行发送。许多zookeeper的写请求都是有条件的:比如,znode只有在没有任何孩子的时候才能被删除;能够使用一个名字和紧跟它之后的序列号来创建znode;只有znode的版本是给定值的时候,才能对它进行修改;甚至无条件的写请求也会使用非幂等方式来修改元数据(比如:版本)。
  • 通过单个服务端(被称之为leader)来发送所有更新,我们可以把非幂等请求转换为幂等事务。在这篇文章中,我们使用事务这个术语来代表请求的幂等版本leader可以执行这个转换,因为它拥有数据库副本的未来状态视图,并能够计算新记录的状态。幂等事务就是这个新状态的记录。zookeeper在很多方面都充分利用了幂等事务的优势。由于我们事务的天然幂等性,使得zookeeperrecovery的时候,可以放松对广播协议的顺序需求。

需求

  • 我们假定有一个进程集合,它们实现了原子广播协议并使用这个协议。为了保证能对zookeeper的请求正确转换为幂等请求,确保在同一时刻只有一个leader是有必要的;因此我们就保证通过协议来实现这一点:同一时刻只有一个leader
  • zookeeper在广播协议上有以下需求:
    • 可靠投递:如果一个消息m,被一个server投递了;那么它最终也会被所有其它server正确投递
    • 全序:如果一个消息a在消息b之前被一个server投递;那么每一个server都会在b之前投递a
    • 因果顺序:如果消息a因为某种原因要在消息b之前被投递,那么a必须排在b之前。
  • 为了正确性,zookeeper还有额外的前缀属性prefix property)需求:如果mleader L投递的最后一个消息,那么在m之前被L提交的任何消息都已经被投递。
  • 注意一个进程可能会被多次被选举;然而为了实现前缀属性,每一次都要看成不同的leader
  • 通过以下3个保证我们能够维护正确的zookeeper数据库副本:
    • 可靠投递全序可以保证所有副本都有一致的状态
    • 因果顺序能够保证使用zab的应用看到的状态是正确的
    • leader基于接收到的请求来提交更新到数据库中
  • zab必须要考虑两种类型的因果关系
    • 如果消息ab,被同一个server发送,如果ab之前被提交,那么我们说a从因果上b之前
    • zab假定在同一时刻只有一个leader server,只有它能够提交更新请求;如果leader改变了,任何先前提交的消息都从因果上先于leader提交的请求。
  • 违反因果顺序的例子:

为了展示违反上述第2因果关系带来的问题,请看下面的例子:

  • 一个zookeeper客户端C1请求设置znode /a1,这个会转换为一个消息w1,它包括("/a", "1", 1)分别是pathvalue和最终version
  • 然后C1请求将/a设置为2,它会转换一个消息w2"/a", "2", 2
  • L1提交并投递了w1,但是它在挂掉之前,只能将w2投递给自己,而没有将w2提交给其它server
  • 一个新的L2产生了
  • 客户端C2请求将/a设置为3,条件是它的当前版本为1,它会转换为消息w3"/a", "3", 2
  • L2提交并投递了w3

在这个场景中,客户端收到对w1的成功返回消息,但是对于w2会接收到一个错误,因为leader挂掉了。如果L1最终恢复了,重新称为leader,并尝试投递消息w2,那么就会违反client请求的因果顺序,副本的状态就变得不正确了。

  • 我们的failture model带有状态的崩溃可恢复模型;我们没有假设同步时钟;但是我们的确假定了所有服务器以近似相同的频率来更新时间(我们使用了超时来检测failture)。Zab中的processes都有持久化状态,所以它们在崩溃之后再次重启时可以使用这个状态。这就意味着一个process能够拥有部分有效状态,比如:丢失了最近的事务,拥有带有问题的数据,它可能拥有之前从来没有投递的事务,现在就必须要忽略它。
  • 我们承诺了能够处理f台机器同时挂掉的情况,但是我们也要能够处理相关的可恢复failture,比如:断电。为了能够处理这类failture,我们需要在消息投递之前,将它记录到磁盘当中。【因为一些非技术的,实用的,行动导向的原因,我们不会在设计中考虑到使用UPS设备(也就是不断电),冗余/专用网络设备,NVRAMs(断电后仍然能够保持数据)等等】
  • 虽然我们没有假定存在拜占庭问题,但是我们还是会使用digests来对数据是否损坏进行检测。我们也在协议报文中增加了额外的元数据,并使用他们来检测数据是否正常;一段检测到数据被破坏,process会被中断。
  • 对于我们的应用,从操作环境和协议本身来看,实现完全可以处理拜占庭问题的系统是完全没必要的。到目前为止,我们产品中的绝大多数问题要么是因为实现的bug导致副本不一致,要么就是Zab实现范围之外的问题(但是影响了Zab),比如:网络配置
  • zookeeper使用了一个常驻内存的数据库,并在磁盘上存储事务日志和周期性的snapshotsZab的事务日志兼做数据库预先写的事务日志,因此每个事务只会写入磁盘一次。因为数据库是常驻内存的,而我们使用的是Gbit的网卡,所以瓶颈在disk I/O写上面。对于磁盘写,我们使用批量写来提升性能,因此我们会在一次写记录多个事务。这个批量写诗在副本上发生的,而不是协议上面;所以这个实现更接近于按组提交,而不是消息打包。我们选择不使用消息打包来减少延迟,但是从批量写磁盘来获得好处。
  • 因为我们是带有状态的崩溃可恢复的模型,这就意味着当一个server恢复了,它会读取它的snapshot,然后对在snapshot之后的所有已经投递的事务重新进行提交;一次,在恢复过程中,原子广播不需要确保只投递一次。
  • 我们使用的是幂等事务,一个事务的多次投递是可以的,只要在重新投递时,在顺序上有保证即可。这是对全序需求的一个放宽。特别的,如果ab之前投递,在a挂掉之后,a重新被投递,b也会在a之后重新被投递。
  • 我们还有其它性能需求:1)底延迟 2)突发的高吞吐量(有可能会发生完全的重新配置,此时写吞吐量会突然增高) 3)平滑的错误处理,如果有一台非leader机器崩溃了,并且当前还有一半以上的服务器还是正常运行的(这些服务器集合称之为quorum),此时不应该有业务中断。

为什么要使用另外一个协议

  • 可靠的广播协议在不同应用需求下有不同的语义。比如:BirmanJoseph提出的2个原语,ABCASTCBCAST,它们分别满足全序因果顺序Zab也保证满足全序因果顺序
  • 对于我们的情况,一个可选的协议是paxos;但是我们做了两个现实假设,使得我们可以简化Paxos算法,并得到很高的吞吐量。第一,Paxos容忍消息丢失和消息重排序。在两个server间使用TCP进行通信的时候,我们能够确保投递消息是以FIFO顺序传递的;这个使得我们能够确保提案的因果顺序,即使server进程还有多个未提交的消息。但是Paxos,不会直接确保因果顺序,因为它不需要FIFO channel
  • Paxos中,Proposers是提交不同实例值的agents。为了确保这个过程能够正常进行,这里必须只有一个议案提交者,否则,议案提交者可能会对某个给定实例永远竞争下去。这个唯一的议案提交者就是leader。当Paxos从一个leader失败中进行恢复,新的leader确保哪些部分投递的消息会再次进行投递,然后从老的leader放弃的instance number开始恢复提案。因为多个leader同时提交一个instance的两个值,这个导致两个问题:1)提案会冲突,Paxos使用选票来检测和解决冲突的提案。 2)只知道一个instance number已经被提交时不足够的,还需要知道提交的是什么值。Zab通过约定给定的proposal number只有一个message proposal。这么做就需要使用选票,并简化了恢复的过程。在Paxos中,如果一个server认为它是leader,它会通过选票来从前一个leader那里夺取leader权。在Zab中,新的leader不能夺取leader权限,直到有一半以上的server放弃了原来的leader
  • 获取高吞吐量的另外一种方式是限制每个广播消息的协议复杂度,比如:可以通过FSRFixed-Sequencer Ring:固定sequencer的环)。随着系统的增长,使用FSR不会使得吞吐量减少,但是延迟会随着进程的数目而增加,这个对于我们系统来说是不方便的。虚拟同步也可以在组长期保持稳定的情况下得到比较高的吞吐量。但是,任何服务器的崩溃都会触发服务的重配置,因此会导致短暂的业务暂停。还有,在这个系统中还需要有一个对所有服务器进行崩溃检测的监测器。这个监测器的可靠性对系统稳定性和重配置速度是非常关键的。基于leader的协议也依赖于崩溃检测来保持活性,但是这个崩溃监测器只需要一次监测一台服务器,这台服务器就是leader。正如我们下个部分要讨论的,我们不会使用固定的服务器集来进行写并在没有leader崩溃的情况下维护服务的可用性。
  • leader的选举是通过一个leader选举算法和leaderQuorum(一半以上的服务器集,也叫做Followers)的同步来完成的。由于leader需要管理到所有followers的消息;相对于广播协议,固定一个sequencer的决定,会导致组成集群中的所有服务器之间的负载非常不均衡。然而我们还采用这个决定,因为
    • Clients能够连接任何serverserver能够在本地对读操作进行响应,并在本地维护客户端的会话信息。Follower进程的这个操作会使得负载分布更均衡。
    • 服务器的数目是比较小的,这就意味着网络通信的开销还没有成为影响fixed sequencer协议的瓶颈。
    • 实现更复杂的协议是没有必要的,因为这个简单的协议就提供了足够的性能

协议

  • Zab协议由2个模式组成:崩溃恢复和广播。当服务启动或在一个leader崩溃之后,Zab进入崩溃恢复模式。当有个leader产生并且Quorum的服务器都和leader同步了状态之后,崩溃恢复模式结束。同步状态就是确保leadernew server有一致的状态。
  • 一旦一个leader拥有quorum个同步Followerleader就开始广播消息。正如我们在“介绍”一节中提到的,zookeeper服务本身使用一个leader来处理请求。leader也负责初始化广播协议并执行广播,任何其它服务器(不是leader)都需要首先向leader广播一个消息。通过使用从崩溃模式中选举出来的leader既负责写请求又对广播协议进行协调,我们消除了从写请求的leader到协调广播协议的leader之间的网络延迟。
  • 一旦一个leader拥有quorum个同步Followerleader就开始广播消息。如果一个Zab服务器启动上线,此时有个leader正在正常地广播消息,这个Zab服务器会使用崩溃恢复模式进行启动,以便发现leader并与之同步,然后参与到消息广播当中。服务一直处于广播模式当中,直到leader崩溃或者leader不再有Quorum个同步Follower。对于leader,任何QuorumFollower就足够维持服务正常。比如:由3个服务器组成的Zab服务,其中有一个是leader,另外2个事Follower,这个系统会进入广播模式。如果有一个Follower崩溃,此时服务不会崩溃,因为leader还是拥有一个Quorum。如果那个Follower恢复了,但是另外一个崩溃了,服务仍然会正常。

如上图:消息投递协议。协议的多个instance可以并发运行。一旦leader发出COMMIT,消息就被成功投递了。

广播

  • 原子广播服务在运行的时候(叫做广播模式),我们使用类似于一个2阶段提交协议:leader发出请求(propose),收集选票,最终进行commit。我们可以简化2阶段提交协议,由于不会发生突然中止的情形,因为Followers要么确认leaderproposalACK消息),要么和leader脱离。不存在突然中止的情况,也意味着我们一旦commitQuorumserverleader proposal ack消息之后就可以确认消息投递成功,而不用等待所有服务器的响应。这个做法简化了2阶段提交协议,但是它本身不能处理leader崩溃的情况,所以我们引入了崩溃恢复模式来处理leader崩溃。
  • 广播协议使用FIFOTCP)通道来进行通信。因为使用FIFO通道,顺序性非常容易保证。消息通过FIFO通道按顺序进行投递,只要消息按照它接收到的先后顺序进行处理即可,这样顺序就得到了维护。
  • leader对一个需要投递的消息广播一个proposal。在proposer一个消息之前,leader分配一个单调递增的唯一id(叫做zxid)。因为Zab保持了因果顺序,投递的消息按照zxid也是顺序的。将每一个消息的proposal和每个Follower的输出队列关联,然后将消息通过FIFO通道发送出去。当Follower接收到一个proposal,只要proposal被写入磁盘后,它将这个proposal写入磁盘(会进行批量写),并给leader发送一个ack。当一个leader接收到了QuorumFollowersACK消息之后,leader就会广播一个COMMIT消息,然后在本地投递这个消息(真正写入)。Followers在接收到这个COMMIT消息之后,才会真正投递这个消息(真正写入)。
  • 注意到:如果Followers彼此进行ACK消息的广播,leader就不必发送COMMIT了。但是这个修改不仅仅会提升网络的总流量,而且它需要一个完全图,而不是一个简单的星状拓扑,从启动TCP的角度来看,这个星状拓扑更易于维护。维护这张图并在客户端跟踪ACK也会给我们实现带来不可接受的复杂性。

崩溃恢复

  • 这个简单的广播协议都会工作的很好,直到leader崩溃或丢失了QuorumFollowers。为了确保正常运行,需要一个崩溃恢复的过程来选举新的leader,然后带领所有servers到一个正确的状态。为了leader选举我们需要一个算法确保选举成功,以便整个系统能够保持活性。leader选举协议不仅能够让leader知道它是leader,并且能让Quorum能够同意这个决定。如果选举阶段出现错误,servers不会得到进一步运行,他们最终会超时,然后重新启动选举。在我们的实现中,我们有两个不同的选举实现。最快的完成时间只有几百毫秒(在有Quorumserver是正常的情况下)。
  • 崩溃恢复过程的部分复杂性在于在给定的时间内,可能有大量的proposals正在传输;这个最大的正在传输的proposals数目是可配置的,但是默认是1000。为了能够让这个协议正常运行,即使leader崩溃了,这里需要做两个特别的保证:我们不能遗忘已经投递的消息,我们需要遗弃已经忽略的消息。
  • 一个消息在一台机器上投递成功,必须在所有其它机器上都要投递成功,机器那台机器崩溃了。这种情况很容易发生,当leader commit了一个消息之后,在COMMIT到达任何其他server之前就崩溃了,如下图所示:

  •  

你可能感兴趣的:(ZAB协议:一个简单的全序广播协议)