Zookeeper:分布式协调服务
一、概念
ZooKeeper 是一个分布式的、开源的分布式应用程序协调服务,为分布式应用提供一致性、配置管理、命名服务、分布式同步和组服务等。可以把它想象成一个为分布式系统提供的“文件系统”+“通知机制”,但它存储的不是普通的文件,而是少量的、关键的数据(如配置信息、状态标志、任务分配等),并且提供了一套保证,使得分布式应用可以基于它实现数据发布/订阅、负载均衡、命名服务、分布式锁、分布式队列、 leader 选举等一系列核心功能。
1.1 核心能力
核心能力总结:Zookeeper 的核心是通过其强一致性的数据模型和高效的监听机制,在分布式系统中可靠地管理“状态”和“元信息”。
分布式数据一致性(核心之核心)
机制:所有客户端连接到Zookeeper集群的任何节点,看到的数据视图都是一致的。写入的数据会通过ZAB协议同步到集群中超过半数的节点后,才被认为成功,从而保证强一致性(顺序一致性)。
体现:这是实现后续所有高级功能的基础。
配置管理
场景:将系统的通用配置(如数据库连接串、特性开关等)存储在Zookeeper的一个znode上。
优势:所有服务实例都可以监听这个znode。当配置变更时,Zookeeper会主动通知所有监听的服务,服务可以实时获取最新配置,无需重启。
分布式锁
场景:在分布式环境中,保证对共享资源的互斥访问,避免并发问题。
机制:利用Zookeeper创建临时顺序节点的特性来实现公平的、可重入的分布式锁。这是其最经典的应用之一(Apache Curator客户端提供了开箱即用的锁实现)。
集群管理与选主
场景:对于一个主从架构的服务集群(如Hadoop HBase),需要确定一个主节点来负责调度和管理。
机制:多个候选主节点同时尝试在指定路径下创建同一个临时节点(
/election/master
)。由于Zookeeper保证节点唯一性,只有一个能创建成功,它就成为Master。其他节点监听这个临时节点,一旦Master宕机导致会话失效节点被删除,其他节点就能立即被通知并重新发起选举。
服务发现与命名服务
场景:在微服务架构中,服务实例动态变化(扩容、宕机),客户端需要知道当前有哪些健康的服务实例可用。
机制:服务提供者启动时,在Zookeeper的特定路径下(如
/services/serviceA
)创建一个临时节点(如/services/serviceA/instance1:8080
)来注册自己。客户端通过监听这个路径的子节点变化,就能动态获取最新的服务实例列表。
1.2 核心特性
ZooKeeper 的性能和可靠性建立在几个核心特性之上,通常简写为 ZAB:
顺序一致性(Ordered Consistency):来自客户端的更新将按其被发送的顺序依次应用。这是最重要的保证。例如,如果客户端先更新了
/a
,然后又更新了/b
,那么所有客户端看到的顺序一定是先/a
后/b
,绝不会相反。原子性(Atomicity):更新操作要么成功,要么失败,没有中间状态。所有服务器的数据副本要么都更新,要么都不更新。
单一系统映像(Single System Image):无论客户端连接到 ZooKeeper 集群中的哪一个服务器,它看到的数据模型都是一致的。不会出现连接到 ServerA 和 ServerB 看到的数据不同。
可靠性(Reliability):一旦一个更新被应用,其结果就会持久化,直到被下一次更新覆盖。
及时性(Timeliness):客户端在一定时间范围内(通常很小)能看到最新的系统状态视图。
这些特性是由 ZooKeeper Atomic Broadcast (ZAB) 协议 来保障的,它是一种类似 Paxos 的共识算法,用于在集群中各服务器间复制状态、达成一致。
1.3 原理支撑
1.3.1 数据模型:ZNode
理解类似于文件系统的树形结构,每个节点叫ZNode。ZNode不仅可以是路径,还可以存储少量数据(默认 < 1MB)。
如何支撑特性:
层级结构:允许用路径的方式组织数据(如
/services/serviceA/instance1
),非常直观,适合做配置管理和服务发现。节点类型:
持久节点(PERSISTENT):创建后,除非主动删除,否则一直存在。
临时节点(EPHEMERAL):与客户端会话绑定。当创建它的客户端会话失效(断开连接或超时)时,该节点会被自动删除。这是实现服务发现和领导者选举的关键。
顺序节点(SEQUENTIAL):在创建节点时,ZooKeeper 会自动在路径末尾附加一个单调递增的序列号。例如,创建
/app/task-
会得到/app/task-0000000001
。这是实现分布式锁和公平队列的关键。这些类型可以组合,例如 临时顺序节点(EPHEMERAL_SEQUENTIAL)。
版本号:每个ZNode都有一个版本号,更新操作(如
setData
,delete
)必须提供正确的版本号才能成功。这实现了乐观锁机制,保证原子性。
1.3.2 会话机制
客户端与Zookeeper服务器建立TCP连接后,会创建一个会话。会话有超时时间,客户端通过定期发送心跳(Ping)来保持会话有效。
如何支撑特性:
连接无关性:客户端可以断开重连,只要在会话超时时间内重新连上(可以是连到集群中任意服务器),会话依然有效,之前创建的临时节点也依然存在。这提供了很好的容错能力。会话是有状态的,包含超时时间等配置。
临时节点生命周期:会话失效是临时节点被清除的唯一原因,从而可靠地反映了客户端的存活状态,支撑了可靠性和时效性。
1.3.3 Watch 机制
一种一次性的、异步的通知机制。客户端可以在读取ZNode时设置一个Watch,监控该节点的变化(数据变更、子节点增减等)。当变化发生时,Zookeeper服务端会向客户端发送一个通知,但只通知一次,之后Watch便失效,如需持续监听需要重新设置。
如何支撑特性:
服务发现与配置变更:客户端无需轮询,通过Watch可以近乎实时地感知到服务列表或配置的变化,支撑了时效性和单一系统映像。
分布式锁/选主:等待锁的客户端可以通过Watch监听前一个序号节点的删除事件,从而被唤醒去尝试获取锁,避免了轮询带来的开销。
1.3.4 ZAB 协议 - 核心中的核心
这是Zookeeper实现强一致性的原子弹级协议,是Paxos算法的一种工业实现变种。ZAB协议的核心是为所有改变Zookeeper状态的操作(写请求)建立一个全局的、有序的顺序。
ZAB协议有两个核心工作模式:
消息广播(正常状态)
所有写请求都由一个领导者服务器接收。
领导者为该事务分配一个全局单调递增的ZXID(事务ID),保证了顺序性。
领导者生成一个提案,并将这个提案连同ZXID一起广播给所有追随者。
追随者接收到提案后,将其写入磁盘日志,并返回一个ACK给领导者。
领导者收到超过半数的ACK后,就提交这个事务,并向所有追随者发送一个提交消息。
追随者收到提交消息后,才在内存中正式应用这个事务。
这个过程保证了:一个提案只有在被集群多数派持久化后,才会被提交。这支撑了原子性和可靠性。
崩溃恢复(选举Leader)
当Leader宕机或失去与多数派的连接时,ZAB协议进入崩溃恢复模式,重新选举Leader。选举:集群中的服务器开始投票,投票标准是:优先比较ZXID(谁的数据最新),ZXID相同则比较服务器ID(myid)。得票超过半数的服务器成为新的Leader。
数据同步:新的Leader会与所有Follower进行数据同步,确保所有Follower都拥有Leader上最新已提交的数据(即高ZXID的数据),同时会丢弃那些未被多数派确认的提案(即使它们来自旧Leader)。
消息广播:同步完成后,集群重新回到消息广播模式。
崩溃恢复模式确保了:
已经被旧Leader提交的提案,一定会被新Leader接受(因为提交意味着已被多数派持久化)。
被旧Leader提出但未提交的提案,会被丢弃(因为未被多数派确认)。
这严格保证了数据的一致性,是单一系统映像和可靠性的根本保障。
1.3.5 数据存储与内存数据库
事务日志(WAL):
所有改变 ZooKeeper 状态的操作(写请求)都会被序列化并追加到事务日志文件中。
写日志是顺序写磁盘,效率非常高。这是 ZooKeeper 即使写数据需要持久化也能保持较好性能的关键。
当日志文件过大时,ZooKeeper 会进行快照和日志裁剪。
快照(Snapshot):
定期(默认每 10w 次事务)将内存中的整个数据树(DataTree)序列化后 dump 到磁盘,形成一个快照文件。
快照用于加快重启后的恢复速度。恢复时,先加载快照文件,再重放快照之后的所有事务日志,即可恢复到最新状态。
内存数据库:
ZooKeeper 的所有数据(DataTree)都完整地存储在内存中,这使得它的读速度极快。
事务日志和快照只是用于持久化和恢复,服务时的所有读请求都是直接读内存。
1.4 集群架构与角色
ZooKeeper 通常以集群模式部署,以实现高可用。
Leader:集群中唯一的一个领导者。所有写请求都必须由 Leader 处理。Leader 接收写请求,并通过 ZAB 协议广播给所有 Follower。
Follower:处理客户端的读请求,并将写请求转发给 Leader。参与 Leader 选举和提案投票。Follower 的本地数据是 Leader 的副本。
Observer:与 Follower 类似,只处理读请求和转发写请求,但不参与投票。它的存在是为了扩展系统的读性能,而不影响写操作的吞吐量(因为投票过程可能成为瓶颈)。
选举过程:当 Leader 宕机时,剩余的服务器会开始新一轮选举,投票选出新的 Leader。在此期间,ZooKeeper 集群将无法处理写请求,但读请求仍然可以正常处理(因为 Follower 有本地副本)。
防止脑裂:脑裂是指一个集群中出现了多个 Leader,导致数据不一致。ZAB 协议通过两次多数投票机制彻底避免了脑裂:
写操作需要过半确认:Leader 必须收到过半 Follower 的 ACK 才能提交提案。这意味着任何一条已提交的数据都存在于过半的服务器上。
选举需要过半投票:新的 Leader 必须获得过半服务器的投票才能当选。
二、典型应用场景
ZooKeeper 的 API 很简单(create
, delete
, exists
, getData
, setData
, getChildren
),但通过巧妙使用节点类型和 Watch 机制,可以实现强大的功能:
2.1 配置管理(Configuration Management)
将通用配置(如数据库连接字符串)存储在 ZNode(如
/app/config
)中。所有应用实例启动时读取该配置,并在这个 ZNode 上设置一个 Watch。
当配置需要变更时,管理员更新
/app/config
的数据。ZooKeeper 会通知所有监听了该节点的应用实例,实例收到通知后重新读取新配置。实现了“一次变更,处处生效”。
2.2 服务发现与注册(Service Discovery & Registration)
每个服务实例启动时,在特定的路径下(如
/services/order-service
)为自己创建一个临时节点(如/services/order-service/192.168.1.100:8080
)。服务实例下线或宕机(会话结束)时,其创建的临时节点会被自动删除。
服务消费者通过监听
/services/order-service
的子节点变化,就能实时获取所有健康可用的服务实例列表。
2.3 分布式锁(Distributed Lock)
非公平锁:所有客户端尝试创建同一个锁节点(如
/lock
),只有一个客户端能创建成功(ZooKeeper保证唯一性),创建成功的客户端即获得锁。释放锁时删除该节点。公平锁(推荐):
所有客户端在锁目录下(如
/locks
)创建临时顺序节点(如/locks/lock-
->/locks/lock-000000001
)。客户端获取
/locks
下的所有子节点,并判断自己创建的节点是否是序号最小的。如果是,则获得锁。
如果不是,则监听比自己序号小1的那个节点的变化(删除事件)。
当监听到前一个节点被删除(锁被释放)时,自己再尝试获取锁。这形成了一个等待队列,保证了获取锁的公平性。
2.4 领导者选举(Leader Election)
与分布式锁的实现非常相似。
多个候选者都在指定路径下(如
/election
)创建临时顺序节点。序号最小的节点成为 Leader。
其他候选者监听排在它前面的节点。如果 Leader 宕机(其临时节点被删除),下一个序号最小的候选者将成为新的 Leader,并收到通知。
2.5 命名服务(Naming Service) & 分布式队列(Queue)
利用顺序节点的单调递增特性,可以生成全局唯一的 ID(命名服务)。
同样,可以利用顺序节点来实现一个先进先出(FIFO)的队列。
三、基础操作
3.1 集群搭建
ZooKeeper 旨在以集群模式(复制模式) 运行,通常包含奇数个服务器节点(如 3, 5, 7...)。这是为了满足多数投票机制,防止脑裂。
每个服务器节点都需要一个唯一的配置文件
zoo.cfg
,其中关键配置包括:clientPort
: 客户端连接的端口(通常 2181)。dataDir
: 存储内存快照和事务日志的目录。server.X=host:port1:port2
: 集群中所有服务器的列表。X
是每个服务器的 myid(必须在dataDir
下的myid
文件中明确写出)。port1
: 用于 Follower 与 Leader 之间的数据同步和通信的端口。port2
: 用于 Leader 选举的端口。
示例:一个三节点的集群配置:
# 基本时间单元(毫秒)
tickTime=2000
# 初始化连接时最长心跳间隔(tickTime的倍数)
initLimit=10
# 运行时同步操作最长延迟(tickTime的倍数)
syncLimit=5
# 数据存储目录,很重要,需自定义且确保有权限
dataDir=/your/path/to/zookeeper/data
# (可选)事务日志目录,可与dataDir不同以提升性能
dataLogDir=/your/path/to/zookeeper/log
# 客户端连接端口
clientPort=2181
# 集群服务器列表,格式为 server.myid=host:quorum_port:leader_election_port
server.1=node1_ip:2888:3888
server.2=node2_ip:2888:3888
server.3=node3_ip:2888:3888
# 其他可选参数,如限制单个IP连接数、开启四字命令等:cite[2]
maxClientCnxns=100
4lw.commands.whitelist=*
在每个节点的 dataDir
目录下,创建一个名为 myid
的文件。文件内容只有一行,即该节点在 zoo.cfg
中 server.x
的对应数字标识(如 server.1
对应的节点 myid
文件内容就是 1
)。
# 在 node1 上执行
echo "1" > /your/path/to/zookeeper/data/myid
# 在 node2 上执行
echo "2" > /your/path/to/zookeeper/data/myid
# 在 node3 上执行
echo "3" > /your/path/to/zookeeper/data/myid
基本命令:
# 进入 ZooKeeper 的 bin 目录
cd /path/to/zookeeper/bin
# 启动服务
./zkServer.sh start# 在所有节点上执行 status 命令检查状态 leader或follower
./zkServer.sh status# 使用客户端连接任意节点进行测试
./zkCli.sh -server your_zookeeper_ip:2181# 在连接成功后,可以尝试执行一些基本操作,如 create /test "data" 和 get /test,观察数据是否一致。
3.2 集群运维
ZooKeeper 提供了一系列简单的基于 Telnet 或 NC 的命令来监控集群状态,非常有用。
echo stat | nc localhost 2181
: 查看客户端连接、节点模式(Leader/Follower)等详细信息。echo ruok | nc localhost 2181
: 测试服务器是否处于非错误状态(“I'm OK”)。echo conf | nc localhost 2181
: 输出完整的服务配置。echo srvr | nc localhost 2181
: 输出服务器的状态信息(比stat
更简洁)。echo cons | nc localhost 2181
: 列出所有连接到该服务器的客户端连接详情。
3.3 客户端编程
开发者通常使用 ZooKeeper 提供的官方客户端库(Java/C等)或更高级的封装库(如Apache Curator)来交互。
3.3.1 原生 ZooKeeper API
核心操作非常简洁,围绕 ZNode 进行 CRUD 和监听。
create(path, data, acl, nodeType)
: 创建节点。delete(path, version)
: 删除节点(可指定版本实现乐观锁)。exists(path, watch)
: 判断节点是否存在,并可设置 Watch。getData(path, watch, stat)
: 获取节点数据和元信息(如版本号),并可设置 Watch。setData(path, data, version)
: 设置节点数据(可指定版本实现乐观锁)。getChildren(path, watch)
: 获取子节点列表,并可设置 Watch。
// 1. 创建连接
ZooKeeper zk = new ZooKeeper("localhost:2181", 3000, new Watcher() {@Overridepublic void process(WatchedEvent event) {// 处理连接状态和Watch事件System.out.println("收到事件:" + event);}
});// 2. 创建节点(持久节点)
String path = zk.create("/myapp/config", "data".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);// 3. 获取数据并监听变化
byte[] data = zk.getData("/myapp/config", new Watcher() {@Overridepublic void process(WatchedEvent event) {if (event.getType() == Event.EventType.NodeDataChanged) {System.out.println("节点数据被修改了!");// 通常这里会重新获取数据并再次设置Watch}}
}, null); // stat 信息会放在这个null的位置
System.out.println("数据是: " + new String(data));// 4. 获取子节点
List<String> children = zk.getChildren("/myapp", true);// 5. 判断节点是否存在
Stat stat = zk.exists("/some/node", true);
if (stat != null) {// 节点存在
}// 6. 更新数据
zk.setData("/myapp/config", "newData".getBytes(), -1); // -1表示匹配任何版本// 7. 删除节点
zk.delete("/myapp/config", -1);
注意:原生API比较底层,需要开发者自己处理连接重试、Watch重复注册等复杂逻辑。
3.3.2 Curator框架(生产级首选)
Netflix开源的Curator封装并简化了Zookeeper的操作,提供了更 fluent 的API和大量高阶解决方案。
// 1. 创建客户端
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
CuratorFramework client = CuratorFrameworkFactory.newClient("localhost:2181", retryPolicy);
client.start();// 2. 创建节点
client.create().forPath("/myapp/config", "data".getBytes());
// 创建临时节点
client.create().withMode(CreateMode.EPHEMERAL).forPath("/instance/id_01", "host:port".getBytes());// 3. 获取数据
byte[] data = client.getData().forPath("/myapp/config");// 4. 带监听获取数据(Curator的NodeCache工具,可以持续监听节点变化)
NodeCache nodeCache = new NodeCache(client, "/myapp/config");
nodeCache.start(true);
nodeCache.getListenable().addListener(() -> {byte[] newData = nodeCache.getCurrentData().getData();System.out.println("节点数据变化,新数据: " + new String(newData));
});// 5. 使用InterProcessMutex实现分布式锁
InterProcessMutex lock = new InterProcessMutex(client, "/locks/my_lock");
if (lock.acquire(10, TimeUnit.SECONDS)) { // 获取锁,最多等10秒try {// 你的业务临界区代码System.out.println("Doing work inside the lock!");} finally {lock.release(); // 必须释放锁}
}