跳到正文

ZooKeeper 架构与 ZAB 协议 ​

一句话:ZooKeeper 是一个用 ZAB 复制的小型元数据存储,靠 Session 绑定临时节点、靠 Watch 通知变化;它解决的是分布式系统里选主、加锁、成员管理这些协调问题,不是存业务数据。

适用版本:ZooKeeper 3.8 / 3.9;3.5.3+ 的 4lw 默认行为与旧版不同。Java 客户端示例用 Curator 5.9.x。

为什么需要 ​

多台机器要达成「谁是 Leader」「谁持有锁」「现在有哪些实例在线」这类共识,自己实现会遇到网络分区、进程暂停、消息重排。ZK 把这些收敛成三个原语:一个全局有序的写日志(ZAB)、一个与连接绑定的会话(Session)、一个一次性的变更通知(Watch)。临时节点随会话消失,正好表达「实例活着」;顺序节点加 Watch 前驱,正好表达「排队」。

核心机制 ​

数据模型 ​

ZK 是一棵 znode 树,每个节点最多 1MB(jute.maxbuffer 默认约 1MB),全量数据放在内存。节点有四种类型:持久(PERSISTENT)、临时(EPHEMERAL)、持久顺序(PERSISTENT_SEQUENTIAL)、临时顺序(EPHEMERAL_SEQUENTIAL)。临时节点在会话过期时删除;顺序节点的后缀来自父节点的 cversion,持久化且单调递增。

stat 里常用的字段:zxid(最后一次写事务)、cversion(子节点变更次数)、version(本节点数据版本)、ephemeralOwner(临时节点所属会话,持久节点为 0)、numChildren。

写路径:ZAB 原子广播 ​

所有写请求都由 Leader 处理,Follower 收到后转发给 Leader:

  1. Leader 生成 zxid(高 32 位 epoch + 低 32 位计数器),向所有 Follower 发 Proposal。
  2. Follower 把 Proposal 写入事务日志并 fsync,然后回 ACK。
  3. Leader 自己也落盘;收到过半 ACK 后发送 Commit。
  4. 所有节点把操作应用到内存 DataTree。

先落盘再 ACK,保证「已提交」意味着数据已经在过半节点的磁盘上。只等 Leader 会丢数据,等全部 Follower 会被最慢的一台拖死,过半是可用性与一致性的折中。读请求不经过 Leader,直接读本地 DataTree,因此是顺序一致性而非线性一致性;需要读最新值时调用 sync()。

选举与状态机 ​

每个节点处于 LOOKING、LEADING、FOLLOWING 之一。Follower 在 syncLimit×tickTime 内收不到 Leader 心跳,或 Leader 失去多数派,节点进入 LOOKING 并广播投票 (myid, zxid);先比 zxid(数据新者胜),再比 myid(编号大者胜),拿到过半票即当选。新 Leader 的 epoch 递增,旧 Leader 恢复后发现自己的 epoch 落后,会降级为 Follower。

选举看 zxid 而不是只看 myid,是因为要让数据最完整的节点当 Leader;只看编号可能选出一个数据落后的节点,覆盖掉已提交的写。

Session 与 Watch ​

客户端连接时请求一个 sessionTimeout,服务端把它夹在 2×tickTime ~ 20×tickTime 之间(默认 tickTime=2000ms,即 4s–40s)。Java 客户端的 ping 间隔约为协商后超时的 1/3;超时未收到心跳,会话过期,临时节点被删除。

Watch 是一次性触发器:注册后节点发生变化,服务端推送一次通知并删除 Watch;客户端要重新注册。通知只告诉你「变了」,不包含新值,收到后必须重新读取。同一个客户端的通知按发生顺序串行投递;跨重连可能丢事件,所以正确做法是「收到通知就全量重读」,而不是在通知里做增量计算。Curator 的 NodeCache / TreeCache 封装了重注册和重读。

ZAB 与 Raft 的区别 ​

两者都是 Leader 主导的复制协议,但细节不同:

  • 日志方向:ZAB 和 Raft 都是 Leader 主动向 Follower 推送(Raft 的 AppendEntries 由 Leader 发),不是 Follower 主动拉取。
  • 选举限制:Raft 要求候选人的日志不落后于任何给它投票的节点;拿到多数票后,新 Leader 一定包含所有已提交日志。「Raft 可能出现已提交日志不在新 Leader」是错误说法。ZAB 用 zxid 最大者优先,同样保证这一点。
  • 提交规则:Raft 不允许 Leader 直接提交旧任期的日志,要等当前任期有一条日志提交后间接提交;ZAB 依靠 epoch 和 zxid 顺序保证旧 Leader 的 Proposal 不会越过新 Leader。
  • 选举收敛:ZAB 的快速选举通常在有限轮内收敛;Raft 的随机超时在撞票时可能多轮。差别在工程表现,不是正确性高低。

Kafka 的 ZK 依赖也要按版本说:ZK 模式在 3.9 及之前存在;KRaft 在 3.3 达到生产可用,4.0 只支持 KRaft、移除 ZooKeeper。ZK 模式下的 ZK 全挂,已有客户端能靠缓存的元数据继续收发一段时间,但 Controller 选举、分区 Leader 切换和元数据变更会停摆;不是「消息立刻全不可用」,也不是「可以长期无人值守」。

实战场景 ​

一、一次 Full GC 造出两个 Leader ​

现象:HBase 依赖的 ZK 集群在某个节点做 Full GC 期间(停顿约 12 秒),该节点上的服务实例临时节点被删除,另一个实例的 LeaderLatch 当选;但旧实例 GC 结束后继续按“我还是主”执行了 40 秒的写任务,两边同时写同一份元数据,出现 6 张表的 region 归属冲突(经验值,需校准),修复花了半天。

排查:会话过期是“确定失主”,不是“暂时失联”。

定位层取证据判定
会话是否过期客户端日志 ConnectionState.LOST、服务端 zk_ephemerals_count 变化LOST 表示会话已过期,临时节点已删
临时节点归属stat /<lock-path> 里的 ephemeralOwner 是否变成别的 sessionId换了 owner 即主身份已转移
停顿时长与超时GC 日志的停顿时间 vs 协商后的 sessionTimeout停顿超过会话超时必然过期
会话超时区间服务端把请求值夹在 2×tickTime ~ 20×tickTime(默认 tickTime=2000ms → 4s~40s)请求 30s 可能被夹到 40s 或 4s,以协商结果为准
是否被误当同步超时syncLimit×tickTime 是 Follower 落后上限与会话超时是两件事,混用会误导调参
恢复后行为连接恢复后是否重新 acquire()新会话必须重新抢主,旧状态不可复用

ZK 没有“锁续期”这个概念:客户端在会话超时内持续 ping 只能维持会话,不能延长已协商好的超时;会话一旦过期,临时节点立即删除。Curator 也不会“在过期前重建锁”,它做的是连接管理。

对症做法:

java
// 1. LOST 一律按“已失去主身份”处理:立刻停止写路径,不允许“等恢复再说”
CuratorConnectionStateListener listener = (client, newState, oldState) -> {
    switch (newState) {
        case LOST:
            log.error("zk session lost, demote immediately");
            roleManager.demote();            // 停写、停调度、停心跳
            break;
        case RECONNECTED:
            leaderLatch.recompute();          // 同一会话则节点仍在;新会话必须重新 acquire
            break;
        default: break;
    }
};
client.getConnectionStateListenable().addListener(listener);

// 2. 主身份判断不能靠“我记得我是主”,要靠数据
public boolean stillLeader() {
    Stat s = new Stat();
    byte[] d = zk.getData("/leader/current", false, s);
    return myId.equals(new String(d)) && s.getVersion() == myLeaderVersion;
}
text
3. GC 与会话超时的关系要在容量阶段确认:
   最大停顿(P99.9)必须显著小于协商后的 sessionTimeout,留 3 倍以上余量(经验值,需校准);
   做不到就换 GC 收集器/缩小堆,而不是把会话超时调到分钟级——
   那会让真实故障的发现时间同步变长。
4. 写路径要能被下游拒绝:给下游存储带单调令牌(fencing token),见 03 篇的对应场景。

复盘要点:判据是“LOST 之后到停写之间的时间”,这个间隔应该以毫秒计,可用埋点量化。另一个判据是“主身份切换演练”:主动 kill 主节点,验证旧主不再写、新主接管、无双写。选主语义只能保证“同一时刻多数派里只有一个 Leader”,它不保证旧进程不会继续跑一段——那必须靠 LOST 处理与 fencing 补上。

二、按 Watch 做增量计算,视图长期对不上 ​

现象:服务网关把 ZK 上当前的实例列表缓存在本地,收到 Watch 通知后按“某个节点新增了/删除了”做增量更新。运行两周后,缓存里的实例数与 getChildren 的真实结果不一致:既有多出来的(已下线实例仍被路由,造成 5xx),也有少了的(新实例收不到流量)。

排查:Watch 是一次性触发器,通知不包含新值。

定位层取证据判定
是否重新注册每次通知后是否重新 exists/getChildren(watch=true)不重注册则该节点之后不再触发
通知内容是否直接用通知里的“变化类型”推断新值通知只说“变了”,新值必须重读
跨重连事件客户端断连期间的变更是否有通知重连可能丢事件,增量必然偏差
顺序保证范围同一客户端通知按发生顺序串行投递跨连接、跨 session 不保证
读一致性是否直接把 Follower 本地 DataTree 当最新读不经过 Leader,是顺序一致性而非线性一致
Watch 数量echo wchs | nc 127.0.0.1 2181、zk_watch_count数量持续增长说明有重复注册或泄漏

对症做法:

java
// 1. 规则:收到通知 = 触发一次全量重读,不在通知里做增量计算
zk.getChildren(path, event -> {
    if (event.getType() != EventType.NodeDeleted) refreshFull();   // 全量替换本地引用
}, stat);

private void refreshFull() {
    List<String> live = zk.getChildren("/services/order", false);   // 全量读
    this.instancesRef.set(Set.copyOf(live));                        // 原子替换,读侧无锁
}

// 2. 需要读到最新值时先 sync(),再读本地
zk.sync();                       // 强制该 Follower 与 Leader 数据对齐后再读
List<String> live = zk.getChildren("/services/order", watch);

// 3. 用 Curator 的缓存封装,重注册与重读由框架负责
CuratorCache cache = CuratorCache.build(client, "/services/order");
cache.listenable().addListener((t, o, n) -> rebuildFromCache(cache));
cache.start();
text
4. 本地缓存要有“对账”通道:定时(例如 60s)无条件全量重读一次,
   把“靠事件保持正确”降级为“靠事件加速、靠对账保证正确”。
5. 大订阅面用 TreeCache/CuratorCache 而不是手工注册几十个 Watch,
   避免 zk_watch_count 膨胀带来通知风暴。

复盘要点:判据是“缓存视图与 getChildren 的差异率”,定时抽样比对即可;出现差异就告警,而不是等 5xx。这条边界要在设计文档写明:ZK 的读是本地 DataTree,跨节点可能读到略旧的数据;把“事件驱动 + 增量计算”当成真相来源是错的,真相来源永远是重读。相关运维口径见 ZK 生产踩坑与运维。

三、事务日志与快照同盘,fsync 拖垮整个集群 ​

现象:ZK 集群平时 zk_avg_latency 2ms。某天开始写请求超时、zk_outstanding_requests 涨到几千,随后一台 Follower 掉队、集群只剩 2/3 可用,客户端报 ConnectionLoss。同期磁盘使用率也逼近 100%——事务日志从不清理,越积越多。

排查:写路径要求“先落盘再 ACK”,盘就是提交延迟的下限。

定位层取证据判定
延迟与堆积echo mntr 的 zk_avg_latency/zk_max_latency、zk_outstanding_requestsmax 远大于 avg 即存在长尾阻塞
是否掉队zk_followers 与 zk_synced_followers 是否相等有掉队说明其 fsync 跟不上 Proposal
磁盘布局dataDir 与 dataLogDir 是否同一块盘同盘时快照与事务日志争抢 IO
日志堆积autopurge.purgeInterval 是否为 0(默认不清理)日志无限增长直到磁盘满
快照大小zk_approximate_data_size、zk_znode_count数据越大快照越久,fsync 抖动越明显
连接数echo cons | wc -l 与 maxClientCnxns(默认 60/IP)打满时新连接直接失败,表现为“连不上”

对症做法:

properties
# 1. 事务日志单独一块盘,与快照/数据分开的 IO 通道
dataDir=/data/zk/data
dataLogDir=/data/zk/log          # 关键:不与 dataDir 同盘

# 2. 打开自动清理,保留量按“能回滚多远”定
autopurge.purgeInterval=1        # 每小时清理一次(默认 0 表示不清理)
autopurge.snapRetainCount=5      # 默认 3,保留足够排障窗口
properties
# 3. 时间参数按“同步上限”理解,不要拿它当会话超时
tickTime=2000
initLimit=10          # 初始同步:10×2000ms = 20s
syncLimit=5           # 落后上限:5×2000ms = 10s
text
4. 容量侧:znode 单节点上限约 1MB(jute.maxbuffer 默认约 1MB)且全量数据在内存,
   不要往 ZK 塞大对象;把“数据体积”当成集群健康指标看,
   zk_approximate_data_size 持续增长就该迁走业务数据。
5. 监控 4lw 命令要显式加白名单(3.5.3+ 默认只放行 srvr):
   4lw.commands.whitelist=srvr,mntr,stat,cons,wchs,ruok

复盘要点:判据是“zk_max_latency 与 zk_synced_followers”两条:延迟长尾和掉队是同一根因的两个表现;zk_outstanding_requests 持续增长要立刻查盘,而不是先重启节点。多数派是硬约束——3 节点集群挂 1 台只剩 2 台时仍可写,但此时任何一台再抖就全集群不可写,扩到 5 节点才是“能容忍 2 台故障”,不要误以为 3 台是双机房容灾。

最小上手 ​

三节点集群(docker-compose.yml,ZooKeeper 3.9):

yaml
x-zk: &zk
  image: zookeeper:3.9
  environment: &env
    ZOO_SERVERS: server.1=zk1:2888:3888;2181 server.2=zk2:2888:3888;2181 server.3=zk3:2888:3888;2181
    ZOO_4LW_COMMANDS_WHITELIST: "srvr,mntr,stat,cons,wchs,ruok"
services:
  zk1:
    <<: *zk
    hostname: zk1
    ports: ["2181:2181"]
    environment: {<<: *env, ZOO_MY_ID: 1}
  zk2:
    <<: *zk
    hostname: zk2
    environment: {<<: *env, ZOO_MY_ID: 2}
  zk3:
    <<: *zk
    hostname: zk3
    environment: {<<: *env, ZOO_MY_ID: 3}

等价的 zoo.cfg 关键项:

properties
tickTime=2000
initLimit=10
syncLimit=5
dataDir=/data
clientPort=2181
server.1=zk1:2888:3888;2181
server.2=zk2:2888:3888;2181
server.3=zk3:2888:3888;2181
4lw.commands.whitelist=srvr,mntr,stat,cons,wchs,ruok

启动并进入客户端:

bash
docker compose up -d
docker compose exec zk1 zkCli.sh -server zk1:2181
bash
create /demo hello
get -s /demo
set /demo world
stat /demo
get -w /demo     # 另开终端执行 set /demo v2,观察只收到一次通知
ls -w /demo
delete /demo

create 默认创建持久节点;临时节点用 create -e,顺序节点用 create -s。get -w 的 Watch 触发一次后即失效,再 set 不会收到第二次通知。

生产动作 ​

时间参数。 tickTime 默认 2000ms,是心跳和超时的基本单位;initLimit=10 限制 Follower 初始同步时间,syncLimit=5 限制 Follower 落后 Leader 的 tick 数。syncLimit×tickTime 是同步上限,不是会话超时;会话超时由客户端请求、服务端在 2×tickTime ~ 20×tickTime 内协商。

4lw 白名单。 3.5.3+ 默认只放行 srvr,直接 echo stat | nc 会提示不在白名单。需要监控时显式配置:

properties
4lw.commands.whitelist=srvr,mntr,stat,cons,wchs,ruok
bash
echo mntr | nc 127.0.0.1 2181
echo cons | nc 127.0.0.1 2181 | wc -l
echo wchs | nc 127.0.0.1 2181

mntr 重点看:zk_server_state(leader/follower)、zk_followers 与 zk_synced_followers(是否掉队)、zk_outstanding_requests(请求堆积)、zk_avg_latency / zk_max_latency、zk_znode_count、zk_watch_count、zk_ephemerals_count、zk_approximate_data_size。

磁盘。 事务日志和快照默认都在 dataDir;生产建议 dataDir 与 dataLogDir 分盘。autopurge.purgeInterval 默认 0(不清理),autopurge.snapRetainCount 默认 3;不开自动清理,日志会一直增长直到磁盘满。maxClientCnxns 默认 60(每 IP 每服务端),连接泄漏会先把这里打满。

边界 ​

  • 不适合高吞吐写。 所有写都走 Leader,并且要过半节点 fsync;写 QPS 有明确上限,不能当消息队列或高并发计数器。
  • 不适合当数据库。 单节点 1MB、全量内存、写放大,适合少量关键元数据;大对象放对象存储或数据库。
  • 读可能落后。 本地读是顺序一致性,不是线性一致性;对新鲜度敏感的操作先 sync()。
  • 不是大规模服务发现的最佳选择。 大量客户端 Watch 同一节点会产生羊群效应;服务发现优先考虑 Nacos/Consul/etcd。
  • 没有原生的健康检查。 临时节点只证明连接活着,不证明服务能处理请求。
  • CP 系统。 多数派不可用时,少数派拒绝服务;不要指望单节点恢复后还能选主。
  • 没有锁续期。 会话过期即失锁,详见 03-分布式锁与Curator;生产事故处置见 04-生产踩坑与运维。

讲清楚 ​

ZooKeeper 可以拆成三层:ZAB 负责「写操作按同一顺序复制到多数派」,Session 负责「谁还活着」,Watch 负责「变化了通知你」。写请求由 Leader 生成 zxid、广播 Proposal,Follower 落盘后 ACK,过半就 Commit;读请求走本地,所以快但可能读到旧值。

临时节点是 Session 的延伸:连接断了、会话过期,节点自动删除,选主和锁就靠这个特性表达「持有者还活着」。Watch 是一次性的,通知后要重新注册,所以生产上用 Curator 的 Cache 封装,收到通知就全量重读。

它不是数据库,也不是高吞吐写入系统。把它放在「少量、关键、需要一致性的元数据」这个位置上,ZAB 和 Session 的设计才是合算的;拿它存业务数据或扛高并发,代价会迅速放大。

把一门技术讲透到既懂也会用