主题
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:
- Leader 生成 zxid(高 32 位 epoch + 低 32 位计数器),向所有 Follower 发 Proposal。
- Follower 把 Proposal 写入事务日志并
fsync,然后回 ACK。 - Leader 自己也落盘;收到过半 ACK 后发送 Commit。
- 所有节点把操作应用到内存 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_requests | max 远大于 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 = 10stext
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:2181bash
create /demo hello
get -s /demo
set /demo world
stat /demo
get -w /demo # 另开终端执行 set /demo v2,观察只收到一次通知
ls -w /demo
delete /democreate 默认创建持久节点;临时节点用 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,ruokbash
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 2181mntr 重点看: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 的设计才是合算的;拿它存业务数据或扛高并发,代价会迅速放大。