Zookeeper是什么?
Zookeeper是一个开源的分布式协调服务框架,为分布式系统提供一致性服务
- 分布式:分布式是指将一个系统的不同组成部分分散到多个计算机节点上,这些节点可以通过网络进行通信和协作。分布式系统的设计目标是通过将任务分解到多个节点上并行处理,以提高系统的性能、可扩展性和可用性。
- 协调服务:在分布式系统中,节点之间需要进行协作和通信,以完成共同的任务。协调服务指的是为了实现节点之间的协作,提供一些机制和工具来管理节点之间的交互和通信。协调服务可以涉及共享配置信息、协调任务分配、实现一致性和同步等功能。
- 一致性服务:在分布式系统中,一致性是指在多个节点之间保持数据的一致性和正确性。由于节点之间的通信可能存在延迟和故障,数据的复制和更新可能会导致数据不一致的情况。一致性服务的目标是确保在分布式系统中的所有节点都具有相同的视图和数据状态,以确保数据的一致性和正确性。
Zookeeper的特点
- 集群:Zookeeper是一个领导者(Leader),多个跟随者(Follower)组成的集群。
- 高可用性:集群中只要有半数以上节点存活,Zookeeper集群就能正常服务。
- 全局数据一致:每个Server保存一份相同的数据副本,Client无论连接到哪个Server,数据都是一致的。
- 更新请求顺序进行:来自同一个Client的更新请求按其发送顺序依次执行。
- 数据更新原子性:一次数据更新要么成功,要么失败。
- 实时性:在一定时间范围内,Client能读到最新数据。
- 从设计模式角度来看,zk是一个基于观察者设计模式的框架,它负责管理跟存储大家都关心的数据,然后接受观察者的注册,数据反生变化zk会通知在zk上注册的观察者做出反应。
- Zookeeper是一个分布式协调系统,满足CAP定理中的CP性。
理论基础
CAP和BASE理论
CAP定理,也称为布鲁尔定理(Brewer’s Theorem),是分布式系统设计中的一个基本原理,CAP定理指出,在一个分布式系统中,无法同时满足以下三个特性:
- 一致性(Consistency):在分布式系统中的所有节点上,对于同一个数据的访问,无论访问哪个节点,都应该获得相同的数据值。换句话说,所有节点在同一时间看到的数据状态应该一致。
- 可用性(Availability):分布式系统在面对节点故障或网络分区等异常情况时,仍能够保持正常的响应和可用性。即系统需要对外提供服务,并在合理的时间内响应请求。
- 分区容忍性(Partition tolerance):分布式系统能够在节点之间出现网络分区(部分节点之间的通信中断)的情况下继续运行和提供服务。分区是指节点之间的通信链路断裂,导致节点无法直接进行通信。
CAP理论中,P
(分区容忍性)是必然要满足的,因为毕竟是分布式,不能把所有的应用全放到一个服务器里面,这样服务器是吃不消的。所以,只能从AP(可用性)和CP(一致性)中找平衡。
怎么个平衡法呢?在这种环境下出现了BASE理论:即使无法做到强一致性,但分布式系统可以根据自己的业务特点,采用适当的方式来使系统达到最终的一致性。BASE理论由:
Basically Avaliable
基本可用、Soft state
软状态、Eventually consistent
最终一致性组成。
- 基本可用(Basically Available):基本可用是指分布式系统在出现故障的时候,允许损失部分可用性,即保证核心可用。例如,电商大促时,为了应对访问量激增,部分用户可能会被引导到降级页面,服务层在该页面只提供降级服务。
- 软状态(Soft State): 软状态是指允许系统存在中间状态,而该中间状态不会影响系统整体可用性。分布式存储中一般一份数据至少会有多个副本,允许不同节点间副本同步的延时就是软状态的体现。
- 最终一致性(Eventual Consistency): 最终一致性是指系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。弱一致性和强一致性相反,最终一致性是弱一致性的一种特殊情况。
ZAP协议
ZAB(ZooKeeper Atomic Broadcast)协议即Apache ZooKeeper分布式协调服务中使用的一种原子广播协议,是ZooKeeper中实现高可用性和数据一致性的核心协议。它确保了在ZooKeeper集群中所有的事务操作都以相同的顺序被所有服务器执行,从而保证了数据的一致性。
ZAB 中三个主要的角色,Leader 领导者、Follower跟随者、Observer观察者 。
Leader
:集群中 唯一的写请求处理者 ,能够发起投票(投票也是为了进行写请求)。Follower
:能够接收客户端的请求,如果是读请求则可以自己处理,如果是写请求则要转发给Leader
。在选举过程中会参与投票,有选举权和被选举权 。Observer
:就是没有选举权和被选举权的Follower
。
ZXID(Zookeeper事务ID)和myid(服务器标识)
ZXID是ZAB协议中用于标识和排序事务的唯一标识符。所有 proposal(提议)在被提出的时候加上了ZooKeeper Transaction Id(ZXID) 。ZXID是64位的Long类型,这是保证事务的顺序一致性的关键。ZXID中高32位表示纪元epoch,低32位表示事务标识xid。你可以认为zxid越大说明存储数据越新。
- ZXID的顺序性保证了所有服务器在执行事务时的一致顺序,从而实现数据的一致性。较大的ZXID表示较新的事务。
myid(服务器标识),myid是ZooKeeper集群中每个服务器的唯一标识符,每个ZooKeeper服务器,都需要在数据文件夹下创建一个名为myid的文件,该文件包含整个ZooKeeper集群唯一的id(整数)。
- myid用于在领导者选举过程中进行投票和识别服务器的身份。
- 通过myid,ZooKeeper集群中的服务器可以互相识别和通信,实现协调和同步。
在 ZAB
协议中对 zkServer
(即上面我们说的三个角色的总称) 还有两种模式的定义,分别是 消息广播 和 崩溃恢复 。
消息广播模式
说白了就是 ZAB
协议是如何处理写请求的,上面我们不是说只有 Leader
能处理写请求嘛?那么我们的 Follower
和 Observer
是不是也需要 同步更新数据 呢?总不能数据只在 Leader
中更新了,其他角色都没有得到更新吧。
第一步肯定需要 Leader
将写请求 广播 出去呀,让 Leader
问问 Followers
是否同意更新,如果超过半数以上的同意那么就进行 Follower
和 Observer
的更新(和 Paxos
一样)。消息广播机制是通过如下图流程保证事务的顺序一致性的:
- leader从客户端收到一个写请求
- leader生成一个新的事务并为这个事务生成一个唯一的ZXID
- leader将这个事务发送给所有的follows节点,将带有 zxid 的消息作为一个提案(proposal)分发给所有 follower。
- follower节点将收到的事务请求加入到历史队列(history queue)中,当 follower 接收到 proposal,先将 proposal 写到硬盘,写硬盘成功后再向 leader 回一个 ACK
- 当leader收到大多数follower(超过一半)的ack消息,leader会向follower发送commit请求(leader自身也要提交这个事务)
- 当follower收到commit请求时,会判断该事务的ZXID是不是比历史队列中的任何事务的ZXID都小,如果是则提交事务,如果不是则等待比它更小的事务的commit(保证顺序性)
- Leader将处理结果返回给客户端
过半写成功策略:Leader节点接收到写请求后,这个Leader会将写请求广播给各个Server,各个Server会将该写请求加入历史队列,并向Leader发送ACK信息,当Leader收到一半以上的ACK消息后,说明该写操作可以执行。Leader会向各个server发送commit消息,各个server收到消息后执行commit操作。
这里要注意以下几点:
- Leader并不需要得到Observer的ACK,即Observer无投票权
- Leader不需要得到所有Follower的ACK,只要收到过半的ACK即可,同时Leader本身对自己有一个ACK
- Observer虽然无投票权,但仍须同步Leader的数据从而在处理读请求时可以返回尽可能新的数据
另外,Follower/Observer也可以接受写请求,此时:
- Follower/Observer接受写请求以后,不能直接处理,而需要将写请求转发给Leader处理
- 除了多了一步请求转发,其它流程与直接写Leader无任何区别
- Leader处理写请求是通过上面的消息广播模式,实质上最后所有的zkServer都要执行写操作,这样数据才会一致
而对于读请求,Leader/Follower/Observer都可直接处理读请求,从本地内存中读取数据并返回给客户端即可。由于处理读请求不需要各个服务器之间的交互,因此Follower/Observer越多,整体可处理的读请求量越大,也即读性能越好。
崩溃恢复模式
在ZAB(ZooKeeper Atomic Broadcast)协议中,崩溃模式(Crashed Mode)指的是ZooKeeper集群中的领导者(Leader)崩溃或失去连接,或者集群中的多数服务器同时崩溃或失去连接,导致无法选举出新的领导者的情况下的工作模式。在该模式下,集群会暂停写操作,直到新的领导者选举完成并恢复正常。
实际上,ZAB协议中的崩溃恢复过程可以简单地概括为两个阶段:选举和同步。
- 选举阶段(Leader Election):
- 当领导者崩溃或失去连接时,集群中的服务器开始进行领导者选举。
- 这个过程旨在选择一个新的领导者来取代崩溃的领导者,以维持集群的正常运行。
- 选举通常基于一定的投票算法,例如法定多数投票算法,确保选出的领导者具有足够多的支持。
- 同步阶段(Synchronization):
- 在选举完成后,新的领导者开始与其他服务器进行同步,以确保它们达到相同的状态。
- 新的领导者会将已提交的事务发送给其他服务器,使它们恢复到与领导者相同的状态。
- 这通常涉及传输和应用事务日志的过程。
Zookeeper选举机制
Leader
选举可以分为两个不同的阶段,第一个是我们提到的 Leader
宕机需要重新选举,第二则是当 Zookeeper
启动时需要进行系统的 Leader
初始化选举。下面是zkserver的几种状态:
- LOOKING 不确定Leader状态。该状态下的服务器认为当前集群中没有Leader,会发起Leader选举。
- FOLLOWING 跟随者状态。表明当前服务器角色是Follower,并且它知道Leader是谁。
- LEADING 领导者状态。表明当前服务器角色是Leader,它会维护与Follower间的心跳。
- OBSERVING 观察者状态。表明当前服务器角色是Observer,与Folower唯一的不同在于不参与选举,也不参与集群写操作时的投票。
初始化Leader选举
假设我们集群中有3台机器,那也就意味着我们需要2台同意(超过半数)。这里假设服务器1~3的myid分别为1,2,3,初始化Leader选举过程如下:
- 服务器 1 启动,发起一次选举。它会首先 投票给自己 ,投票内容为
(myid, ZXID)
,因为初始化所以ZXID
都为0,此时server1
发出的投票为(1, 0)
,即myid
为1,ZXID
为0。此时服务器 1 票数一票,不够半数以上,选举无法完成,服务器 1 状态保持为 LOOKING。 - 服务器 2 启动,再发起一次选举。服务器2首先也会将投票选给自己
(2, 0)
,并将投票信息广播出去(server1
也会,只是它那时没有其他的服务器了),server1
在收到server2
的投票信息后会将投票信息与自己的作比较。首先它会比较ZXID
,ZXID
大的优先为Leader
,如果相同则比较myid
,myid
大的优先作为Leader
。所以,此时server1
发现server2
更适合做Leader
,它就会将自己的投票信息更改为(2, 0)
然后再广播出去,之后server2
收到之后发现和自己的一样无需做更改。此时,服务器1票数0票,服务器2票数2票,投票已经超过半数,确定server2
为Leader
。服务器 1更改状态为 FOLLOWING,服务器 2 更改状态为 LEADING。 - 服务器 3 启动,发起一次选举。此时服务器 1,2已经不是 LOOKING 状态,它会直接以
FOLLOWING
的身份加入集群。
运行时Leader选举
运行时候如果Leader节点崩溃了会走崩溃恢复模式,新Leader选出前会暂停对外服务,大致可以分为四个阶段:选举、发现、同步、广播(见4.5节),此时Leader选举流程如下:
- Leader挂掉,剩下的两个
Follower
会将自己的状态 从Following
变为Looking
状态 ,每个Server会发出一个投票,第一次都是投自己,其中投票内容为(myid, ZXID)
,注意这里的zxid
可能不是0了 - 收集来自各个服务器的投票
- 处理投票,处理逻辑:优先比较ZXID,然后比较myid
- 统计投票,只要超过半数的机器接收到同样的投票信息,就可以确定leader
- 改变服务器状态Looking变为Following或Leading
- 然后依次进入发现、同步、广播阶段
举个例子来说明,假设集群有三台服务器,Leader (server2)
挂掉了,只剩下server1和server3。 server1
给自己投票为(1,99),然后广播给其他 server
,server3
首先也会给自己投票(3,95),然后也广播给其他 server
。server1
和 server3
此时会收到彼此的投票信息,和一开始选举一样,他们也会比较自己的投票和收到的投票(zxid
大的优先,如果相同那么就 myid
大的优先)。这个时候 server1
收到了 server3
的投票发现没自己的合适故不变,server3
收到 server1
的投票结果后发现比自己的合适于是更改投票为(1,99)然后广播出去,最后 server1
收到了发现自己的投票已经超过半数就把自己设为 Leader
,server3
也随之变为 Follower
。
Zookeeper数据模型
ZooKeeper 数据模型(Data model)采用层次化的多叉树形结构,每个节点上都可以存储数据,这些数据可以是数字、字符串或者是二级制序列。并且,每个节点还可以拥有 N 个子节点,最上层是根节点以/
来代表。
每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。并且,每个 znode 都一个唯一的路径标识。由于ZooKeeper 主要是用来协调服务的,而不是用来存储业务数据的,这种特性使得 Zookeeper 不能用于存放大量的数据,每个节点的存放数据上限为1M。
和文件系统一样,我们能够自由的增加、删除znode,在一个znode下增加、删除子znode,唯一的不同在于znode是可以存储数据的。默认有四种类型的znode:
- 持久化目录节点 PERSISTENT:客户端与zookeeper断开连接后,该节点依旧存在。
- 持久化顺序编号目录节点 PERSISTENT_SEQUENTIAL:客户端与zookeeper断开连接后,该节点依旧存在,只是Zookeeper给该节点名称进行顺序编号。
- 临时目录节点 EPHEMERAL:客户端与zookeeper断开连接后,该节点被删除。
- 临时顺序编号目录节点 EPHEMERAL_SEQUENTIAL:客户端与zookeeper断开连接后,该节点被删除,只是Zookeeper给该节点名称进行顺序编号。
Zookeeper的监听通知机制
Watcher 监听机制是 Zookeeper 中非常重要的特性,我们基于 Zookeeper上创建的节点,可以对这些节点绑定监听事件,比如可以监听节点数据变更、节点删除、子节点状态变更等事件,通过这个事件机制,可以基于 Zookeeper 实现分布式锁、集群管理等多种功能,它有点类似于订阅的方式,即客户端向服务端 注册 指定的 watcher
,当服务端符合了 watcher
的某些事件或要求则会 向客户端发送事件通知 ,客户端收到通知后找到自己定义的 Watcher
然后 执行相应的回调方法 。
Zookeeper的几个应用场景
1. Zookeeper分布式锁
利用Zookeeper的临时顺序节点,可以轻松实现分布式锁。
1. 获取锁
首先,在Zookeeper当中创建一个持久节点ParentLock。当第一个客户端想要获得锁时,需要在ParentLock这个节点下面创建一个临时顺序节点 Lock1。
之后,Client1查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock1是不是顺序最靠前的一个。如果是第一个节点,则成功获得锁。
这时候,如果再有一个客户端 Client2 前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock2。
Client2查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock2是不是顺序最靠前的一个,结果发现节点Lock2并不是最小的。
于是,Client2向排序仅比它靠前的节点Lock1注册Watcher,用于监听Lock1节点是否存在。这意味着Client2抢锁失败,进入了等待状态。
这时候,如果又有一个客户端Client3前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock3。
Client3查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock3是不是顺序最靠前的一个,结果同样发现节点Lock3并不是最小的。
于是,Client3向排序仅比它靠前的节点Lock2注册Watcher,用于监听Lock2节点是否存在。这意味着Client3同样抢锁失败,进入了等待状态。
这样一来,Client1得到了锁,Client2监听了Lock1,Client3监听了Lock2。这恰恰形成了一个等待队列,很像是Java当中ReentrantLock所依赖的AQS(AbstractQueuedSynchronizer)。
2. 释放锁
释放锁分为两种情况:
1)任务完成,客户端显示释放
当任务完成时,Client1会显示调用删除节点Lock1的指令。
2)任务执行过程中,客户端崩溃
获得锁的Client1在任务执行过程中,如果Duang的一声崩溃,则会断开与Zookeeper服务端的链接。根据临时节点的特性,相关联的节点Lock1会随之自动删除。
由于Client2一直监听着Lock1的存在状态,当Lock1节点被删除,Client2会立刻收到通知。这时候Client2会再次查询ParentLock下面的所有节点,确认自己创建的节点Lock2是不是目前最小的节点。如果是最小,则Client2顺理成章获得了锁。
同理,如果Client2也因为任务完成或者节点崩溃而删除了节点Lock2,那么Client3就会接到通知。
最终,Client3成功得到了锁。
使用Java的Zookeeper API实现分布式锁
-
创建Zookeeper客户端
- 导入ZooKeeper的Java客户端库。
- 创建一个ZooKeeper对象,并指定ZooKeeper服务器的连接地址、会话超时时间和一个实现了Watcher接口的对象,用于处理连接状态变化的事件。
import org.apache.zookeeper.*; public class ZooKeeperLockExample { private static final int SESSION_TIMEOUT = 5000; public static void main(String[] args) throws Exception { String connectString = "localhost:2181"; ZooKeeper zooKeeper = new ZooKeeper(connectString, SESSION_TIMEOUT, null); // 进行其他操作... } } ```
- 创建分布式锁
- 选择一个唯一的锁路径,例如
/lock
。 - 在ZooKeeper中创建一个临时有序节点,例如
/lock/node-000000001
,表示一个请求锁的节点。 - 检查是否是当前最小的序号节点,如果是,则表示该节点获得了锁;否则,等待。
public class ZooKeeperLockExample { private static final int SESSION_TIMEOUT = 5000; private static final String LOCK_PATH = "/lock"; public static void main(String[] args) throws Exception { String connectString = "localhost:2181"; ZooKeeper zooKeeper = new ZooKeeper(connectString, SESSION_TIMEOUT, null); // 创建分布式锁 String lockNodePath = zooKeeper.create(LOCK_PATH + "/node-", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL); List<String> children = zooKeeper.getChildren(LOCK_PATH, false); String minNodePath = Collections.min(children); if (lockNodePath.equals(minNodePath)) { // 当前节点获得了锁,可以进行操作 System.out.println("Acquired lock"); // 处理业务逻辑... // 释放锁 zooKeeper.delete(lockNodePath, -1); } else { // 等待锁 System.out.println("Waiting for lock"); synchronized (zooKeeper) { zooKeeper.wait(); } } // 关闭ZooKeeper连接 zooKeeper.close(); } } ```
- 释放锁
- 当节点完成操作后,删除自己创建的临时节点,释放锁。
- 唤醒等待中的节点。
public class ZooKeeperLockExample { // ... public static void main(String[] args) throws Exception { // ... if (lockNodePath.equals(minNodePath)) { // 当前节点获得了锁,可以进行操作 // ... // 释放锁 zooKeeper.delete(lockNodePath, -1); synchronized (zooKeeper) { zooKeeper.notifyAll(); // 唤醒等待的节点 } } else { // ... } // ... } } ```
ZK和Redis分布式锁的比较
下面的表格总结了Zookeeper和Redis分布式锁的优缺点:
有人说Zookeeper实现的分布式锁支持可重入,Redis实现的分布式锁不支持可重入,这是错误的观点。两者都可以在客户端实现可重入逻辑。
什么是 “可重入”,可重入就是说某个线程已经获得某个锁,可以再次获取锁而不会出现死锁
2. 数据发布/订阅
当某些数据由几个机器共享,且这些信息经常变化数据量还小的时候,这些数据就适合存储到ZK中。
- 数据存储:将数据存储到 Zookeeper 上的一个数据节点。
- 数据获取:应用在启动初始化节点从 Zookeeper 数据节点读取数据,并在该节点上注册一个数据变更 Watcher
- 数据变更:当变更数据时会更新 Zookeeper 对应节点数据,Zookeeper会将数据变更通知发到各客户端,客户端接到通知后重新读取变更后的数据即可。
3. 统一配置管理
本质上,统一配置管理和数据发布/订阅是一样的。
分布式环境下,配置文件的同步可以由Zookeeper来实现。
- 将配置文件写入Zookeeper的一个ZNode
- 各个客户端服务监听这个ZNode
- 一旦ZNode发生改变,Zookeeper将通知各个客户端服务
4. 命名服务
命名服务是指通过指定的名字来获取资源或者服务的地址,利用 zk 创建一个全局唯一的路径,这个路径就可以作为一个名字,指向集群中某个具体的服务器,提供的服务的地址,或者一个远程的对象等等。
【参考资料】
https://wxler.github.io/2021/03/01/175946/