Apache Zookeeper

ZooKeeper

Zookeeper是一个为分布式系统提供协调服务的多副本分布式系统。它提供如下能力:

  1. 配置管理
  2. 组管理
  3. Consensus
  4. Leader选举
  5. 集群探测
  6. 分布式锁

举例来说,假设在一个分布式系统中,会有一种配置需求,要将配置更新到一个分布式集群中的多台实例。对于这种情况,可以使用zookeeper来管理全局配置,zookeeper能够确保对配置的多个更新的一致性,当机器实例需要读取配置,直接请求zookeeper即可。

Zookeeper的特性

  1. wait-free

    不像Chubby那样使用阻塞原语(locks),Zookeeper从设计之初就强调使用非阻塞原语来完成协调工作,带来的效果是延迟更低,吞吐更高。

  2. FIFO以及Linearizable writes

    同一个客户端的全部请求(读和写)是FIFO有序的,这使得客户端的请求可以通过异步的方式发送到server,带来更高的吞吐率,这种策略在某些初始化场合(需要发起大量的请求)会特别有效。 另外,为了确保全局数据的一致性,多个客户端的不同更新请求采用了leader-based原子广播(Zab)算法保证updates全局有序性。Zk的每个写请求对应一个版本号的概念,只有在要set的数据的版本号等于预期的版本号,才能set成功,类似于compare-and-set的原子操作。

  3. Relax read

    Zookeeper适用于读多写少的系统,因此提高读吞吐可以大大提升系统的整体吞吐。Zookeeper的server使用local的数据来服务客户端的读请求,另外也不使用Zab协议来保证全局有序性。这会带来一个问题:client读取的数据可能是陈旧的数据(因为leader的更新操作还未广播到当前client连接的server中),Zookeeper支持在read之前执行一条sync命令,来保证将pending的update请求写入到Zookeeper中。

  4. Watch for updates

    当全局存储的数据更新后,需要通知到客户端,Zk使用客户端注册watch的方式来监控全局数据的改变。例如当有其他客户端对此数据进行了更新,Zk会将update通知下发到所有watch这个数据的客户端。这种策略同样可以使用cache的更新。Client为了提高read吞吐,需要在Client端cache历史数据,而cache的数据可能会失效(其他client的更新),Zk提供的watch方式就可以协助来无效化本地的cache(类似于一种拉的方式)。不同于Zk,Chubby直接管理Client Cache,当有数据更新引起cache失效后,Chubby负责向这些客户端发起invalidate Cache请求(是一种推的方式)。如果有些客户端是慢节点,这会使得update操作的延迟很大,为了避免无限度的延迟,Chubby使用了Lease操作,但是这种也只是将延迟设置了bound而已。

Zookeeper架构

如下图所示,每个Server都是Zk的一个实例,外接多个clients。这些server中有一个leader server,用于处理leader-based更新操作。每个server维护了全局数据的内存结构,并且在磁盘上保存了事务日志以及snapshot,用于failure recovery。Zk cluster使用majority qurom,只要Zk cluster中有多于一半的机器在工作者,zk就可以正常的对外提供服务。

Zk的client通过tcp连接到某一个server上,这会创建一个session。客户端负责维护连接以及心跳,发送请求并且接收通知,当客户端断开连接后,session关闭。

Zookeeper数据管理

Zk使用类似于文件系统的树形命名空间,每个节点有一个独一无二的路径,如下图所示。不像文件系统那样,每个文件会有一个handle指示它,Zk的节点没有handle的概念,要想操作某一个Zk节点,必须使用这个节点的绝对路径。这会简化Zk的API接口,例如open,close这些API函数就不再需要。另外,Zk的树状结构没有目录的概念,每个节点都可以存储数据。

Zk节点

在ZK中有两种类型的节点,

  1. Regular

    长期节点,用户必须显式的创建以及删除它

  2. Ephemeral

    短期节点,可以显式的创建和删除,或者当session关闭的时候,自动删除。

另外,Zk节点可以有一个额外的sequantial标记,可用来创建单调递增的独一无二的节点。

ZooKeeper的API

API名称	语义
create	在名称空间树中的指定位置创建一个节点
delete	删除一个指定节点
exists	查询指定节点是否存在
get data	获取指定节点上的数据
set data	向指定节点写数据
get children	获取一个指定节点的所有子节点
sync	等待数据传播完成

Zookeeper实现

如下图所示,Zk包含多个server,每个server下都会有一个in-memroy的database,用于存储完整的数据目录树。

读写请求

当收到一个请求,如果这个请求需要协调(写请求),那么Zk使用一个agreement协议(原子广播),来将更新操作最终提交到多个server下的replicated databases中。如果这个请求是读请求,那么此client直接读取其连接的Zk server的local database。

Zk数据库以及故障恢复

前面已经多次强调,这个db是一个in-memory的db,那也就意味着我们需要一种持久化机制来恢复可能的掉电故障。Zk采用了一种常见的write-ahead log机制,先写日志到磁盘,再更新内存数据库。

当发生故障之后,单纯的从log恢复会很慢(因为需要一条条的读取log来replay log),因此Zk还另外定时维护了内存数据库的snapshots。这个snapshot比较特殊,它叫做fuzzy(失真的) snapshots,这个snapshot采用的树的深度优先遍历算法,并且在生成snapshot的时候并不会block当前的update请求,这会带来一个问题,某一个snapshot可能与实际中db的任何时刻的数据都不一样(这块好费解)。

举例:
{{:各种分布式系统:pasted:20181102-100629.png}}
从网上down下来一个图,假定它是一个内存的db,现在有如下两条更新语句,

原始的数据2的版本为v1,数据3的版本为v3。set操作要求传入new data以及new version
a:   更新左子树的节点
b:   更新右子树的节点

且这棵树的遍历从左子树开始,再到右子树。假定在命令a执行之前,左子树已经遍历完毕,也就是[1,2,4,5],然后在遍历右子树开始之前,上述的两条命令ab全部执行完毕,也就是说3已经被更新为3’,这样遍历得到的右子树的数据就是[3’, 6]。使用[1,2,4,5,3’,6]这个数据生成的snapshot并不会对应实时的内存db任何一刻的情况,因为更新操作是全局有序的,如果b命令被执行了,那么它之前的a命令肯定已经执行了。

这种情况可以通过回放命令来解决,系统会记录发起snapshot的时刻,对于上述的例子,也就是a,b执行之前,然后重新回放这两条命令。你可能有疑问,那么b命令不是相当于执行了两次吗? 确实是两次,那是因为这里面的命令执行符合幂等性

什么是幂等性?在分布式系统里,命令被一次或者多次执行,并不会改变系统状态。在函数中,指可以使用相同参数重复执行,并不会使得系统处于不一致的状态。简单说来,对于上述的命令a,不管执行多少次,都会是一致的。假设命令序列a,b,c,d,e,f,g,幂等性一个例子是在a-d之间插入任意多个e,f,g命令(等价于前面的序列插入后面的命令),都不会影响最后的执行结果,但是按道理说将d命令插入到g的后面是会影响执行结果的(等价于后面的序列插入前面的命令)?不知道这个该如何理解?经过长时间的思考,发现d命令插入到g后面也不会影响执行结果,这是因为每个命令对数据操作的时候绑定了版本号,只有在你操作的数据满足对应的版本号,此操作才会成功,如果满足版本号,那么执行的是一种完整的set操作,这个操作不是一种increase的操作,多次执行set的最终结果依然保持一致。

幂等性的额外例子:

int c;

void setValue(int v) { //符合幂等,多次set结果依然是v
    this.c = v;  
}

void incValue(int v) { //不符合幂等,多次inc之后,c的结果不一致
   this.c += v;
}

void ideIncValue(int c, int v) { // 符合幂等,只有在满足指定的条件下,才会进行inc操作
    if (this.c == c) {
        this.c += v;
    } 
    else {
       // do nothing, response with error version
    }
}

Zk原子广播

zk的server收到的客户端的所有update操作,最终都会forward到leader server。Leader将更新请求包装为一个事务,记录着数据更新后的状态,以及版本号,这个更新请求会下发到所有Followers,当多于一半的followers响应了这个请求,则请求处理成功。Zk为了保证高的throughout,会将请求以最大可能的pipeline(流水线)来执行,这样的话每个阶段都会有非常多的requests,Zk保证原子广播的请求是严格ordered,并且在发生leader切换的时候,旧leader的数据必须完全传递到新leader之后,新leader才能开启新的原子广播(同样是保证广播请求严格有序)。正常情况下,对于一个更新请求,Zab只会广播一次,但是由于Zab并没有将自己广播过的消息进行序列话,可能会出现重复广播,对于这种情况,幂等性的事务再次派上了用场,重复广播并不会改变执行结果。事实上,server在读取snapshot重启之后,会将自从snapshot之后的全部请求进行广播。

Zk的性能表现

实验环境配置:

  • 用的java ZooKeeper
  • 35台机器模拟250个并发clients,异步请求,1K数据大小
  • Zk实例独占机器,采用双核心2GHz Xeon处理器, 磁盘两块, 15K RPM.
  • Zk实例的两块硬盘一块专用于存放ZooKeeper的日志, 另一块磁盘专用于存储快照.
  • Leader不允许直连client.
     {{:各种分布式系统:pasted:20181102-112232.png}}

如上图所示,横轴为读请求量占比,纵轴为QPS,可以预见,读请求量越大,QPS越大,读写比4:1的时候性能较好(80%的点);另外在3个server的情况下,性能表现不怎么好,当达到5个server的时候,性能已经相当好了。要注意的是,server的个数并不是越多越好,因为当server越多的时候,原子广播的代价会增大,从而限制系统的吞吐率,但是会提升系统的可靠性(例如9个server的时候故障4个还可以正常工作)。


  • [1] ZooKeeper: Wait-free coordination for Internet-scale systems
  • [2] https://www.cnblogs.com/neooelric/p/9230967.html

你可能感兴趣的:(分布式系统)