Zookeeper的能力
Zookeeper是一个开源的、高性能的分布式协调服务,专门为解决分布式应用中的一致性问题而设计。
其核心特性包括:
分布式协调与同步:提供防止多个节点同时修改关键资源(分布式锁
DistributedLock
),进而提供分布式环境下的锁服务、队列、屏障等基础协调能力,解决进程间同步问题配置管理:作为集中式的配置存储。应用节点可通过Watcher机制实现对节点变化的监听,从而实时获取和监听配置变更
命名服务:提供类似文件系统的层级命名空间(ZNode树),用于注册和查找服务地址、节点信息等
集群管理:轻松监控集群节点状态(在线/离线)、选举主节点
Master 选举: 多个候选节点通过创建临时有序节点(EPHEMERAL_SEQUENTIAL),序号最小者自动成为 Master。
节点状态监控: 利用临时节点(EPHEMERAL),节点下线时其注册节点自动删除,其他节点可感知。
高可用:ZooKeeper 服务本身是一个集群(Ensemble),通常由多个节点(奇数个,如 3、5、7)组成
强一致性:保证所有客户端看到的数据视图是一致的(遵循 ZAB 原子广播协议),所有写操作都会经由 Leader 节点协调,确保顺序并在集群内达成一致后才返回成功。读操作默认从本地副本读取(可能读到稍旧数据,但可通过 sync 操作保证最新)
顺序一致性:所有操作都严格按照发起顺序生效,为每个更新操作分配全局唯一、单调递增的zxid
观察者机制:客户端可以在ZNode上设置Watcher监听器,当被监听的 ZNode 或其子节点发生创建、删除、数据更新等事件时,ZooKeeper 会主动通知注册了 Watcher 的客户端,监听器一次触发,通知后失效,需重新注册
轻量级与高效性:数据模型简单,采用ZNode树形结构,特别适合存储少量但关键的数据,如配置、状态、元数据,读写性能高(读远高于写)
Zookeeper与Redis的本质区别
类似这种分布式配置、master选举、分布式锁这些能力,而zk的多节点高可用特性,redis的哨兵部署特性或redis cluster同样可以实现,那么项目为何要使用ZK而不直接用Redis呢?
这是因为二者的侧重点不同:
ZooKeeper的核心设计目标是强一致性(线性一致性)和分区容错性(CP)。 它使用ZAB协议(类似Raft)确保所有节点拿到的是最新最真实的数据,ZK宁愿不返回也不愿返回错误数据。
Redis的核心设计是性能、可用性、分区容忍,存在读取旧数据的风险,也可能丢失写入
Zookeeper和Redis都有类似监听器的能力:
ZK是Watcher机制,注册后,ZNode数据变化都会通知客户端,但一次通知后失效,需要重新注册
Redis是Publisher/Receiver机制,但是这种机制相比kafka、pulsar是不安全的,首先历史消息不做存储,其次宕机后消息不可恢复
因此,ZK更适合存储量少、占用空间小的元数据、配置属性等,而Redis更适合存储要求性能、变化快速的业务临时数据
Zookeeper代码分析
Curator框架与Zookeeper的过渡
apache除了提供Zookeeper源码以外,还提供了apache.curator框架,用于管理zk连接,相比原始api,curator框架的优势在于:
自动连接恢复(含会话过期处理)
预定义复杂模式(分布式锁、选举等)
更友好的API风格
内置测试框架(TestingServer)
// Curator连接示例(自动处理连接恢复)
CuratorFramework client = CuratorFrameworkFactory.newClient(
"zk1:2181,zk2:2181",
new ExponentialBackoffRetry(1000, 3) // 重试策略
);
client.start();
除了简单的构造方法,还有复杂一些的,可以自行设置一些认证模式等:
CuratorFramework client = CuratorFrameworkFactory.builder()
.dontUseContainerParents()
.connectString(connectString)
.sessionTimeoutMs(sessionTimeoutMs)
.connectionTimeoutMs(connectionTimeoutMs)
.retryPolicy(retryPolicy)
.zookeeperFactory(zookeeperFactory)
.build();
其中,这里就需要自行实现zookeeperFactory完成一些基础配置的能力
构造出来的CuratorFramwork中持有一个CuratorZookeeperClient对象
public class CuratorFrameworkImpl implements CuratorFramework {
……
private final CuratorZookeeperClient client;
这个对象就是Curator客户端与Zookeeper客户端之间的连接点,但是不是直接写在里面,而是需要继续向下
public class CuratorZookeeperClient implements Closeable {
……
private final ConnectionState state;
继续看ConnectionState类,其中持有的是HandleHolder和一些表示状态的数据
class ConnectionState implements Watcher, Closeable {
……
private final HandleHolder handleHolder;
private final AtomicBoolean isConnected = new AtomicBoolean(false);
跟进handleHolder,其中持有的是ZK的构造Factory、watcher、以及volatile变量Helper,这里的Helper就是为了持有zk连接的内存一致性
class HandleHolder {
private final ZookeeperFactory zookeeperFactory;
private final Watcher watcher;
……
private volatile Helper helper;
class Helper {
private final Data data;
static class Data {
volatile ZooKeeper zooKeeperHandle = null;
volatile String connectionString = null;
}
Helper(Data data) {
this.data = data;
}
ZooKeeper getZooKeeper() throws Exception {
return data.zooKeeperHandle;
}
Helper中有一个Data内部类,里面就是ZooKeeper实例,即原生zk的封装逻辑
基于Curator的操作
正常来说Zookeeper实例本身肯定是提供读写能力的,但是使用ctrl + F12看一下,里面的api乱七八糟的,完全看不懂
这样的api不适合提供给开发者,因此Curator重新对这些api进行了封装,举个例子
// org.apache.curator.framework.imps.CuratorFrameworkImpl#getData
public GetDataBuilder getData() {
checkState();
return new GetDataBuilderImpl(this);
}
Curator#getData
方法首先会返回一个Builder,这个方法是无参的,后面必须调用其forPath方法才能准确定位到zk的特定节点上
// org.apache.curator.framework.imps.GetDataBuilderImpl#forPath
public byte[] forPath(String path) throws Exception {
client.getSchemaSet().getSchema(path).validateWatch(path, watching.isWatched() || watching.hasWatcher());
path = client.fixForNamespace(path);
byte[] responseData = null;
if (backgrounding.inBackground()) {
client.processBackgroundOperation(
new OperationAndData<String>(
this, path, backgrounding.getCallback(), null, backgrounding.getContext(), watching),
null);
} else {
responseData = pathInForeground(path);
}
return responseData;
}
继续跟进pathInForeground方法
// org.apache.curator.framework.imps.GetDataBuilderImpl#pathInForeground
private byte[] pathInForeground(final String path) throws Exception {
OperationTrace trace = client.getZookeeperClient().startAdvancedTracer("GetDataBuilderImpl-Foreground");
byte[] responseData = RetryLoop.callWithRetry(client.getZookeeperClient(), new Callable<byte[]>() {
@Override
public byte[] call() throws Exception {
byte[] responseData;
if (watching.isWatched()) {
responseData = client.getZooKeeper().getData(path, true, responseStat);
} else {
responseData = client.getZooKeeper().getData(path, watching.getWatcher(path), responseStat);
watching.commitWatcher(KeeperException.NoNodeException.Code.OK.intValue(), false);
}
return responseData;
}
});
trace.setResponseBytesLength(responseData)
.setPath(path)
.setWithWatcher(watching.hasWatcher())
.setStat(responseStat)
.commit();
return decompress ? client.getCompressionProvider().decompress(path, responseData) : responseData;
}
首先,代码在responseData = client.getZooKeeper().getData(path, true, responseStat)
是获取值的,这里的clinet.getZookeeper
一路跟踪向下,就会看到上面看到的Helper#getZooKeeper
的逻辑
其次,为什么apache在设计这个代码架构的时候,不直接给getData方法一个入参,而是先通过无参构造一个Builder,然后再调forPath方法?
因为这是方便后续增加其他参数时,无需修改getData的api代码,一个三方包中,对外的api是尽量不能改变的,为了应对后续可能增加的逻辑和功能,Builder设计模式就有着非常强大的优势
想像如下场景:
// 反例:参数爆炸的"伸缩构造函数"模式
byte[] data = client.getData(
path,
watcher,
stat,
watchMode,
compression,
timeout... // 参数会持续增长
);
// builder解决方案
client.getData()
.storingStatIn(stat) // 接收节点状态
.usingWatcher(watcher) // 设置监听器
.decompressed() // 启用压缩
.withVersion(version) // 指定版本
.forPath(path); // 最终执行
对比这两种写法,后者的扩展性和代码可读性那是明细非常高的
对于Builder模式,其好处在于:
复杂操作标准化
// 原子性事务操作(Builder处理复杂参数)
client.inTransaction()
.delete().forPath("/node1")
.setData().forPath("/node2", data)
.and().commit();
异步操作统一范式
// 异步回调(Builder处理线程池/回调等)
client.getData()
.inBackground((client, event) -> {
// 处理回调
}, executorService)
.forPath(path);
路径操作与命名空间解耦
// 命名空间自动应用(Builder隐藏实现细节)
CuratorFramework namespacedClient = client.usingNamespace("app");
namespacedClient.getData().forPath("/config");
// 实际路径变成:/app/config
各种操作策略的集成
// 重试策略内置到Builder
client.getData()
.withRetry(policy) // 自定义重试策略
.forPath(path);
评论区