ZooKeeper
Zookeeper是一个为分布式系统提供协调服务的多副本分布式系统。它提供如下能力:
- 配置管理
- 组管理
- Consensus
- Leader选举
- 集群探测
- 分布式锁
举例来说,假设在一个分布式系统中,会有一种配置需求,要将配置更新到一个分布式集群中的多台实例。对于这种情况,可以使用zookeeper来管理全局配置,zookeeper能够确保对配置的多个更新的一致性,当机器实例需要读取配置,直接请求zookeeper即可。
Zookeeper的特性
-
wait-free
不像Chubby那样使用阻塞原语(locks),Zookeeper从设计之初就强调使用非阻塞原语来完成协调工作,带来的效果是延迟更低,吞吐更高。
-
FIFO以及Linearizable writes
同一个客户端的全部请求(读和写)是FIFO有序的,这使得客户端的请求可以通过异步的方式发送到server,带来更高的吞吐率,这种策略在某些初始化场合(需要发起大量的请求)会特别有效。 另外,为了确保全局数据的一致性,多个客户端的不同更新请求采用了leader-based原子广播(Zab)算法保证updates全局有序性。Zk的每个写请求对应一个版本号的概念,只有在要set的数据的版本号等于预期的版本号,才能set成功,类似于compare-and-set的原子操作。
-
Relax read
Zookeeper适用于读多写少的系统,因此提高读吞吐可以大大提升系统的整体吞吐。Zookeeper的server使用local的数据来服务客户端的读请求,另外也不使用Zab协议来保证全局有序性。这会带来一个问题:client读取的数据可能是陈旧的数据(因为leader的更新操作还未广播到当前client连接的server中),Zookeeper支持在read之前执行一条sync命令,来保证将pending的update请求写入到Zookeeper中。
-
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就可以正常的对外提供服务。
![](https://i-blog.csdnimg.cn/blog_migrate/d445fc139f7e4e943a9a4464dcec2bb1.png)
Zk的client通过tcp连接到某一个server上,这会创建一个session。客户端负责维护连接以及心跳,发送请求并且接收通知,当客户端断开连接后,session关闭。
Zookeeper数据管理
Zk使用类似于文件系统的树形命名空间,每个节点有一个独一无二的路径,如下图所示。不像文件系统那样,每个文件会有一个handle指示它,Zk的节点没有handle的概念,要想操作某一个Zk节点,必须使用这个节点的绝对路径。这会简化Zk的API接口,例如open,close这些API函数就不再需要。另外,Zk的树状结构没有目录的概念,每个节点都可以存储数据。
![](https://i-blog.csdnimg.cn/blog_migrate/478edaed501d89dd96068dfda38ac902.png)
Zk节点
在ZK中有两种类型的节点,
-
Regular
长期节点,用户必须显式的创建以及删除它
-
Ephemeral
短期节点,可以显式的创建和删除,或者当session关闭的时候,自动删除。
另外,Zk节点可以有一个额外的sequantial标记,可用来创建单调递增的独一无二的节点。
ZooKeeper的API
API名称 语义
create 在名称空间树中的指定位置创建一个节点
delete 删除一个指定节点
exists 查询指定节点是否存在
get data 获取指定节点上的数据
set data 向指定节点写数据
get children 获取一个指定节点的所有子节点
sync 等待数据传播完成
Zookeeper实现
如下图所示,Zk包含多个server,每个server下都会有一个in-memroy的database,用于存储完整的数据目录树。
![](https://i-blog.csdnimg.cn/blog_migrate/56e29bbb12526c97871ffe0dea8a58bd.png)
读写请求
当收到一个请求,如果这个请求需要协调(写请求),那么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: <Set 2 to be 2', v2> 更新左子树的节点
b: <Set 3 to be 3', v3> 更新右子树的节点
且这棵树的遍历从左子树开始,再到右子树。假定在命令a
执行之前,左子树已经遍历完毕,也就是[1,2,4,5],然后在遍历右子树开始之前,上述的两条命令a
,b
全部执行完毕,也就是说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.
如上图所示,横轴为读请求量占比,纵轴为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