ZooKeeper拟解决的问题
- 能否将分布式系统中的不同的协作服务进行统一?
- 如何设计统一的API接口?
- 上层应用该如何使用?
- 为了系统的容错性,我们需要搭建N个副本机器,我们能否同样获得N倍的性能提升?
Raft机制下的性能问题
理想情况下,在分布式系统中,我们希望可以通过水平扩展集群规模来增加提升系统性能。然而,在Raft机制下水平扩展甚至会降低系统的性能。 在Raft机制下,所有的操作都需要通过Leader来进行处理,因此Leader才是影响系统性能的瓶颈节点。
ZooKeeper性能解决方案
读操作不通过Leader来进行处理,直接由local server进行回复。 但是在以下几种情况时该方法会读取到过时数据:
- local server不在最近一次写入数据的Majority中,还不知道数据进行过写入。
- local server还没有及时提交写入的数据。
- local server与Leader断开了连接。
ZooKeeper的读写分离方案还会带来一个问题就是读取操作的不一致性,比如第一次读操作是从replica server1获取到的是最新的数据,如果此时replica server1断开连接,则第二次相同的读操作获取到的可能是过时的数据,导致第二次读取的数据甚至比第一次的数据更延后!
为了解决该问题,Raft将所有的读操作都交给leader来执行,ZooKeeper则规定了独有的一致性保证。
ZooKeeper一致性保证
- Linearizable writes
– client的写操作将发送给leader
– leader将不同client的写操作进行排序,即zxid
– leader将所有的写操作按照zxid顺序同步给所有servers - FIFO client order
– 每个clinet给自己的读写操作规定一个特有的顺序,所有操作按顺序执行
– write operation将按照clinet规定的顺序执行
– 只有当该client的read operation之前的所有write operation执行完毕后,该read operation才会执行
ZooKeeper只保证了写入操作的线性一致性,并不保证读操作的强一致性。 如果client1和client2同时读取相同的数据,Zookeeper允许两个client读取的数据不一致。
ZooKeeper通过zxid来规定了写操作的顺序,并且每个client的读操作的时钟顺序与读取到的数据的zxid顺序一致,即不可能出现当前的读操作读取到的数据比之前的读操作读取到的数据过时的情况。client在进行读操作时,会记录所见到的最大的zxid,这样即使当replicated server发生故障时,client也会选择至少比自己拥有的zxid大的replciated server来处理读请求,从而保证读操作的FIFO order。
ZooKeeper配置更改
假设当ZooKeeper中产生了一个新的Leader来管理集群时,我们需要更改所有server中的相关配置,在配置更改的过程中,通常有如下两个重要需求:
- 当new leader更新配置文件时,我们不希望其它server使用正在更新中的配置文件。
- 如果new leader在配置更新完成前宕机,我们不希望其它server使用未更新完成的部分配置文件。
Zookeeper通过引ready znode来保证上述条件,当new leader在开始更新配置文件前会删除ready znode,更新完成后重新创建ready znode。集群中的server只有在看到ready znode存在时才会开始启用新配置。
基于上述机制,ZooKeeper进行配置更改的流程可以表示为如下形式。
Write order: Read order:
delete("ready")
write f1
write f2
create("ready")
exists("ready")
read f1
read f2
但是,存在一种特殊情况,如果client在leader删除ready znode前就进行了判断,则会造成不一致性问题,具体情况如下所示。
Write order: Read order:
exists("ready", watch=true)
read f1
delete("ready")
write f1
write f2
read f2
ZooKeeper通过设置watch标志位来解决这个问题。watch标志位可以检测ready znode的状态,如果ready znode发生了改变会通知client,client得到通知后得知ready znode已更新,就会重新开始进行配置更新操作。
ZooKeeper API设计
ZooKeeper将不同的application和相关配置通过树的结构组织起来,树中的节点称为znode。每个Znode中存储了配置信息等数据,znode包括三种类型:regular,ephemeral,sequential。
ZooKeeper提供了一些列的API接口以供client对znode进行操作,具体如下所示:
create(path, data, flags)
exclusive -- only first create indicates success
delete(path, version)
if znode.version = version, then delete
exists(path, watch)
watch=true means also send notification if path is later created/deleted
getData(path, watch)
setData(path, data, version)
if znode.version = version, then update
getChildren(path, watch)
sync()
sync then read ensures writes before sync are visible to same client's read
ZooKeeper API的一些设定能够有效的帮助并发时同步机制的实现:
- exclusive file creation,并发情况下,只有一次的文件创建会成功
- getData()/setData(x, version) 操作具有原子性
- 当client宕机时,相关资源会自动释放(如锁,znode等)
- sequential znode规定了多个client并发创建文件时的全局顺序
- watches机制可以监控其它client对数据的写入操作
ZooKeeper 互斥锁实现
假设现在有一个client想要实现read-and-set操作,使用API可以编写为如下形式:
x := getData("f")
setData(x + 1)
然而当多个client并发调用read-and-set操作时,由于该操作并不具有原子性,因此会出现数据错误。为了解决该问题,我们可以写成如下形式:
while true:
x, v := getData("f")
if setData(x + 1, version=v):
break
该形式将read-and-test操作封装成了一个原子操作,只有setData和getData的version number一致时,才进行修改操作。然而,该方法有一个问题就是会造成大量的CPU资源开销,client会一致重复进行getData和setData操作,从而导致大量的RPC通信开销和资源浪费。
为了提高性能,可以使用ZooKeeper来实现互斥锁优化上述代码:
acquire():
while true:
if create("lf", ephemeral=true), success
if exists("lf", watch=true)
wait for notification
release(): (voluntarily or session timeout)
delete("lf")
client通过创建lf文件来申明自己对互斥资源的修改权限,其它client则会进入阻塞状态,等到client删掉if文件触发watch机制后被唤醒,从而重新争夺互斥资源。这种方法存在的问题是会产生羊群效应,即每次释放互斥资源后,所有的client都会被唤醒并参与到竞争当中,造成大量的RPC通信开销和CPU资源浪费。
ZooKeeper巧妙地使用了sequential znode来解决上述问题,具体如下所示:
acquire():
n = create("if", EPHEMERAL|SEQUENTIAL)
c = getChildren("if", watch=false)
if n is lowest znode in c, success
p = znode in c ordered just before n
if exists(p, watch=true) wait for watch event
goto 2
release():
delete(n)
由于ZooKeeper保证了sequential file创建时地唯一顺序,因此每个文件在初始时都会创建一个if + seq number地临时znode。如果当前client所创建的znode的sequence number为最小,则该znode获取互斥资源的访问权。如果不为最小,则等待前一个sequence number的znode释放临界资源。由于sequence order的唯一性,每个client都只等待前一个client,形成了一个链式的等待结构。该方法保证了每次资源的获取只会唤醒一个client,避免了羊群效应。 可以发现,每次进行资源获取的判断时,都需要重新调用getchildren操作,这是为了防止出现需要为n-1的client出现宕机,而导致client n即使不是最低的znode也被错误唤醒的情况。