目 录CONTENT

文章目录

Zookeeper

FatFish1
2025-07-21 / 0 评论 / 0 点赞 / 2 阅读 / 0 字 / 正在检测是否收录...

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);

0

评论区