Zookeeper的定位是分布式协调系统,可以理解为key-value内存数据库,使用Java编写。
数据模型
ZooKeeper 中的数据模型是一种树形结构,非常像电脑中的文件系统,有一个固定的根节点(/),我们可以在根节点下创建子节点,并在子节点下继续创建下一级节点,这些节点在ZooKeeper中叫做znode。ZooKeeper 树中的每一层级用斜杠(/)分隔开,且只能用绝对路径(如“get /work/task1”)的方式查询 ZooKeeper 节点。
具体的结构你可以看看下面这张图:
有如下四种节点:
- 临时节点(EPHEMERAL) :连接断开就没了,不能创建子节点,不能同名
- 临时顺序节点(EPHEMERAL_SEQUENTIAL) :连接断开就没了,不能创建子节点,同名节点会在后面添加上自增加全局递增的序号
- 持久节点(PERSISTENT):连接断开、服务端重启还在;可以创建子节点,子节点可以临时也可以持久;不能同名
- 持久顺序节点(PERSISTENT_SEQUENTIAL):连接断开、服务端重启还在;可以创建子节点,子节点可以临时也可以持久;同名节点会在后面添加上自增加全局递增的序号
也就是说,
- 持久:节点会一直存储在 ZooKeeper 服务器上,即使创建该节点的客户端与服务端的会话关闭了,该节点依然不会被删除;可以创建子节点;如果我们想删除持久节点,就要显式调用 delete 函数进行删除操作。
- 临时:节点不会一直存储在 ZooKeeper 服务器上。当创建该临时节点的客户端会话因超时或发生异常而关闭时,该节点也相应在 ZooKeeper 服务器上被删除;不可以创建子节点;同样,我们可以像删除持久节点一样主动删除临时节点。
- 顺序:节点顺序是说在我们创建顺序节点时,ZooKeeper 服务器会自动在节点路径后面添加上10位的数字(计数器)作为后缀,例如 < path >0000000001,< path >0000000002,……,这个计数器可以保证在同一个父节点下是唯一的。
这几种数据节点虽然类型不同,但 ZooKeeper 中的每个节点都维护有这些内容:一个二进制数组(byte data[]),用来存储节点的数据、ACL 访问控制信息、子节点数据(因为临时节点不允许有子节点,所以其子节点字段为 null),除此之外每个节点还有一个记录自身状态信息的字段 stat,用来存放数据版本,version(znode的版本),cversion(znode子节点的版本),aversion(znode的ACL权限控制版本)。
那么一个节点中最多能存储多大数据量吗?
由jute.maxbuffer配置控制,默认1MB(即 1,048,576 字节)
jute是Zookeeper的序列化协议。
ZooKeeper虽然提供了在节点存储数据的功能,但它并不将自己定位为一个通用的数据库,也就是说,你不应该在节点存储过多的数据。
使用命令create可以创建一个持久节点。
create /module1 module1
使用命令create加上-s参数,可以创建顺序节点,例如,
create -s /module1/app app
输出:
Created /module1/app0000000001
便创建了一个持久顺序节点 /module1/app0000000001。如果再执行此命令,则会生成节点 /module1/app0000000002。
如果在create -s再添加-e参数,则可以创建一个临时顺序节点。
使用命令创建一个节点/module1/app2,且其存储的数据为app2。
create /module1/app2 app2
Watch监听回调机制
Zookeeper 的 Watcher 机制是一种高效的事件通知机制,允许客户端监视 ZNode 的数据或状态变化。
Zookeeper 的 Watcher 事件类型主要包括以下几种:
- NodeCreated:节点被创建。
- NodeDeleted:节点被删除。
- NodeDataChanged:节点数据被修改。
- NodeChildrenChanged:子节点列表发生变化。
备注:watcher监听是一次性的,当watcher被触发之后,需要重新注册才能监听。
这是ZooKeeper最核心的机制,就是你一个客户端可以对znode进行Watcher监听,然后znode改变的时候回调通知你的这个客户端,这个是非常有用的一个功能,在分布式系统的协调中是很有必要的
分布式系统的协调需求:分布式架构中的系统A监听一个数据的变化,如果分布式架构中的系统B更新了那个数据/节点,zk反过来通知系统A这个数据的变化
典型场景
- 配置中心:分布式系统的一些配置不要写在本地磁盘文件里,可以放入zk中,你可以基于zk封装一个配置中心。业务系统就可以从配置中心(zk)获取一些配置,对配置加入监听,如果说配置变更了,立马就可以通过zk通知到所有监听配置项的系统,让他们立马根据配置做出一些变化
- 开关:例如降级,打开一个降级开关,所有系统从配置感知到到了之后,立马就可以进行降级
- 集群负载均衡:例如你有一些系统,启动可以去zk里注册自己,创建一个临时节点,ip-list服务可以被反过来通知到最新的机器list变化,做负载均衡,别的服务如果要调用你的系统,此时可以找ip-list,可以随机选择一台机器。如果你部署的系统宕机了一台,重启一台,此时zk里的临时节点就没了,此时zk通知ip-list服务,就能感知到某台机器不可以对外提供服务了,此时调用他的其他系统从ip-list服务这里就不会获取到宕机或者重启的机器了
原理
实现的方式大概就是通过客服端和服务端分别创建有观察者的信息列表。客户端调用 getData、exist 等接口进行监听时,首先将对应的 Watch 事件放到本地的 ZKWatchManager 中进行管理。服务端在接收到客户端的请求后根据请求类型判断是否含有 Watch 事件,并将对应事件放到 WatchManager 中进行管理。
在事件触发的时候服务端通过节点的路径信息查询相应的 Watch 事件通知给客户端,客户端在接收到通知后,首先查询本地的 ZKWatchManager 获得对应的 Watch 信息处理回调操作。这种设计不但实现了一个分布式环境下的观察者模式,而且通过将客户端和服务端各自处理 Watch 事件所需要的额外信息分别保存在两端,减少彼此通信的内容。大大提升了服务的处理性能。
详情请参考:
会话
参考
http://www.cyxcoder.cn/article/100.html
我们都知道Watch机制常用的场景就是:一台机器上线时在zk中写入一个临时节点,一旦该机器关闭或者宕机,临时节点自动消失,其他Watch监听该节点下线的机器就可以进行一些操作。
那么Zookeeper是如何知道客户端机器下线的呢?就是通过会话。
当会话关闭之后(客户端主动退出,或者是客户端异常断开连接),超过了会话的超时时间,服务端检测到之后就会删除该会话所创建的临时节点,触发 NodeDeleted(节点被删除)事件,通过 Watch 监控机制就可以向订阅了该事件的客户端发送通知。
所以会话在Watch中是一个比较重要的概念。
会话生命周期
ZooKeeper 会话从客户端连接到服务端时创建,经历以下阶段:
- 创建:客户端通过 ZooKeeper.connect() 建立连接,协商会话超时时间(Session Timeout)。
- 活跃:客户端通过心跳或操作维持会话存活。
- 断开:网络故障或客户端主动断开,会话进入短暂Disconnected状态(未超时前仍有效)。
- 过期:服务端在超时时间内未收到心跳,标记会话为Expired,触发资源清理。
- 关闭:客户端显式关闭连接或会话超时后,会话终止。
会话创建
ZooKeeper 的工作方式一般是通过客户端向服务端发送请求而实现的。而在一个请求的发送过程中,首先,客户端要与服务端进行连接,而一个连接就是一个会话。在 ZooKeeper 中,一个会话可以看作是一个用于表示客户端与服务器端连接的数据结构 Session。而这个数据结构由三个部分组成:分别是会话 ID(sessionID)、会话超时时间(TimeOut)、会话关闭状态(isClosing)。
- 会话 ID:会话 ID 作为一个会话的标识符,当我们创建一次会话的时候,ZooKeeper 会自动为其分配一个唯一的 ID 编码。
- 会话超时时间:一般来说,一个会话的超时时间就是指一次会话从发起后到被服务器关闭的时长。而设置会话超时时间后,服务器会参考设置的超时时间,最终计算一个服务端自己的超时时间。而这个超时时间则是最终真正用于 ZooKeeper 中服务端用户会话管理的超时时间。
- 会话关闭状态:会话关闭 isClosing 状态属性字段表示一个会话是否已经关闭。如果服务器检查到一个会话已经因为超时等原因失效时, ZooKeeper 会在该会话的 isClosing 属性值标记为关闭,再之后就不对该会话进行操作了。
会话状态
通过上面的学习,我们知道了 ZooKeeper 中一次会话的内部结构。下面我们就从系统运行的角度去分析,一次会话从创建到关闭的生命周期中都经历了哪些阶段。
上面是来自 ZooKeeper 官网的一张图片。该图片详细完整地描述了一次会话的完整生命周期。而通过该图片我们可以知道,在 ZooKeeper 服务的运行过程中,会话会经历不同的状态变化。而这些状态包括:正在连接(CONNECTING)、已经连接(CONNECTIED)、正在重新连接(RECONNECTING)、已经重新连接(RECONNECTED)、会话关闭(CLOSE)等。
当客户端开始创建一个与服务端的会话操作时,它的会话状态就会变成 CONNECTING,之后客户端会根据服务器地址列表中的服务器 IP 地址分别尝试进行连接。如果遇到一个 IP 地址可以连接到服务器,那么客户端会话状态将变为 CONNECTIED。
而如果因为网络原因造成已经连接的客户端会话断开时,客户端会重新尝试连接服务端。而对应的客户端会话状态又变成 CONNECTING ,直到该会话连接到服务端最终又变成 CONNECTIED。
在 ZooKeeper 服务的整个运行过程中,会话状态经常会在 CONNECTING 与 CONNECTIED 之间进行切换。最后,当出现超时或者客户端主动退出程序等情况时,客户端会话状态则会变为 CLOSE 状态。
会话超时
对于那些对 ZooKeeper 接触不深的开发人员来说,他们常常踩坑的地方在于,虽然设置了超时间,但是在实际服务运行的时候 ZooKeeper 并没有按照设置的超时时间来管理会话。
这是因为 ZooKeeper 实际起作用的超时时间是通过客户端和服务端协商决定。 ZooKeeper 客户端在和服务端建立连接的时候,会提交一个客户端设置的会话超时时间,而该超时时间会和服务端设置的最大超时时间和最小超时时间进行比对,如果正好在其允许的范围内,则采用客户端的超时时间管理会话。如果大于或者小于服务端设置的超时时间,则采用服务端设置的值管理会话。
会话心跳
ZooKeeper客户端会通过定期发送心跳(PING消息)来维持与服务端的会话连接,同时会根据服务端的当前时间重置与客户端的 Session 时间,更新该会话的请求延迟时间等。进而保持客户端与服务端连接状态。从中也能看出,在 ZooKeeper 的会话管理中,最主要的工作就是管理会话的过期时间。
ZooKeeper客户端通过以下两种方式维持心跳:
- 显式心跳(PING消息):
- 当客户端空闲(无读写请求)时,会主动定期发送PING消息。
- 心跳间隔通常为会话超时的1/3(例如,若超时设为30秒,则每10秒发送一次心跳)。这种设计允许最多两次心跳丢失的容错。
- 隐式心跳(普通操作):
- 客户端的任何操作(如getData、create等)都会刷新会话的活动时间,等同于心跳。因此,高频操作下无需额外PING。
开发者无需手动编码:ZooKeeper的客户端库(如Java的Curator或原生API)会自动处理心跳逻辑,开发者只需配置会话超时参数。
网络中断处理:若客户端检测到连接断开,会尝试重连并继续发送心跳。只有在整个超时期间无法恢复连接时,会话才会终止。
会话状态监听:客户端可以注册Watcher监听会话事件(如SyncConnected、Disconnected、Expired),以便在连接状态变化时采取相应措施(如重连或资源清理)。
ZooKeeper客户端并非“一直”发送心跳,而是根据会话超时时间和活动状态智能调度:
- 空闲时定期发送PING(频率≈超时时间/3)。
- 有操作时通过请求隐含心跳。
- 客户端库自动处理细节,确保会话在超时窗口内保持活跃。
这种机制在保证会话存活的同时,最小化不必要的网络开销。
分桶策略
ZooKeeper 使用分桶策略来管理会话。它将会话按照其超时时间划分到不同的 “桶” 中,每个桶代表一个特定的时间范围,超时时间相近的会话将被放在同一个“桶” 中。例如,可能会有一个桶包含超时时间在 1 - 10 秒的会话,另一个桶包含超时时间在 11 - 20 秒的会话,以此类推。
通过分桶,服务端可以更高效地检查会话是否超时。服务端只需要定期检查每个桶的过期时间,而不需要对每个会话进行单独的检查,这样可以减少检查的时间复杂度,提高系统性能。当一个桶的过期时间到达时,服务端会检查该桶内的所有会话,如果发现某个会话已经超时,就会将其标记为失效。(没有被转移走的会话全是超时的)
分桶策略的作用:
- 提高效率:如前面所述,分桶策略减少了会话超时检查的时间复杂度。如果不使用分桶策略,服务端需要对每个会话的最后活跃时间进行逐一比较,随着会话数量的增加,这种检查方式会变得非常耗时。而分桶策略通过批量检查的方式,大大提高了检查效率。
- 优化资源利用:分桶策略有助于更合理地管理系统资源。服务端可以根据不同桶的情况,合理分配资源来处理会话超时检查和其他相关操作,避免资源的浪费。
要注意的是,每次客户端和ZooKeeper服务器之间有通信时,会话就会被激活,会话的超时时间就会被重新计算,然后会话就会被放到其他的桶中。
ZooKeeper 服务没有时刻去监控每一个会话是否过期。而是通过 roundToNextInterval 函数将会话过期时间转化成心跳时间的整数倍,根据不同的过期时间段管理会话。
private long roundToNextInterval(long time) {
// 这里的意思是计算会话属于哪一个时间间隔(因为expirationInterval是区分桶的单位)
// 即计算会话属于哪一个桶
return (time / expirationInterval + 1) * expirationInterval;
}
expirationInterval,单位是毫秒,默认值是 tickTime。
如上面的代码所示,roundToNextInterval 函数的主要作用就是以向上取正的方式计算出每个会话的时间间隔,当会话的过期时间发生更新时,会根据函数计算的结果来决定它属于哪一个时间间隔。比如我们取 expirationInterval 的值为 2,会话的超时 time 为10,那么最终我们计算的 bucket 时间区间就是 12。
顺便说一句,每个桶就是一个Set,还有了另外一个Map:
HashMap sessionSets= new HashMap();
sessionSets存放超时时间到桶的映射。这样通过某个超时时间,就可以获取这个时间超时的所有会话了。这样一次可以检查多个会话。
@Override
synchronizedpublic void run() {
try {
while (running) {
currentTime =System.currentTimeMillis();
if (nextExpirationTime >currentTime) {
this.wait(nextExpirationTime - currentTime);
continue;
}
SessionSet set;
set =sessionSets.remove(nextExpirationTime);
if (set != null) {
for (SessionImpl s :set.sessions) {
setSessionClosing(s.sessionId);
expirer.expire(s);
}
}
nextExpirationTime +=expirationInterval;
}
} catch (InterruptedException e) {
handleException(this.getName(), e);
}
LOG.info("SessionTrackerImpl exited loop!");
}
从源代码中可以看出,分桶后,每次把一个桶中剩下的会话全认为是超时的即可(不超时的会话会被移走)。检查一个桶后,经过expirationInterval时间后,再检查下一个桶。
架构说明
主从架构
集群的三种角色
- Leader:集群启动自动选举一个Leader出来,只有Leader是可以写的
- Follower:只能同步数据和提供数据的读取,Leader挂了,Follower会继续选举出来Leader
- Observer:Observer节点是不参与leader选举的,它也不参与ZAB协议同步时的“过半写成功”环节,它只是单纯的接收数据,同步数据,可能数据存在一定的不一致的问题,但是是只读的,因此Observer可以在不影响写性能的情况下提升集群的读性能
Zookeeper集群中的任何一台机器都可以响应客户端的读操作,且全量数据都存在于内存中,当不是leader的服务器收到客户端事务操作,它会将其转发到Leader,让Leader进行处理。
ZooKeeper集群同一时刻只会有一个Leader,其他都是Follower或Observer。所以大家思考一个问题了:zk集群无论多少台机器,只能是一个leader进行写,单机写入最多每秒上万QPS,这是没法扩展的,所以zk是适合读多写少的场景。
对于读操作,follower起码有2个或者4个,读起码可以有每秒几万QPS,没问题,那如果读请求更多呢?此时你可以引入Observer节点,它就只是同步数据,提供读服务,可以无限的扩展机器。
如果扩展过多的follower,那么每次写操作leader都要等待过半的follower返回ack,影响性能。
一般来说,leader配合两个follower就差不多了,最多四台。
根据集群三种角色的定位,目前还有三个问题需要分析:
- 集群启动如何自动选举出来一个Leader
- Leader挂了,Follower中是如何选举出来新的Leader
- Leader和Follower之间是如何进行数据一致性同步的
服务启动时的 Leader 选举
当 ZooKeeper 集群中的三台服务器启动之后,首先会进行通信检查,如果集群中的服务器之间能够进行通信。集群中的三台机器开始尝试寻找集群中的 Leader 服务器并进行数据同步等操作。如何这时没有搜索到 Leader 服务器,说明集群中不存在 Leader 服务器。这时 ZooKeeper 集群开始发起 Leader 服务器选举。在整个 ZooKeeper 集群中 Leader 选举主要可以分为三大步骤分别是:发起投票、接收投票、统计投票。
发起投票
我们先来看一下发起投票的流程,在 ZooKeeper 服务器集群初始化启动的时候,集群中的每一台服务器都会将自己作为 Leader 服务器进行投票。也就是每次投票时,发送的服务器的 myid(服务器标识符)和 ZXID (集群投票信息标识符)等选票信息字段都指向本机服务器。 而一个投票信息就是通过这两个字段组成的。以集群中三个服务器 Serverhost1、Serverhost2、Serverhost3 为例,三个服务器的投票内容分别是:Severhost1 的投票是(1,0)、Serverhost2 服务器的投票是(2,0)、Serverhost3 服务器的投票是(3,0)。
接收投票
集群中各个服务器在发起投票的同时,也通过网络接收来自集群中其他服务器的投票信息。
在接收到网络中的投票信息后,服务器内部首先会判断该条投票信息的有效性。检查该条投票信息的时效性,是否是本轮最新的投票,并检查该条投票信息是否是处于 LOOKING 状态的服务器发出的。
统计投票
在接收到投票后,ZooKeeper 集群就该处理和统计投票结果了。对于每条接收到的投票信息,集群中的每一台服务器都会将自己的投票信息与其接收到的 ZooKeeper 集群中的其他投票信息进行对比。主要进行对比的内容是 ZXID,ZXID 数值比较大的投票信息优先作为 Leader 服务器。如果每个投票信息中的 ZXID 相同,就会接着比对投票信息中的 myid 信息字段,选举出 myid 较大的服务器作为 Leader 服务器。
拿上面列举的三个服务器组成的集群例子来说,对于 Serverhost1,服务器的投票信息是(1,0),该服务器接收到的 Serverhost2 服务器的投票信息是(2,0)。在 ZooKeeper 集群服务运行的过程中,首先会对比 ZXID,发现结果相同之后,对比 myid,发现 Serverhost2 服务器的 myid 比较大,于是更新自己的投票信息为(2,0),并重新向 ZooKeeper 集群中的服务器发送新的投票信息。而 Serverhost2 服务器则保留自身的投票信息,并重新向 ZooKeeper 集群服务器中发送投票信息。
而当每轮投票过后,ZooKeeper 服务都会统计集群中服务器的投票结果,判断是否有过半数的机器投出一样的信息。如果存在过半数投票信息指向的服务器,那么该台服务器就被选举为 Leader 服务器。比如上面我们举的例子中,ZooKeeper 集群会选举 Severhost2 服务器作为 Leader 服务器。
当 ZooKeeper 集群选举出 Leader 服务器后,ZooKeeper 集群中的服务器就开始更新自己的角色信息,除被选举成 Leader 的服务器之外,其他集群中的服务器角色变更为 Following。
服务运行时的 Leader 选举
当 ZooKeeper 集群中的 Leader 服务器发生崩溃时,集群会暂停处理事务性的会话请求,直到 ZooKeeper 集群中选举出新的 Leader 服务器。过程与初始化启动时 Leader 服务器的选举过程基本类似,更多可参考下方ZAB的崩溃恢复分析。
在这个过程中我们思考一个问题,那就是之前崩溃的 Leader 服务器是否会参与本次投票,以及是否能被重新选举为 Leader 服务器。这主要取决于在选举过程中旧的 Leader 服务器的运行状态。如果该服务器可以正常运行且可以和集群中其他服务器通信,那么该服务器也会参与新的 Leader 服务器的选举,在满足条件的情况下该台服务器也会再次被选举为新的 Leader 服务器。
ZAB
ZooKeeper 设计了一套 ZAB 协议算法来解决如下的问题:
- Leader挂了,Follower中是如何选举出来新的Leader
- Leader和Follower之间是如何进行数据一致性同步的
它的两个核心功能点是崩溃恢复和原子广播协议。
ZooKeeper Atomic Broadcast,ZooKeeper原子广播协议
在整个 ZAB 协议的底层实现中,ZooKeeper 集群主要采用主从模式(Leader/Follower)的系统架构方式来保证 ZooKeeper 集群系统的一致性。整个实现过程如下图所示,当接收到来自客户端的事务性会话请求后,系统集群采用主服务器来处理该条会话请求,经过主服务器处理的结果会通过网络发送给集群中其他从节点服务器进行数据同步操作。
以 ZooKeeper 集群为例,这个操作过程可以概括为:当 ZooKeeper 集群接收到来自客户端的事务性的会话请求后,集群中的其他 Follow 角色服务器会将该请求转发给 Leader 角色服务器进行处理。当 Leader 节点服务器在处理完该条会话请求后,会将结果通过操作日志的方式同步给集群中的 Follow 角色服务器。然后 Follow 角色服务器根据接收到的操作日志,在本地执行相关的数据处理操作,最终完成整个 ZooKeeper 集群对客户端会话的处理工作。
崩溃恢复
在介绍完 ZAB 协议在架构层面的实现逻辑后,我们不难看出整个 ZooKeeper 集群处理客户端会话的核心点在一台 Leader 服务器上。所有的业务处理和数据同步操作都要靠 Leader 服务器完成。所以我们会发现就目前介绍的 ZooKeeper 架构方式而言,极易产生单点问题,即当集群中的 Leader 发生故障的时候,整个集群就会因为缺少 Leader 服务器而无法处理来自客户端的事务性的会话请求。因此,为了解决这个问题。在 ZAB 协议中也设置了处理该问题的崩溃恢复机制。
崩溃恢复机制是保证 ZooKeeper 集群服务高可用的关键。触发 ZooKeeper 集群执行崩溃恢复的事件是集群中的 Leader 节点服务器发生了异常而无法工作,于是 Follow 服务器会通过投票来决定是否选出新的 Leader 节点服务器。
投票过程如下:当崩溃恢复机制开始的时候,整个 ZooKeeper 集群的每台 Follow 服务器会发起投票,并同步给集群中的其他 Follow 服务器。在接收到来自集群中的其他 Follow 服务器的投票信息后,集群中的每个 Follow 服务器都会与自身的投票信息进行对比,如果判断新的投票信息更合适,则采用新的投票信息作为自己的投票信息。在集群中的投票信息还没有达到超过半数原则的情况下,再进行新一轮的投票,最终当整个 ZooKeeper 集群中的 Follow 服务器超过半数投出的结果相同的时候,就会产生新的 Leader 服务器。
选票结构
介绍完整个选举 Leader 节点的过程后,我们来看一下整个投票阶段中的投票信息具有怎样的结构。以 Fast Leader Election 选举的实现方式来讲,如下图所示,一个选票的整体结果可以分为一下六个部分:
- logicClock:用来记录服务器的投票轮次。logicClock 会从 1 开始计数,每当该台服务经过一轮投票后,logicClock 的数值就会加 1 。
- state:用来标记当前服务器的状态。在 ZooKeeper 集群中一台服务器具有 LOOKING、FOLLOWING、LEADERING、OBSERVING 这四种状态。
- self_id:用来表示当前服务器的 ID 信息,该字段在 ZooKeeper 集群中主要用来作为服务器的身份标识符。
- self_zxid: 当前服务器上所保存的数据的最大事务 ID ,从 0 开始计数。
- vote_id:投票要被推举的服务器的唯一 ID 。
- vote_zxid:被推举的服务器上所保存的数据的最大事务 ID ,从 0 开始计数。
当 ZooKeeper 集群需要重新选举出新的 Leader 服务器的时候,就会根据上面介绍的投票信息内容进行对比,以找出最适合的服务器。
事务 ID
我们先来看看这个事务ID是什么,在 ZooKeeper 中,事务 ID(zxid,ZooKeeper Transaction ID)是一个全局唯一的标识符,用于标识每个事务操作。它不仅用于标识事务顺序,还用于实现一致性协议。每个事务操作(如创建、删除、更新 ZNode)都会生成一个唯一的 zxid,并且 zxid 是递增的,确保了事务操作的全局顺序。
- zxid 的结构
zxid 是一个 64 位的长整型数,其中高 32 位表示纪元(epoch),低 32 位表示事务计数器(counter)。
- 纪元(epoch):表示 Leader 的任期,每次 Leader 选举后都会增加。
- 事务计数器(counter):表示在当前纪元内的事务数,每次事务操作都会增加。
这种结构确保了在整个集群中 zxid 的唯一性和递增性。
- zxid 的生成
zxid 的生成在 Leader 服务器上进行,每次事务操作都会生成一个新的 zxid。以下是生成 zxid 的主要步骤:
- 初始化 zxid:在 Leader 选举成功后,初始化 zxid 的纪元部分。
- 生成新的 zxid:每次事务操作时,增加事务计数器部分,生成新的 zxid。
选票筛选
接下来我们再来看一下,当一台 Follow 服务器接收到网络中的其他 Follow 服务器的投票信息后,是如何进行对比来更新自己的投票信息的。Follow 服务器进行选票对比的过程,如下图所示。
首先,会对比 logicClock 服务器的投票轮次,当 logicClock 相同时,表明两张选票处于相同的投票阶段,并进入下一阶段,否则跳过。接下来再对比 vote_zxid 被选举的服务器 ID 信息,若接收到的外部投票信息中的 vote_zxid 字段较大,则将自己的票中的 vote_zxid 与 vote_myid 更新为收到的票中的 vote_zxid 与 vote_myid ,并广播出去。要是对比的结果相同,则继续对比 vote_myid 被选举服务器上所保存的最大事务 ID ,若外部投票的 vote_myid 比较大,则将自己的票中的 vote_myid 更新为收到的票中的 vote_myid 。 经过这些对比和替换后,最终该台 Follow 服务器会产生新的投票信息,并在下一轮的投票中发送到 ZooKeeper 集群中。
简单来说,就是比较参与投票的Follow 服务器的事务id,谁大谁占据的票数就越大,就最有可能成为Leader,因为一个Follower中事务id越大就表示数据越新。
消息广播
在 Leader 节点服务器处理请求后,需要通知集群中的其他角色服务器进行数据同步。ZooKeeper 集群采用消息广播的方式发送通知。
ZooKeeper 集群使用原子广播协议进行消息发送,该协议的底层实现过程与分布式事务中的2PC二阶段提交过程非常相似,如下图所示。
当要在集群中的其他角色服务器进行数据同步的时候,Leader 服务器将该操作过程封装成一个 Proposal 提交事务,并将其发送给集群中其他需要进行数据同步的服务器。当这些服务器接收到 Leader 服务器的数据同步事务后,会将该条事务能否在本地正常执行的结果反馈给 Leader 服务器,Leader 服务器在接收到其他 Follow 服务器的反馈信息后进行统计,判断是否在集群中执行本次事务操作。
这里请大家注意 ,与2PC(即需要集群中所有服务器都反馈可以执行事务操作后,主服务器才会认为可以提交事务,才会再次发送 commit 提交请求执行数据变更) 不同,ZAB中当 ZooKeeper 集群中有超过一般的 Follower 服务器能够正常执行事务操作后,整个 ZooKeeper 集群就可以提交 Proposal 事务了(“过半写”)。
数据存储
在 ZooKeeper 服务的运行过程中,会涉及内存数据、事务日志、数据快照这三种数据文件。从存储位置上来说,事务日志和数据快照一样,都存储在本地磁盘上;而从业务角度来讲,内存数据就是我们创建数据节点、添加监控等请求时直接操作的数据。事务日志数据主要用于记录本地事务性会话操作,用于 ZooKeeper 集群服务器之间的数据同步。事务快照则是将内存数据持久化到本地磁盘。
这里要注意的一点是,数据快照是每间隔一段时间才把内存数据存储到本地磁盘,因此数据并不会一直与内存数据保持一致。在单台 ZooKeeper 服务器运行过程中因为异常而关闭时,可能会出现数据丢失等情况。
配置目录
事务日志、数据快照文件存放地址是通过如下参数进行配置的:
- dataDir
就是把内存中的数据存储成快照文件snapshot的目录,同时myid也存储在这个目录下(myid中的内容为本机server服务的标识)。写快照不需要单独的磁盘,而且是使用后台线程进行异步写数据到磁盘,因此不会对内存数据有影响。默认情况下,事务日志也会存储在这里。建议同时配置参数dataLogDir, 事务日志的写性能直接影响zk性能。
- dataLogDir
事务日志输出目录。尽量给事务日志的输出配置单独的磁盘或是挂载点,这将极大的提升ZK性能。 由于事务日志输出时是顺序且同步写到磁盘,只有从磁盘写完日志后(forceSync)才会触发follower和leader发回事务日志确认消息(zk事务采用两阶段提交),因此需要单独磁盘避免随机读写和磁盘缓存导致事务日志写入较慢或存储在缓存中没有写入。
事务日志
事务日志记录了对Zookeeper的操作,命名为log.ZXID,后缀是一个事务ID。并且是写入该事务日志文件第一条事务记录的ZXID,使用ZXID作为文件后缀,可以帮助我们迅速定位到某一个事务操作所在的事务日志。同时,使用ZXID作为事务日志后缀的另一个优势是:ZXID本身由两部分组成,高32位代表当前leader周期(epoch),低32位则是真正的操作序列号,因此,将ZXID作为文件后缀,我们就可以清楚地看出当前运行时的zookeeper的leader周期。
事务日志的写入是采用了磁盘预分配的策略。因为事务日志的写入性能直接决定看Zookeeper服务器对事务请求的响应,也就是说事务写入可被看做是一个磁盘IO过程,所以为了提高性能,避免磁盘寻址seek所带来的性能下降,所以zk在创建事务日志的时候就会进行文件空间“预分配”,即:在文件创建之初就想操作系统预分配一个很大的磁盘块,默认是64M,而一旦已分配的文件空间不足4KB时,那么将会再次进行预分配,再申请64M空间。
事务日志写入流程
- 事务ID生成:客户端发起事务请求(如创建节点、设置数据等)时,Leader节点生成全局唯一的事务ID(ZXID),包含epoch(当前Leader任期)和计数器(保证同一Leader期间的操作顺序)
- 本地事务写入:Leader将事务日志写入本地日志文件,包含事务头信息(如操作类型、路径等)和序列化后的数据
- 事务广播:Leader将事务日志广播至所有Follower节点,Follower节点接收后会放到一个内存队列中,并写入本地日志文件,写入成功后返回确认ACK
- 事务提交确认:如果Leader收到来自多数派(即超过半数)Followers的ACK确认,它就知道这个事务已经被足够多的节点记录,它会向所有节点广播Commit指令
- 内存更新:Leader和所有收到Commit指令的Follower会正式提交事务,将数据应用到内存中的数据树中,使数据变更生效
- 返回响应:此时,客户端的写请求完成,Leader可以返回成功响应
客户端 → Follower转发 → Leader
↓
生成ZXID → 写入事务日志(磁盘)
↓
广播提案 → Followers写入事务日志(磁盘)
↓
半数ACK → 提交Commit → 更新DataTree(存)
↓
触发快照生成(异步) → 返回客户端成功
forceSync配置
默认情况下,在2PC阶段的第一个阶段里,各个机器把事务日志写入磁盘,此时一般进入os cache的,没有直接进入物理磁盘上去
forceSync配置:
该参数用于配置ZooKeeper服务器是否在commit事务提交的时候,将日志写入操作强制刷入磁盘(即调用java.nio.channels.FileChannel.force接口),默认情况下是“yes”,即在commit提交的时候强制把写的事务fsync到磁盘上去,否则会丢失os cache里没刷入磁盘的数据。
快照
数据快照是Zookeeper数据存储中非常核心的运行机制,数据快照用来记录Zookeeper服务器上某一时刻的全量内存数据内容,并将其写入指定的磁盘文件中。也是使用ZXID来作为文件后缀名,并没有采用磁盘预分配的策略,因此数据快照文件在一定程度上反映了当前zookeeper的全量数据大小。
针对客户端的每一次事务操作,Zookeeper都会将他们记录到事务日志中,同时也会将数据变更应用到内存数据库中,Zookeeper在进行若干次(snapCount)事务日志记录后,将内存数据库的全量数据Dump到本地文件中,这就是数据快照。
进行快照:
开始快照时,首先关闭当前日志文件(已经到了该快照的数了),重新创建一个新的日志文件,创建单独的异步线程来进行数据快照以避免影响Zookeeper主流程,从内存中获取zookeeper的全量数据和校验信息,并序列化写入到本地磁盘文件中,以本次写入的第一个事务ZXID作为后缀。
当事务日志记录的次数达到一定数量后(默认10W次),就会将内存数据库序列化一次,使其持久化保存到磁盘上,序列化后的文件称为"快照文件"。每次拍快照都会生成新的事务日志。
有了事务日志和快照,就可以让任意节点恢复到任意时间点(只要没有清理事务日志和快照)。
事务日志和数据快照是如何进行定时清理
不停的zk运行,事务日志会越来越多,不可能是无限多的,切割出来多个事务日志文件,每次你执行一次数据快照,每次都有一个独立的数据快照文件,多个事务日志文件,多个数据快照文件
默认来说没有开启定时清理数据文件的功能,一般来说要开启
autopurge.purgeInterval=1// 每1个小时清理1次
autopurge.snapRetainCount=3// 每次清理要在dataDir保留多少个快照
让它自己后台默默的自动清理掉多余的事务日志文件和数据快照文件
事务日志和数据快照配合进行数据恢复
在Zookeeper服务器启动期间,首先会进行数据初始化工作,用于将存储在磁盘上的数据文件加载到Zookeeper服务器内存中。
数据恢复时,会加载最近100个快照文件(如果没有100个,就加载全部的快照文件)。之所以要加载100个,是因为防止最近的那个快照文件不能通过校验。在逐个解析过程中,如果正确性校验通过之后,那么通常就只会解析最新的那个快照文件,但是如果校验和发现最先的那个快照文件不可用,那么就会逐个进行解析,直到将这100个文件全部解析完。如果将所有的快照文件都解析后还是无法恢复出一个完整的“DataTree”和“sessionWithTimeouts”,则认为无法从磁盘中加载数据,服务器启动失败。当基于快照文件构建了一个完整的DataTree实例和sessionWithTimeouts集合了,此时根据这个快照文件的文件名就可以解析出最新的ZXID,该ZXID代表了zookeeper开始进行数据快照的时刻,然后利用此ZXID定位到具体事务文件从哪一个开始,然后执行事务日志对应的事务,恢复到最新的状态,并得到最新的ZXID。
流程图
顺序一致性
明显,ZAB协议的过半写机制,zk一定不是强一致性,而是最终一致性。
zk官方给自己的定义:顺序一致性,即zk保证的最终一致性也叫顺序一致性,每个结点的数据都是严格按事务的发起顺序生效的。
ZooKeeper是如何保证事务顺序的呢?
通过事务ID(ZXID),由于ZXID是递增的,所以谁的ZXID越大,就表示谁的数据是最新的。所以同一任期内,ZXID是连续的,每个结点又都保存着自身最新生效的ZXID,通过对比新提案的ZXID与自身最新ZXID是否相差“1”,来保证事务严格按照顺序生效的。
我们都知道ZooKeeper集群的写入是由Leader结点协调的,真实场景下写入会有一定的并发量,那Zab协议的两阶段提交是如何保证事务严格按顺序生效的呢?在Leader在收到半数以上ACK后会将提案生效并广播给所有Follower结点的环节中,Leader为了保证提案按ZXID顺序生效,使用了一个ConcurrentHashMap,记录所有未提交的提案,命名为outstandingProposals,key为ZXID,Value为提案的信息。对outstandingProposals的访问逻辑如下:
1、每发起一个提案,会将提案的ZXID和内容放到outstandingProposals中,作为待提交的提案;
2、收到Follower的ACK信息后,根据ACK中的ZXID从outstandingProposals中找到对应的提案,对ACK计数;
3、执行tryToCommit尝试将提案提交,判断流程如下:
3.1:判断当前ZXID之前是否还有未提交提案,如果有,当前提案暂时不能提交;
3.2:判断提案是否收到半数以上ACK,如果达到半数则可以提交;
3.3:如果可以提交,将当前ZXID从outstandingProposals中清除并向Followers广播提交当前提案;
Leader是如何判断当前ZXID之前是否还有未提交提案的呢?由于前提是保证顺序提交的,所以Leader只需判断outstandingProposals里,当前ZXID的前一个ZXID是否存在,代码如下:
所以ZooKeeper是通过两阶段提交保证数据的最终一致性,并且通过严格的按照ZXID的顺序生效提案保证其顺序一致性的。
假设有两个客户端C1和C2,它们分别执行以下操作:
- C1: 创建节点/node1
- C2: 创建节点/node2
在ZooKeeper中,这两个操作会有一个全局的顺序。例如,如果C1的操作在C2的操作之前完成,那么在所有客户端看来,/node1都会先于/node2被创建。
虽然顺序一致性保证了操作的正确顺序,但它也可能对性能和可用性产生影响,因为每个操作都需要等待前一个操作完成并被所有服务器确认。
使用
使用场景
- 分布式锁:用于分布式Java业务系统的并发控制中
- 元数据管理:用于集中保存一些分布式系统的元数据
- 分布式协调:如果有人对zk中的数据做了变更,然后zk会反过来去通知其他监听这个数据的人,告诉别人这个数据变更了,比如说Master选举
思路
HA-Master选举
利用临时有序节点的特性来实现,所有参与选举的客户端在 ZooKeeper 服务器的/master节点下创建一个临时有序节点,编号最小的节点表示Master,后续的节点可以监听前一个节点的删除事件,用于触发重新选举。
集群扩容与宕机自动感知机制
集群中加入一台机器,自动在zk中写入一个znode临时节点,一旦节点关闭或者宕机,临时节点自动消失。由集群Master控制节点监听zk目录子节点变化,自动感知集群中节点的上线和下线。
哪些系统用到zk
三类系统
第一类:分布式Java业务系统,Dubbo、Spring Cloud把系统拆分成很多的服务或者是子系统,大家协调工作,完成最终的功能
第二类:开源的分布式系统
Dubbo,HBase,HDFS,Kafka,Canal,Storm,Solr
分布式集群的集中式元数据存储、Master选举实现HA架构、分布式协调和通知
Dubbo:ZooKeeper作为注册中心,分布式集群的集中式元数据存储
HBase:分布式集群的集中式元数据存储
HDFS:Master选举实现HA架构,部署主备两个NameNode,只有一个人可以通过zk选举成为Master,另外一个backup
Kafka:分布式集群的集中式元数据存储,分布式协调和通知。Kafka有多个broker,多个broker会竞争成为一个controller的角色,如果作为controller的broker挂掉了,此时它在zk里注册的一个节点会消失,其他broker瞬间会被zk反向通知这个事情,继续竞争成为新的controller
Canal:分布式集群的集中式元数据存储,Master选举实现HA架构
第三类:自研的分布式系统
如果你自己研发类似的一些分布式系统,可以考虑,你是否需要一个地方集中式存储分布式集群的元数据?是否需要一个东西辅助你进行Master选举实现HA架构?进行分布式协调通知?
如果你在自研分布式系统的时候,有类似的需求,那么就可以考虑引入ZooKeeper来满足你的需求
Java客户端
目前主流的Java客户端是Curator。
为什么说ZooKeeper不适合做注册中心?
在集群中当Leader 服务器出现网络中弄断、崩溃退出或重启等异常时,Zab协议就会进入崩溃恢复模式,选举产生新的Leader。因为zookeeper保证了数据的一致性,所以zookeeper会对集群会发起投票选举新的Leader 被调用服务,但是此过程会持续 30~120s,此过程对于高并发来说十分漫长,会导致整个注册服务的瘫痪,这是不可容忍的。
zookeeper 为了一致性牺牲了可用性,但是注册中心实际上对一致性要求并不高,不一致产生的后果也就是某个服务下线了而客户端并不知道,但是客户端通过重试其他节点就可以了。
扩展
如果想进行代码层面的了解,可以到如下博客进行学习: