这篇笔记围绕 ZooKeeper 在分布式系统中的定位展开,重点梳理它的数据模型、Watcher 机制、分布式锁实现思路、注册中心语义、Leader 选举以及 ZAB 一致性协议。
文章以“为什么 ZooKeeper 适合做协调而不适合做大规模业务存储”为主线,同时补上几个容易混淆的边界,例如
getChildren()返回的是什么、Watcher 为什么必须重新注册、ZooKeeper 的 CP 倾向究竟意味着什么。
参考资料:
官方文档:ZooKeeper Programmer’s Guide 、 ZooKeeper Internals 、 ZooKeeper Recipes and Solutions
官方扩展文档:ZooKeeper Observers 、 Getting Started Guide
[TOC]
分布式和集群
先明确分布式和集群的区别:
集群( Cluster ):比如现在有一个秒杀服务,并发量太大,单机系统承受不住,那么加几台机器也一样提供秒杀服务,可以说是 集群
分布式( Distributed ):同样的秒杀服务,但是将秒杀服务拆分成多个子服务,然后将子服务部署在不同的服务器上,那么这个时候是 分布式
集群确实只需要加机器即可,但是分布式需要对业务进行拆分,之后再加机器,同时需要解决分布式带来的问题:各个分布式组件之间如何协调、解耦、事务处理、配置等等
ZooKeeper 简介
在大数据和云计算盛行的今天,应用服务由很多个独立的程序组成,这些独立的程序则运行在形形色色,千变万化的一组计算机上,而如何让一个应用中的多个独立的程序协同工作是一件非常困难的事情
而 ZooKeeper 就是一个分布式的,开放源码的分布式应用程序协调服务。它使得应用开发人员可以更多的关注应用本身的逻辑,而不是协同工作上。从系统设计看,ZooKeeper 从文件系统 API 得到启发,提供一组简单的 API,使得开发人员可以实现通用的协作任务,例如选举主节点,管理组内成员的关系,管理元数据等,同时 ZooKeeper 的服务组件运行在一组专用的服务器之上,也保证了高容错性和可扩展性
可以用 ZooKeeper 来做:统一配置管理、统一命名服务、分布式锁、集群管理
ZooKeeper 是一个典型的分布式数据一致性解决方案,分布式应用程序可以基于 ZooKeeper 实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能
使用分布式系统就无法避免对节点管理的问题(需要实时感知节点的状态、对节点进行统一管理等等),而由于这些问题处理起来可能相对麻烦和提高了系统的复杂性,ZooKeeper 作为一个能够通用解决这些问题的中间件就应运而生了
-
ZooKeeper 本身就是一个分布式协调服务。只要集群中仍然保有法定多数节点(quorum),服务就能够继续对外工作。
-
为了保证高可用,通常会以集群形态部署 ZooKeeper。只要大多数节点仍然存活,就可以容忍少量节点故障而不至于整体失效。
-
ZooKeeper 将内存中的数据树作为主要读写对象,同时会把事务日志和快照落盘。这种设计带来了高吞吐和低延迟,但也决定了它更适合保存小而关键的协调元数据,而不是大对象或海量业务数据。
-
ZooKeeper 是高性能的。 在“读”多于“写”的应用程序中尤其地高性能,因为“写”会导致所有的服务器间同步状态。(“读”多于“写”是协调服务的典型场景。)
-
ZooKeeper 有临时节点的概念。当创建临时节点的客户端会话持续有效时,临时节点就会一直存在;而当会话结束或过期时,临时节点会被自动删除。持久节点则会一直存在,除非显式执行删除操作。
-
ZooKeeper 底层其实只提供了两个功能:①管理(存储、读取)用户程序提交的数据;②为用户程序提交数据节点监听服务
ZooKeeper 的使命
ZooKeeper 的主要目标,是在分布式系统中为多个任务提供协作基础设施。
例如,在典型的主从工作模式中,主节点和从节点之间需要进行协作:从节点空闲时要通知主节点可以接收任务,主节点需要把任务分配给可用从节点,同时系统通常又只允许存在一个主节点。这些都属于多进程协作问题。另外,协作并不总是表现为主节点选举或加锁这类同步原语;配置元数据本身也是一种协作信息,例如主从系统中的从节点需要知道自己当前被分配到了什么任务,并且这些信息在主节点崩溃时仍然需要保持有效。
在 ZooKeeper 之前,一些系统也会采用分布式锁管理器或分布式数据库来实现协作,例如使用数据库或 Redis 实现分布式锁。
ZooKeeper 的关键变化在于,它把关注点放在“协调原语”而不是“业务存储”上。它不直接提供现成的锁接口,也不试图做通用数据库,而是提供一组足够简单但有序的工具,让上层应用自行组合出主节点选举、成员管理、命名服务、配置发布等协作能力。
分布式系统中关键在于进程通信,其有两种选择:直接通过网络进行信息交换,或者读写某些共享存储
从机制上看,ZooKeeper 实现协作与同步原语所依赖的是共享存储模型:应用以客户端身份连接到 ZooKeeper 服务端,对共享数据树进行创建、读取、更新、删除和监听,最终借助共享状态完成进程间协作。
原语:操作系统或计算机网络用语范畴。是由若干条指令组成的,用于完成一定功能的一个过程。具有不可分割性·即原语的执行必须是连续的,在执行过程中不允许被中断
ZooKeeper 不适合的场景
整个 ZooKeeper 的服务器集群管理着应用协作的关键数据,ZooKeeper 不适合用作海量的数据存储,对于需要海量的应用数据的情况,可以使用数据库和分布式文件系统,所以在设计应用时,最佳实践是把应用数据和协同数据独立分开
ZooKeeper 数据结构
ZooKeeper 的数据结构和 Unix 文件系统非常类似,可以看做一棵树,每个节点叫做 ZNode,并且通过路径暴露操作接口。结构图如下:

ZNode 的节点类型: 新建 ZNode 时需要指定类型,不同类型决定了节点的生命周期和命名方式。通常会先从两个维度理解:
- 是否持久:持久节点(
PERSISTENT)或临时节点(EPHEMERAL) - 是否顺序:普通节点或顺序节点(
SEQUENTIAL)
具体在节点创建过程中,一般是组合使用,可以生成以下 4 种节点类型:
- 持久节点( PERSISTENT )
- 持久有序节点( PERSISTENT_SEQUENTIAL )
- 临时节点( EPHEMERAL )
- 临时有序节点( EPHEMERAL_SEQUENTIAL )
对于持久节点,只能主动调用 delete 删除。一般会用持久节点保存需要长期存在的配置、命名信息或目录结构。
临时 ZNode 会在创建它的客户端会话失效后被自动删除,因此常用于服务注册、存活探测和锁竞争。需要注意的是,临时节点不能拥有子节点。
顺序 ZNode 会在原始节点名后附加一个由 ZooKeeper 分配的、单调递增的序号,因此非常适合实现排队、选主和锁。
如果从较新的版本看,ZooKeeper 3.5+ 还引入了 CONTAINER 和 TTL 等节点类型。不过在大多数分布式协调场景里,最常使用的仍然是上面这四种经典组合。
节点类型 枚举类 org.apache.zookeeper.CreateMode
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public enum CreateMode {
// 第二个参数 持久 ;第三个参数 有序
/**
* The znode will not be automatically deleted upon client's disconnect.
* 持久节点
*/
PERSISTENT (0, false, false),
/**
* The znode will not be automatically deleted upon client's disconnect,
* and its name will be appended with a monotonically increasing number.
* 持久有序节点
*/
PERSISTENT_SEQUENTIAL (2, false, true),
/**
* The znode will be deleted upon the client's disconnect.
* 临时节点
*/
EPHEMERAL (1, true, false),
/**
* The znode will be deleted upon the client's disconnect, and its name
* will be appended with a monotonically increasing number.
* 临时有序节点
*/
EPHEMERAL_SEQUENTIAL (3, true, true);
...
...
}
API 接口
1
2
3
4
5
6
create /path data 创建一个名为/path 的 znode 节点,并包含数据 data。
delete /path 删除名为/path 的 znode。
exists /path 检查是否存在名为/path 的节点。
setData /path data
getData /path
getChildren /path
需要注意的是,ZooKeeper 并不支持对单个 ZNode 做“局部更新”或“局部读取”。setData() 和 getData() 面向的是整个节点数据,而 getChildren() 返回的是子节点名称列表,不会把每个子节点的数据一并取回。也正因为如此,ZNode 更适合存放小块元数据,而不是大体积内容。
ZooKeeper 监视与通知
ZooKeeper 客户端感知数据变化并不是靠轮询,而是基于通知机制。客户端会对某个 ZNode 注册监视点(Watcher),服务端在相关事件发生时向客户端发送通知。需要强调的是,Watcher 是一次性触发的。
常见的监听场景有两项:
- 监听 Znode 节点的数据变化
- 监听子节点的增减变化
Watcher 在实践里有三个特别容易被忽略的边界:
- Watcher 触发一次后就失效了;如果后续还要继续监听,客户端必须重新注册
- Watcher 负责通知“发生了变化”,并不直接携带完整数据,因此收到事件后通常还要再次读取节点
- 客户端断连再重连后会自动恢复一部分监听语义,但对于“节点在断连期间被创建又删除”这类瞬时事件,不能指望 Watcher 永不丢失,因此业务侧仍然要以重新读取当前状态为准
ZooKeeper 统一配置管理
项目中的数据库地址、开关参数、限流阈值等配置,如果全部写死在本地配置文件中,一旦进入集群部署,就会带来统一修改和动态生效的问题。统一配置管理正是 ZooKeeper 的典型应用场景之一。
例如有三个系统 A、B、C,它们分别维护 ASystem.yml、BSystem.yml、CSystem.yml 三份配置,而其中很多配置项是相同的。
此时,只要某一份公共配置发生变化,其他系统通常也要同步修改,甚至还要配合重启才能生效。
一种常见做法是把这些公共配置抽取为一份 common.yml,并把它放到 ZooKeeper 中集中维护;应用启动后读取该配置,并通过 Watcher 感知变更,以便在不重启的前提下完成动态刷新。
解决思路
-
把公共配置抽取出来
-
对公共配置进行维护
-
修改公共配置后应用不需要重新部署

做法是把 common.yml 对应的配置内容写入某个 ZNode,系统 A、B、C 订阅这个节点的数据变化,一旦配置被更新,就重新加载最新内容。

下面的代码只演示原理,使用的客户端是 zkclient:
配置文件实体
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package com.nchu.zk.util;
import java.io.Serializable;
public class Config implements Serializable{
private static final long serialVersionUID = 1L;
private String userName;
private String userPassword;
public Config() {
}
public Config(String userName, String userPassword) {
this.userName = userName;
this.userPassword = userPassword;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getUserPassword() {
return userPassword;
}
public void setUserPassword(String userPassword) {
this.userPassword = userPassword;
}
@Override
public String toString() {
return "Config [userName=" + userName + ", userPassword=" + userPassword + "]";
}
}
配置管理中心ZkConfigManager
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.nchu.zk.util;
import org.I0Itec.zkclient.ZkClient;
public class ZkConfigManager {
private Config config;
/**
* 从数据库加载配置
*/
public Config downLoadConfigFromDB(){
//getDB
config = new Config("nm", "pw");
return config;
}
/**
* 配置文件上传到数据库
*/
public void upLoadConfigToDB(String name, String password){
if(config == null) config = new Config();
config.setUserName(name);
config.setUserPassword(password);
//updateDB
}
/**
* 配置文件同步到zookeeper
*/
public void syncConfigToZk(){
ZkClient zk = new ZkClient("localhost:2181");
if (!zk.exists("/zkConfig")) {
zk.createPersistent("/zkConfig",true);
}
zk.writeData("/zkConfig", config);
zk.close();
}
}
应用监听实现ZkGetConfigClient
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.nchu.zk.util;
import org.I0Itec.zkclient.IZkDataListener;
import org.I0Itec.zkclient.ZkClient;
public class ZkGetConfigClient {
private Config config;
public Config getConfig() {
ZkClient zk = new ZkClient("localhost:2181");
config = (Config)zk.readData("/zkConfig");
System.out.println("加载到配置:"+ config.toString());
//监听配置文件修改
zk.subscribeDataChanges("/zkConfig", new IZkDataListener(){
@Override
public void handleDataChange(String arg0, Object arg1) throws Exception {
config = (Config) arg1;
System.out.println("监听到配置文件被修改:" + config.toString());
}
@Override
public void handleDataDeleted(String arg0) throws Exception {
config = null;
System.out.println("监听到配置文件被删除");
}
});
return config;
}
public static void main(String[] args) {
ZkGetConfigClient client = new ZkGetConfigClient();
client.getConfig();
System.out.println(client.config.toString());
for( int i = 0;i < 10; i++){
System.out.println(client.config.toString());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
测试,启动配置管理中心
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.nchu.zkConfig.test;
import com.nchu.zk.util.Config;
import com.nchu.zk.util.ZkConfigManager;
public class ZkConfigTest {
public static void main(String[] args) {
ZkConfigManager manager = new ZkConfigManager();
Config config = manager.downLoadConfigFromDB();
System.out.println("....加载数据库配置...." + config.toString());
manager.syncConfigToZk();
System.out.println("....同步配置文件到zookeeper....");
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
manager.upLoadConfigToDB("cwhcc", "passwordcc");
System.out.println("....修改配置文件...."+config.toString());
manager.syncConfigToZk();
System.out.println("....同步配置文件到zookeeper....");
}
}
测试结果

应用监听

ZooKeeper 统一命名服务
统一命名服务的理解其实跟域名一样,为某一部分的资源给它取一个名字,别人通过这个名字就可以拿到对应的资源
如说,现在有一个域名 www.nchu.com ,但这个域名下有多台机器
- 192.168.1.1
- 192.168.1.2
- 192.168.1.3
- 192.168.1.4
别人访问 www.nchu.com 即可访问到机器,而不是通过IP去访问

ZooKeeper 分布式锁
分布式锁的实现方式有很多种,比如 Redis、数据库、ZooKeeper 等。
如果说 Redis 的思路更像是“谁先抢到某个 key,谁就持有锁”,那么 ZooKeeper 的思路更像是“大家先到同一个目录下排队,然后按顺序获取锁”。
ZooKeeper 之所以适合做分布式锁,核心靠的是三个特性:
- 临时节点(EPHEMERAL):客户端会话断开后,节点会自动删除,锁可以自动释放
- 顺序节点(SEQUENTIAL):ZooKeeper 会给新建节点追加递增序号,天然形成一个有序队列
- Watcher 机制:没拿到锁的客户端不用一直轮询,可以在前一个节点释放时收到通知
所以,ZooKeeper 实现锁并不是简单地“建一个节点就结束了”,而是利用“临时顺序节点 + 排队 + 监听前驱节点”来完成加锁和唤醒。
最常见的实现方式:临时顺序节点实现独占锁
假设需要为某个资源加锁,可以先约定一个锁目录,比如 /locks/order-lock。这个目录本身通常是一个持久节点,真正参与竞争的是它下面的临时顺序子节点。
整个流程可以理解为下面几步:
- 客户端 A、B、C 都到
/locks/order-lock下面创建临时顺序节点,例如lock-000000、lock-000001、lock-000002 - 每个客户端创建完节点后,读取
/locks/order-lock的所有子节点,并按序号从小到大排序 - 如果发现自己创建的节点序号最小,说明自己排在队首,可以获得锁
- 如果自己不是最小节点,说明前面还有别人持有锁或排在自己前面,此时不能执行业务
- 没拿到锁的客户端不需要监听整个
/locks/order-lock,而是只监听自己前一个节点 - 当前一个节点被删除后,再重新判断自己是不是最小节点;如果是,就正式获得锁
- 业务执行完成后,主动删除自己创建的节点,释放锁;如果客户端宕机或会话过期,ZooKeeper 也会自动删除这个临时节点
sequenceDiagram
participant A as Client A
participant B as Client B
participant ZK as ZooKeeper
A->>ZK: create /locks/order-lock/lock-000000
B->>ZK: create /locks/order-lock/lock-000001
A->>ZK: getChildren(/locks/order-lock)
B->>ZK: getChildren(/locks/order-lock)
A->>A: 序号最小,获得锁
B->>ZK: watch 前驱节点 lock-000000
A->>ZK: delete lock-000000
ZK-->>B: NodeDeleted 事件
B->>ZK: 重新判断自己是否最小
B->>B: 获得锁
这种方式可以把竞争过程变成一个有序队列,因此它比“大家不断抢一个名字”更公平,也更容易避免无休止轮询。
系统 A、B、C 都去访问 /locks 节点:

访问的时候会创建带顺序号的临时节点,也就是 EPHEMERAL_SEQUENTIAL 节点。比如,系统 A 创建了 id_000000,系统 B 创建了 id_000002,系统 C 创建了 id_000001

接着,拿到 /locks 节点下的所有子节点(id_000000、id_000001、id_000002),判断自己创建的是不是最小的那个节点:
- 如果是,则拿到锁。释放锁时,执行完操作后把自己创建的节点删掉
- 如果不是,则监听排在自己前面的那个节点变化
案例:
- 系统 A 拿到
/locks节点下的所有子节点,经过比较,发现自己(id_000000)是最小节点,所以获得锁 - 系统 B 拿到
/locks节点下的所有子节点,经过比较,发现自己(id_000002)不是最小节点,所以监听排在自己前面的id_000001 - 系统 C 拿到
/locks节点下的所有子节点,经过比较,发现自己(id_000001)不是最小节点,所以监听排在自己前面的id_000000 - ……
- 等到系统 A 执行完操作以后,将自己创建的节点删除(
id_000000)。系统 C 通过监听发现id_000000已经删除,再次检查后发现自己已经是最小节点,于是顺利拿到锁 - 系统 B 依次循环
这里有一个很重要的优化点:不要让所有等待者都监听同一个节点。如果大家都监听锁目录或者都监听当前持锁节点,一旦锁释放,就可能把所有等待客户端一起唤醒,形成“羊群效应”。更合理的做法是,每个等待者只监听自己的前驱节点,这样被唤醒的通常只有下一个应该获得锁的客户端。
共享锁和独占锁也可以基于这个模型扩展
上面的流程本质上描述的是独占锁。它很好理解:谁的节点序号最小,谁就先执行。
如果要实现读写锁,也可以沿用“临时顺序节点排队”的思路:
- 读锁:如果排在自己前面的节点里没有写请求,就可以获取读锁
- 写锁:只有当自己前面没有任何节点时,才可以获取写锁
因此,ZooKeeper 的共享锁和独占锁,本质上都是在同一个有序队列上做规则判断。
Java 视角下的伪代码流程
如果从 Java 程序的角度去看,使用 ZooKeeper 获取独占锁,大致可以写成下面这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class ZkDistributedLock {
private ZooKeeper zk;
private String lockRoot = "/locks/order-lock";
private String currentNode;
public void lock() throws Exception {
// 1. 先创建一个临时顺序节点,例如 lock-0000003
currentNode = zk.create(
lockRoot + "/lock-",
new byte[0],
OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL_SEQUENTIAL
);
while (true) {
// 2. 读取锁目录下的所有子节点,并按序号排序
List<String> children = zk.getChildren(lockRoot, false);
Collections.sort(children);
String currentNodeName = currentNode.substring(currentNode.lastIndexOf("/") + 1);
int index = children.indexOf(currentNodeName);
// 3. 如果自己是最小节点,说明已经拿到锁
if (index == 0) {
return;
}
// 4. 否则只监听前一个节点,避免羊群效应
String prevNode = children.get(index - 1);
CountDownLatch latch = new CountDownLatch(1);
Stat stat = zk.exists(lockRoot + "/" + prevNode, event -> {
if (event.getType() == Watcher.Event.EventType.NodeDeleted) {
latch.countDown();
}
});
// 5. 前驱节点存在,就等待它释放;不存在则立即重试
if (stat != null) {
latch.await();
}
}
}
public void unlock() throws Exception {
// 6. 业务执行完后删除自己的节点,释放锁
if (currentNode != null) {
zk.delete(currentNode, -1);
currentNode = null;
}
}
}
这段伪代码表达的重点有三个:
create(EPHEMERAL_SEQUENTIAL):表示先去队列里领一个带序号的号getChildren() + sort():表示看看自己在队列里的位置exists() + watcher:表示如果还没轮到自己,就监听前一个节点,等它释放后再继续竞争
真正落地到项目中时,一般还会补上这些细节:
- 会话超时和重连处理,避免客户端误以为自己还持有锁
tryLock()、超时等待、可中断等待等接口封装unlock()时校验当前节点是否还是自己创建的节点- 封装成工具类,或者直接使用 Curator 这类成熟客户端来减少底层细节处理
另外还有两个工程上的边界需要注意:
- 官方 recipes 明确提到,创建临时顺序节点时需要考虑“服务端已经创建成功,但客户端还没拿到返回结果就断连”的可恢复错误,因此生产实现通常会额外引入 GUID、重试和幂等处理,而不是只写一个最短 happy path
- 示例代码里使用
OPEN_ACL_UNSAFE只是为了讲清流程,生产环境通常还要结合 ACL、认证和命名约束来避免锁目录被任意客户端误操作
方案二:简单锁
还有一种更容易理解的写法,是让多个客户端同时去创建同一个临时节点,比如 /locks/job-lock:
- 创建成功的客户端获得锁
- 创建失败的客户端说明锁已被占用,于是对这个节点注册监听
- 当节点被删除后,再重新尝试创建
这种方式实现简单,但通常不如“临时顺序节点”方案优雅。原因在于它的公平性较弱,也更容易在高并发下造成大量客户端同时被唤醒。因此在实际项目里,更常见的做法仍然是使用临时顺序节点来实现锁。
和 Redis 锁相比,ZooKeeper 锁的优势不在于“更简单”,而在于“更适合表达有序竞争关系”。另外,ZooKeeper 的临时节点会随着会话失效而自动删除,所以通常不需要像 Redis 那样重点处理“锁忘记释放”这个问题。不过业务执行时间仍然不能无限拉长,否则依然要考虑会话超时、重连和锁失效后的补偿逻辑。
ZooKeeper 架构


ZooKeeper 服务器端运行于两种模式下:
- 独立模式
- 仲裁模式
独立模式只有一个单独的服务器,ZooKeeper 状态无法复制
仲裁模式下,具有一组 ZooKeeper 服务器,称为 ZooKeeper 集合,它们之间可以进行状态的复制,并同时服务客户端的请求。不过服务器集合并不会让客户端等待每个服务器完成数据保存后再继续,而是在满足仲裁数目的服务器保存或者同步了状态就会返回给客户端
在解决这一分布式数据一致性,ZooKeeper 采用 ZAB(ZooKeeper Atomic Broadcast 原子广播协议)的一致性协议,关于 ZAB 协议后面会详细的介绍。
ZooKeeper 客户端在服务器集群中执行任何请求前必须先与服务器建立会话(session)
客户端提交给 ZooKeeper 的所有操作均关联在一个会话上。客户端初始化连接到集合中某个服务器或一个独立的服务器,客户端提供 TCP 协议与服务器进行连接并通信,但当会话无法与当前连接的服务器继续通信时,会话就可能转移到另外一个服务器,ZooKeeper 客户端透明地转移一个会话到不同的服务器。需要指明的,会话提供了顺序保障,同一个会话中的请求会以 FIFO(先进先出)顺序执行
ZooKeeper 作为注册中心与 CAP
很多人在第一次接触 ZooKeeper 时,都会把它和“注册中心”直接画等号。这个理解不能说错,但还不够完整。更准确地说,ZooKeeper 首先是一个分布式协调服务,而注册中心只是它非常常见的一种用法。
如果把 ZooKeeper 当作注册中心来理解,那么它通常会这样工作:
- 服务提供者启动时,在某个路径下创建自己的临时节点,比如
/services/order-service/instance-1 - 节点里保存自己的地址、端口、版本等元数据
- 服务消费者去读取
/services/order-service下的子节点列表,得到当前可用实例 - 同时对该目录注册 Watcher,当实例上下线时及时感知变化
这里之所以适合用临时节点,是因为实例宕机、网络断开或者会话过期后,对应节点会自动删除,这样注册信息可以随着实例生命周期自动收敛,不需要额外做很多“摘除脏实例”的补偿逻辑。
注册中心为什么也要谈 CAP?
因为注册中心本质上也是分布式系统,而分布式系统只要面临网络分区,就绕不开 CAP 的权衡:
- C(一致性):所有节点在同一时刻看到的数据尽量一致
- A(可用性):每个请求都能在有限时间内得到响应
- P(分区容错性):集群出现网络分区时,系统仍然要继续工作
在实际系统里,网络分区是无法彻底避免的,所以真正要做取舍的通常是 CP 和 AP。
ZooKeeper 更偏向 CP
如果从 CAP 的角度看,ZooKeeper 的选择通常被归类为 CP:
- 它必须先保证集群内的数据一致性,再去提供服务
- 写请求需要经过 Leader 处理,并获得法定多数节点确认
- 一旦集群失去多数派,ZooKeeper 宁可暂时不可写,甚至部分场景下不可用,也不会轻易返回一个可能冲突的数据状态
这也是为什么大家常说:ZooKeeper 是一个偏一致性的注册中心,而不是一个偏可用性的注册中心。
不过这里有一个很容易被简化过头的地方:说 ZooKeeper 是 CP,并不意味着“任何一次读都绝对是全局最新值”。因为 ZooKeeper 的普通读请求可能直接在本地副本上处理,所以在极端情况下读到的可能不是刚刚提交的最新值。如果业务真的要求“读到最新提交结果”,通常还要结合 sync()、Leader 读或者更严格的访问策略来理解。也就是说,ZooKeeper 的“CP”更多描述的是它在系统设计取舍上的立场,而不是一句“所有读写永远绝对强一致”就能概括完。
为什么 ZooKeeper 做注册中心时会牺牲一部分可用性?
根本原因在于:注册中心保存的不是普通缓存,而是整个分布式系统里非常关键的协调信息。
如果注册中心在网络分区时优先追求可用性,可能就会出现下面这些问题:
- 消费者拿到已经下线的实例地址,导致请求不断失败
- 同一个服务实例在不同分区里被看到的状态不一致
- 主节点选举、分布式锁、配置发布等依赖一致性的场景出现错误判断
对这类场景来说,短暂不可用往往比返回错误信息更安全。这正是 ZooKeeper 选择偏 CP 的核心原因。
那注册中心是不是都必须是 CP?
也不是。要看系统更怕什么。
- 如果系统更怕“注册信息错乱”,宁可短时间不可用,也不能把错误实例列表返回给客户端,那么更适合偏 CP 的方案,比如 ZooKeeper、etcd 一类
- 如果系统更怕“注册中心完全不可访问”,哪怕返回的服务列表短时间内不是最新的,也希望尽量可用,那么就会更偏向 AP 的设计思路
以常见中间件来说,可以做一个粗略理解:
- ZooKeeper:偏 CP,擅长分布式协调、选举、锁、命名服务
- Eureka:更偏 AP,强调服务发现过程中的可用性
- Nacos:在不同模块和不同实现路径下可以体现出不同取舍,既不是一句“纯 AP”,也不是一句“纯 CP”就能说完
所以,不能简单地问“注册中心有没有 CAP”,而应该问:这个注册中心在网络分区下优先保什么?
ZooKeeper 适不适合做微服务注册中心?
从能力上说,当然可以,早期也确实有不少系统这么做。但从工程实践看,ZooKeeper 更适合做的是“协调中心”,而不只是“服务地址簿”。
原因在于:
- ZooKeeper 的强项是协调语义,比如选主、锁、成员管理、元数据一致性
- 它的 Watcher 是一次性触发的,需要客户端自己处理重注册
- 它更适合保存小而关键的元数据,而不是承载特别频繁、规模特别大的服务实例变更洪峰
- 对纯微服务注册发现而言,很多团队会选择在可用性、生态和运维体验上更贴合该场景的组件
因此,一个更准确的结论是:ZooKeeper 能做注册中心,但它的定位并不只是注册中心;如果系统的核心诉求是分布式协调而不是单纯服务发现,那么 ZooKeeper 往往更合适。
ZooKeeper 应用案例
Apache HBase
HBase 是一个通常与 Hadoop 一起使用的数据存储仓库。在 HBase 中,ZooKeeper 用于选举一个集群内的主节点,以便跟踪可用的服务器,并保持集群的元数据。
Apache Kafka
Kafka 是一个基于发布-订阅模型的消息系统。其中 ZooKeeper 用于检测崩溃,实现主题的发现,并保持主题的生产和消费状态。
Apache Solr
Solr 是一个企业级的搜索平台,它使用 ZooKeeper 来存储集群的元数据,并协作更新这些元数据。
ZooKeeper 应该是 “The King Of Coordination for Big Data”
总体来说 ZooKeeper 运行于一个集群环境中,选举出某个服务器作为群首( Leader ),其他服务器追随群首( Follower )
群首作为中心处理所有对 ZooKeeper 系统变更的请求,它就像一个定序器,建立了所有对 ZooKeeper 状态的更新的顺序,追随者接收群首所发出更新操作请求,并对这些请求进行处理,以此来保障状态更新操作不会发生碰撞
请求、事务、标识符
ZooKeeper 服务器会在本地处理只读请求(exists、getData、getChildren),例如一个服务器接收客户端的 getData 请求,服务器读取该状态信息,并把这些信息返回给客户端
那些会改变 ZooKeeper 状态的客户端请求(create、delete 和 setData)会由 Leader 统一排序并形成状态更新,这类状态更新称为事务(transaction)。事务一旦被提交,就会按照相同顺序应用到各个副本上。
当 Leader 生成一个事务时,会为它分配一个标识符,称为 zxid(ZooKeeper Transaction Id)。通过 zxid 可以对事务进行全局排序;服务器在新一轮 Leader 选举时也会交换 zxid,以判断谁掌握了更新的事务历史。
zxid 是一个 long 型(64 位)整数,通常可理解为两部分:高 32 位表示 epoch,也就是 Leader 所属的纪元;低 32 位表示该纪元内递增的事务计数器。epoch 不是物理时间戳,而是 Leader 变更后的逻辑时代标识。
群首选举
群首 为集群中的服务器选择出来的一个服务器,并会一直被集群所认可
设置群首的目的是为了对客户端所发起的 ZooKeeper 状态更新请求进行排序,包括 create,setData 和 delete 操作。群首将每一个请求转换为一个事务,将这些事务发送给追随者,确保集群按照群首确定的顺序接受并处理这些事务
每个服务器启动后进入 LOOKING 状态,开始选举一个新的群首或者查找已经存在的群首。如果群首已经存在,其他服务器就会通知这个新启动的服务器,告知哪个服务器是群首,于此同时,新服务器会与群首建立连接,以确保自己的状态与群首一致。如果群首中的所有的服务器均处于 LOOKING 状态,这些服务器之间就会进行通信来选举一个群首,通过信息交换对群首选举达成共识的选择。在本次选举过程中胜出的服务器将进入 LEADING 状态,而集群中其他服务器将会进入 FOLLOWING 状态
具体看,一个服务器进入 LOOKING 状态,就会发送向集群中每个服务器发送一个通知信息,该消息中包括该服务器的投票(vote)信息,投票中包含服务器标识符(sid)和最近执行事务的 zxid 信息
当一个服务器收到一个投票信息,该服务器将会根据以下规则修改自己的投票信息:
- 将接收的 voteId 和 voteZxid 作为一个标识符,并获取接收方当前的投票中的 zxid,用 myZxid 和 mySid 表示接收方服务器自己的值
- 如果(
voteZxid > myZxid)或者(voteZxid == myZxid且voteId > mySid),就接受对方的投票信息,并把自己的投票更新为该候选者 - 否则,保留当前投票信息
从上面的投票过程可以看出,只有最新的服务器将赢得选举,因为其拥有最近一次的 zxid。如果多个服务器拥有的最新的 zxid 值,其中的 sid (服务器标识符)值最大的将会赢得选举
当一个服务器连接到仲裁数量的服务器发来的投票都一样时,就表示群首选举成功,如果被选举的群首为某个服务器自己,该服务器将会开始行使群首角色,否则就会成为一个追随者并尝试连接被选举的群首服务器。一旦连接成功,追随者和群首之间将会进行状态同步,在同步完成后,追随者才可以进行新的请求
Zab:状态更新的广播协议
Zab 协议是为分布式协调服务 ZooKeeper 专门设计的一种支持崩溃恢复的原子广播协议,是 ZooKeeper 保证数据一致性的核心机制。Zab 借鉴了 Paxos 的思想,但并不是一个通用一致性算法,而是围绕 ZooKeeper 的主备复制模型设计出来的专用协议。
Zab 的核心思想可以概括为:当一个提案被法定多数节点确认后,该事务才算提交。例如 5 台服务器组成的集群里,至少需要 3 台确认,事务才可以继续进入提交阶段。
这也是为什么 ZooKeeper 通常建议使用奇数台服务器构成集群。
在接收到一个写请求后,Follower 会把请求转发给 Leader,Leader 会为这次更新生成事务提案,并把状态更新广播给其他副本。ZooKeeper 通过 ZAB(ZooKeeper Atomic Broadcast)协议来确认事务是否可以提交。它的提交流程可以粗略理解为一个面向多数派的提交过程:
- 群首向所有追随者发送一个 PROPOSAL 消息 p。
- 当一个追随者接收到消息 p 后,会响应群首一个 ACK 消息,通知群首其已接受该提案(proposal)。
- 当收到仲裁数量的服务器发送的确认消息后(该仲裁数包括群首自己),群首就会发送消息通知追随者进行提交(COMMIT)操作
sequenceDiagram
participant C as Client
participant F as Follower
participant L as Leader
participant Q as Quorum Peers
C->>F: 写请求
F->>L: 转发写请求
L->>L: 生成 proposal / zxid
L->>Q: 广播 PROPOSAL
Q-->>L: ACK
L->>Q: COMMIT
L-->>F: 提交结果
F-->>C: 响应客户端
Zab中的三个角色:Leader 领导者、Follower跟随者、Observer观察者
- Leader: 集群中 唯一的写请求处理者 ,能够发起投票(投票也是为了进行写请求)
- Follower: 能够接收客户端的请求,如果是读请求则可以自己处理,如果是写请求则要转发给 Leader 。在选举过程中会参与投票,有选举权和被选举权
- Observer: 就是没有选举权和被选举权的 Follower
在 ZAB 协议中对 zkServer(即上面说的三个角色的总称) 还有两种模式的定义,分别是 消息广播 和 崩溃恢复
消息广播模式:
ZAB 协议其实就是如何处理写请求的,上面提到只有 Leader 群首能处理写请求,但是 Follower 和 Observer 也是需要同步更新数据的,这也就是在整个集群中保持数据的一致性
第一步需要 Leader 将写请求 广播 出去,让 Leader 问问 Followers 是否同意更新,如果超过半数以上(仲裁数量)的同意那么就进行 Follower 和 Observer 的更新(和 Paxos 一样)
Zab 需要让 Follower 和 Observer 保证顺序性 。何为顺序性,比如我现在有一个写请求A,此时 Leader 将请求 A 广播出去,因为只需要半数同意就行,所以可能这个时候有一个 Follower F1 因为网络原因没有收到,而 Leader 又广播了一个请求B,因为网络原因,F1竟然先收到了请求 B 然后才收到了请求 A ,这个时候请求处理的顺序不同就会导致数据的不同,从而 产生数据不一致问题
所以在 Leader 这端,它为每个其他的 zkServer 准备了一个 队列 ,采用先进先出的方式发送消息。由于协议是 通过 TCP来进行网络通信的,保证了消息的发送顺序性,接受顺序性也得到了保证。
除此之外,在 ZAB 中还定义了全局单调递增的事务 ID zxid。它是一个 64 位整数,其中高 32 位表示 epoch,低 32 位表示该 epoch 内递增的事务编号。当 Leader 发生变化时,epoch 也会随之变化。
定义 zxid 的核心目的就是把所有提案放进一个全局有序序列里,从而保证广播顺序和提交顺序的一致性。
崩溃恢复模式
崩溃恢复的核心前提,是先处理 Leader 故障。因为集群在同一时刻只有一个 Leader,所以一旦 Leader 出现问题,就必须先完成重新选举,再进入后续的状态恢复流程。
ZooKeeper 为什么能避免脑裂?
所谓脑裂,可以简单理解为:集群发生网络分区后,两边都觉得自己“是主”,并且都继续对外提供写服务,最终把同一份数据写成了两个版本。ZooKeeper 避免脑裂,并不是靠某一个单点机制,而是靠 多数派、Leader 选举、zxid 和 ZAB 恢复期 这几件事一起生效。
第一层约束是多数派。无论是选举出一个新的 Leader,还是 Leader 提交一个写事务,都必须拿到超过半数服务器的支持。这样一来,发生网络分区时,最多只会有一边拥有多数派,另一边由于票数不够,既不能完成 Leader 选举,也不能继续提交新的事务,只能进入 Looking 或者不可写状态。
第二层约束是Leader 选举。ZooKeeper 不是谁先喊“我是 Leader”谁就算,而是要让一个候选节点得到仲裁数量的投票。由于任意两个多数派集合必然有交集,所以不可能同时存在两个都被多数派确认的 Leader,这就从选举层面堵住了“双主并存”的可能。
第三层约束是zxid。选举并不只是比谁票多,还要比较每个节点手里最新的事务进度。zxid 更大的服务器,说明它见过更新的事务历史,会在选举中更有优势;如果 zxid 相同,再比较 sid。这样重新选出来的 Leader,通常会是那个持有最新已知状态的节点,而不是一个落后的节点硬接管集群。
最后一层约束是 ZAB 的恢复期。新的 Leader 选出来之后,并不会立刻接收新的写请求,而是先和多数派完成历史同步:已经被提交的事务要确保最终被提交,没有被多数派确认的提案则要被丢弃。等这一步完成,集群才会从恢复期切换到消息广播模式,继续处理新的写操作。
把这几步连起来看就很清楚了:网络分区时,只有多数派一侧可能保住或选出 Leader;重新选主时,优先选择 zxid 更大的节点;新 Leader 上位后还要先经过 ZAB 恢复期,把历史状态对齐后才能继续写。 所以 ZooKeeper 的策略不是“分区后两边都尽量继续服务”,而是“宁可少数派停下来,也不要让两个主节点各写各的”,这正是它能够避免脑裂的核心原因。
Leader 选举可以分为两个阶段:一个是 Leader 宕机后的重新选举,另一个是 ZooKeeper 集群刚启动时的初始化选举。下面先看初始化选举。
假设集群中有 3 台机器,那么就需要至少 2 台同意才算达到仲裁数量。以初始化为例,server1 启动后会先投票给自己,投票内容是自己的 myid 和 zxid。因为是初始阶段,所以各节点的 zxid 都是 0,此时 server1 发出的投票可以记作 (1,0)。但仅凭 1 票还不足以成为 Leader,因此整个集群仍然处于 LOOKING 状态。
接着 server2 启动了,它首先也会将投票选给自己(2,0),并将投票信息广播出去(server1也会,只是它那时没有其他的服务器了),server1 在收到 server2 的投票信息后会将投票信息与自己的作比较。首先它会比较 ZXID ,ZXID 大的优先为 Leader,如果相同则比较 myid,myid 大的优先作为 Leader。所以此时 server1 发现 server2 更适合做 Leader,它就会将自己的投票信息更改为 (2,0) 然后再广播出去,之后 server2 收到之后发现和自己的一样无需做更改,并且自己的投票已经超过半数 ,则 确定 server2 为 Leader,server1 也会将自己服务器设置为 Looking 变为 Follower。整个服务器就从 Looking 变为了正常状态
当 server3 启动发现集群没有处于 Looking 状态时,它会直接以 Follower 的身份加入集群
还是前面三个 server 的例子,如果在整个集群运行的过程中 server2 挂了,那么整个集群会如何重新选举 Leader 呢?其实和初始化选举差不多。
首先毫无疑问的是剩下的两个 Follower 会将自己的状态 从 Following 变为 Looking 状态 ,然后每个 server 会向初始化投票一样首先给自己投票(不过这里的 zxid 可能不是 0了,这里为了方便随便取个数字)
假设 server1 给自己投票为 (1,99),然后广播给其他 server,server3 首先也会给自己投票(3,95),然后也广播给其他 server。server1 和 server3 此时会收到彼此的投票信息,和一开始选举一样,他们也会比较自己的投票和收到的投票(zxid 大的优先,如果相同那么就 myid 大的优先)。这个时候 server1 收到了 server3 的投票发现没自己的合适故不变,server3 收到 server1 的投票结果后发现比自己的合适于是更改投票为 (1,99) 然后广播出去,最后 server1 收到了发现自己的投票已经超过半数就把自己设为 Leader,server3 也随之变为 Follower
上面是选举过程,下面再看什么是崩溃恢复
其实主要就是当集群中有机器挂了,整个集群如何保证数据一致性?
如果只是 Follower 宕机,而且宕机节点没有超过半数,集群仍然可以继续工作。因为 Leader 会维护提案顺序并推动多数派提交,所以不会因为单个 Follower 的暂时离线而立刻破坏整体一致性。
如果发生 Leader 宕机,集群就需要先进入 LOOKING 状态并重新选举 Leader。恢复阶段通常要处理两类问题:一类是确保已经被 Leader 提交的提案最终能够被所有 Follower 提交;另一类是跳过那些实际上已经被放弃的提案。
确保已经被 Leader 提交的提案最终能够被所有的 Follower 提交是什么意思呢?
假设 Leader(server2)已经把某个 commit 请求发给了 server3,但在发送给 server1 之前突然宕机。如果此时重新选举后让 server1 成为新的 Leader,就会出现数据不一致:server3 可能已经提交了这次提案,而 server1 根本没有接收到对应提交信息,恢复后就会把它当成未生效提案处理。

所以这个时候 server1 已经不可能成为 Leader 了,因为 server1 和 server3 进行投票选举的时候会比较 ZXID ,而此时 server3 的 ZXID 肯定比 server1 的大了
那么跳过那些已经被丢弃的提案又是什么意思呢?
假设 Leader (server2) 此时同意了提案 N1,自身提交了这个事务并且要发送给所有 Follower 要 commit 的请求,却在这个时候挂了,此时肯定要重新进行 Leader 的选举,比如说此时选 server1 为 Leader (这无所谓)。但是过了一会,这个 挂掉的 Leader 又重新恢复了 ,此时它肯定会作为 Follower 的身份进入集群中,需要注意的是刚刚 server2 已经同意提交了提案 N1,但其他 server 并没有收到它的 commit 信息,所以其他 server 不可能再提交这个提案 N1 了,这样就会出现数据不一致性问题了,所以 该提案 N1 最终需要被抛弃掉

Zab 保障了以下几个重要的属性
如果群首按顺序广播了事务 T1 和事务 T2,那么每个服务器在提交 T2 事务前保证事务 T1 已经完成提交。
如果某个服务器按照事务 T1 和事务 T2 的顺序提交了事务,所有其他服务器也必然会在提交事务 T2 前提交事务 T1。
第一个属性保证事务在服务器之间传送顺序的一致,而第二个属性保证服务器不会跳过任何事务
观察者
观察者与追随者有一些共同的特点,他们提交来自群首的提议,不同于追随者的是,观察者不参与选举过程,他们仅仅学习经由 INFORM 消息提交的提议。
引入观察者的一个主要原因,是提高读请求的可扩展性。通过加入多个观察者,可以在不牺牲写操作吞吐率的前提下服务更多读请求。不过观察者也并非没有代价,每一个新增观察者都会对应到每一个已提交事务点上的额外消息传播成本。
采用观察者的另外一个原因是进行跨多个数据中心部署。由于数据中心之间的网络链接延时,将服务器分散于多个数据中心将明显地降低系统的速度。引入观察者后,更新请求能够先以高吞吐量和低延迟的方式在一个数据中心内执行,接下来再传播到异地的其他数据中心得到执行
服务器的构成
群首,追随者,观察者根本上都是服务器。在实现服务器主要抽象概念是请求处理器。请求处理器是对处理流水线上不同阶段的抽象,每个服务器实现一个请求处理器的序列
独立服务器
PrepRequestProcessor 接受客户端的请求并执行这个请求,处理结果则是生成一个事务。不过只有改变 ZooKeeper 状态的操作才会产生事务,对于读操作并不会产生任何事务
SyncRequestProcessor 负责将事务持久化到磁盘上。实际上就是将事务数据按照顺序追加到事务日志中,并形成快照数据
最后一个处理器为 FinalRequestProcessor,如果 Request 对象包含事务数据,该处理器就会接受对 ZooKeeper 数据树的修改,否则,该处理器会从数据树中读取数据并返回客户端
群首服务器
在切换到仲裁模式时,服务器的流水线则有一些变化

第一个处理器同样是 PrepRequestProcessor,而之后的处理器则为 ProposalRequestProcessor,该处理器会准备一个提议,并将该提议发送给跟随者,并且会把所有请求转发给 CommitRequestProcessor,对于写操作请求,还会把请求转发给 SyncRequestProcessor 处理器
SyncRequestProcessor 和独立服务器的功能一样,是持久化事务到磁盘上,执行完后会触发 AckRequestProcessor 处理器,它仅仅生成确认消息并返回给自己
CommitRequestProcessor 会将收到足够多的确认消息的提议进行提交
追随者和观察者服务器
Follower 服务器是先从 FollowerRequestProcessors 处理器开始,该处理器接收并处理客户端请求,FollowerRequestProcessors 处理器之后转发请求给 CommitRequestProcessor,同时也会转发写请求到群首服务器。CommitRequestProcessor 会直接转发读取请求到 FinalRequestProcessor 处理器,而且对于写请求,在转发前会等待提交事务。而群首接收到一个新的写请求时会生成一个提议,之后转发到追随者服务器,在收到一个提议,追随服务器会发送这个提议到 SyncRequestProcessor,SendRequestProcessor 会向群首发送确认消息
当群首服务器接收到足够多确认消息来提交这个提议是,群首就会发送提交事务消息给追随者,当收到提交的事务消息时,追随者就通过 CommitRequestProcessor 处理器进行处理。为了保证执行的顺序,CommitRequestProcessor 处理器会在收到一个写请求处理器时暂停后续的请求处理
对于观察者服务器不需要确认提议消息,因此观察者服务器并不需要发送确认消息给群首服务器,一般情况下,也不用持久化事务到磁盘。对于观察者服务器是否持久化事务到磁盘,以便加速观察者服务器的恢复速度,可以根据具体情况决定
本地存储
SyncRequestProcessor 处理器是用于处理提议写入的日志和快照
日志和磁盘的使用
服务器通过事务日志来持久化事务。在接受一个提议时,一个服务器就会将提议的事务持久化到事务日志中,该事务日志保存在服务器本地磁盘中,而事务将会按照顺序追加其后
写事务日志是写请求处理的关键路径,因此 ZooKeeper 必须高效处理日志落盘。在将事务持久化到磁盘时,还要注意一个事实:现代操作系统通常会缓存脏页(Dirty Page),并异步写入磁盘介质。为了保证事务真正落盘,必须在继续执行前完成 flush。
这里的冲刷,是指要求操作系统把对应脏页真正写入磁盘,并在完成后返回。为了提高整体吞吐,ZooKeeper 还会结合组提交和补白等手段:组提交指一次磁盘写入中顺序追加多个事务,以减少磁盘寻址开销;补白则是提前在文件中预分配磁盘块。
快照
快照是 ZooKeeper 数据树的拷贝副本,每一个服务器会经常以序列化整个数据树的方式来提取快照,并将这个提取的快照保存到文件。服务器在进行快照时不需要进行协作,也不需要暂停处理请求。因此服务器在进行快照时还会继续处理请求,所以当快照完成时,数据树可能又发生了变化,称为快照是模糊的,因为它们不能反映出在任意给定的时间点数据树的准确的状态
服务器与会话
会话(session)是 ZooKeeper 的一个重要的抽象。保证请求有序,临时 znode 节点,监控点都与会话密切相关。因此会话的跟踪机制对 ZooKeeper 来说也是非常重要的。
在独立模式下,单个服务器会跟踪所有的会话,而在仲裁模式下则由群首服务器来跟踪和维护。而追随者服务器仅仅是简单地把客户端连接的会话信息转发到群首服务器。
为了保证会话的存活,服务器需要接收会话的心跳信息。心跳的形式可以是一个新的请求或者显式的 ping 信息。两种情况下,服务器通过更新会话的过期时间来触发会话活跃,在仲裁模式下,群首服务器发送一个 PING 信息给它的追随者们,追随者们返回自从最新一次 PING 消息之后的一个 session 列表。群首服务器每半个 tick 就会发送一个 ping 信息给追随者们
服务器与监视点
监视点是由读取操作所设置的一次性触发器,每个监视点有一个特定操作来触发,即通过监视点,客户端可以对指定的 znode 节点注册一个通知请求,在发生时就会收到一个单次的通知。监视点只会存在内存,而不会持久化到硬盘,当客户端与服务端的连接断开时,它的所有的监视点会从内存中清除。因为客户端也会维护一份监视点的数据,在重连之后,监视点数据会再次同步到服务端
客户端
在客户端库中有 2 个主要的类:ZooKeeper 和 ClientCnxn,写客户端应用程序时通过实例化 ZooKeeper 类来建立一个会话。一旦建立起一个会话,ZooKeeper 就会使用一个会话标识符来关联这个会话。这个会话标识符实际上是由服务端所生产的。
ClientCnxn 类管理连接到 server 的 socket 连接。该类维护一个可连接的 ZooKeeper 的服务列表,并当连接断掉的时候无缝地切换到其他服务器,当重连到一个其他的服务器时会使用同一个会话,客户端也会重置所有的监视点到刚连接的服务器上
序列化
对于网络传输和磁盘保存的序列化消息和事务,ZooKeeper 使用了 Hadoop 中的 Jute 来做序列化