ZooKeeper 3.4.10 集群深度剖析(上)—— ZAB 协议与核心架构
ZooKeeper 作为分布式系统的”协调中枢”,在企业内部几乎无处不在——配置中心、服务发现、分布式锁、Leader 选举、消息队列……这些场景背后都离不开 ZK。但很多中间件团队成员对 ZK 的理解停留在”会调 API”层面,对内部的 ZAB 协议、Session 机制、Leader 选举等核心原理缺乏系统认知。
本文以 ZooKeeper 3.4.10 + 5 节点集群为基准,深入源码层面,覆盖 ZK 的方方面面。上篇聚焦 ZK 的定位、集群架构和 ZAB 协议——这是理解 ZK 一切的基石。
一、ZooKeeper 是什么
1.1 官方定义
ZooKeeper 是一个分布式协调服务,为分布式应用提供高性能、高可用、严格顺序访问的协调能力。它本质上是一个精简的文件系统 + 通知机制。
┌──────────────────────────────────────────────────────┐
│ ZooKeeper 定位 │
│ │
│ 不是一个数据库 不是一个缓存 不是一个消息队列 │
│ ↓ ↓ ↓ │
│ 它是分布式系统间的 "协调器" —— 让多个进程达成共识 │
└──────────────────────────────────────────────────────┘
1.2 典型使用场景
┌─────────────┐
│ │
配置管理 │ │ 命名服务
┌──────────────┤ ZooKeeper ├──────────────┐
│ │ │ │
▼ └─────────────┘ ▼
┌─────────┐ ┌─────────┐
│ /config │ │ /naming │
│ /db.yml │ │ /rpc/1 │
│ /mq.yml │ │ /rpc/2 │
└─────────┘ └─────────┘
│ │
▼ ┌─────────────┐ ▼
分布式锁 │ │ Leader 选举
┌──────────┐ │ ZooKeeper │ ┌──────────┐
│ /lock/A │◄───────┤ ├───────►│ /election│
│ EPHEMERAL│ │ │ │ SEQUENTIAL│
└──────────┘ └─────────────┘ └──────────┘
- 配置管理:
/config/app/db.url,变更时 Watcher 通知所有客户端 - 命名服务:
/services/order-service/192.168.1.10:8080(Dubbo 经典用法) - 分布式锁:临时顺序节点 + Watch 前一个节点
- Leader 选举:EPHEMERAL_SEQUENTIAL 节点,序号最小者当选
- 队列:顺序节点实现 FIFO 队列(不推荐,ZK 不适合作高吞吐存储)
1.3 CAP 模型下的 ZK
在 CAP 三角中,ZK 明确选择了 CP(一致性 + 分区容错),牺牲了可用性:
C (Consistency)
/\
/ \
/ ZK \
/______\
/ \
/ \
A P
(Availability) (Partition Tolerance)
ZK = CP 系统
- 发生网络分区时,少数派节点不可用(拒绝服务),保证数据一致
- 这也是为什么 ZK 集群推荐奇数台(过半机制)
这意味着:当 5 节点集群中 3 台故障时,剩余 2 台无法形成过半,整个集群不可用。这是 ZK 的核心设计取舍。
二、集群架构总览 —— 5 节点全景图
2.1 物理拓扑
┌─────────────────────────────────────────────────────────────┐
│ ZooKeeper 5节点集群 │
│ │
│ ┌─────────┐ │
│ │ Leader │ (zk1: 10.0.0.1:2181) │
│ │ myid=1 │ │
│ └────┬────┘ │
│ │ │
│ ┌────────────┼────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │Follower │ │Follower │ │Follower │ │Observer │ │
│ │ myid=2 │ │ myid=3 │ │ myid=4 │ │ myid=5 │ │
│ │10.0.0.2 │ │10.0.0.3 │ │10.0.0.4 │ │10.0.0.5 │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ ▲ ▲ ▲ │
│ │ │ │ │
│ └────────────┼────────────┘ │
│ │ │
│ ┌────┴────┐ │
│ │ Clients │ (Java / C / Python ...) │
│ └─────────┘ │
└─────────────────────────────────────────────────────────────┘
角色说明(3.4.10):
| 角色 | 职责 | 参与投票 | 参与写 | 直接读 |
|---|---|---|---|---|
| Leader | 处理写请求、发起 Proposal、协调投票、管理 Session | ✓ | ✓ | ✓ |
| Follower | 转发写请求到 Leader、参与投票、处理读请求 | ✓ | ✗ | ✓ |
| Observer | 处理读请求、转发写请求、不参与投票 | ✗ | ✗ | ✓ |
Observer 的作用:在不降低写性能的前提下水平扩展读能力。每增加一个参与投票的节点,ZAB 的写延迟就增加(需要更多 ACK),但 Observer 不参与投票,所以可以自由扩。
2.2 单节点内部架构
┌─────────────────────────────────────────────────────────────┐
│ ZK 单节点内部架构 │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 网络层 (NIOServerCnxnFactory) │ │
│ │ Java NIO Selector + 多线程 Accept/Read/Write │ │
│ └──────────────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 请求处理器链 (RequestProcessor) │ │
│ │ │ │
│ │ Leader 链: │ │
│ │ PrepRequest → Proposal → Sync → Ack → Commit → Final│ │
│ │ │ │
│ │ Follower 链: │ │
│ │ FollowerRequest → Commit → Final │ │
│ └──────────────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 数据层 │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ │
│ │ │ DataTree │ │ SessionTracker│ │ Watcher │ │ │
│ │ │ (内存树) │ │ (会话跟踪) │ │ Manager │ │ │
│ │ └──────────────┘ └──────────────┘ └────────────┘ │ │
│ └──────────────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 持久化层 │ │
│ │ ┌──────────────────┐ ┌────────────────────────┐ │ │
│ │ │ Transaction Log │ │ Snapshot │ │ │
│ │ │ (事务日志 WAL) │ │ (快照 / 数据镜像) │ │ │
│ │ │ dataDir/version-2│ │ dataDir/version-2 │ │ │
│ │ │ log.xxx │ │ snapshot.xxx │ │ │
│ │ └──────────────────┘ └────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
2.3 核心数据结构:DataTree
ZK 的内存数据模型是一棵 DataTree,每个节点叫 DataNode:
// ZooKeeper 3.4.10 源码: src/java/main/org/apache/zookeeper/server/DataTree.java
public class DataTree {
// 根节点 "/" 为起点,in-memory 的 ConcurrentHashMap
private final ConcurrentHashMap<String, DataNode> nodes =
new ConcurrentHashMap<String, DataNode>();
// Watch 管理
private final WatchManager dataWatches;
private final WatchManager childWatches;
// 节点变化监听 (用于 Quorum 复制)
private final ArrayList<ProcessTxnResult> listeners;
}
// 每个 ZNode 的内存表示
public class DataNode implements Record {
byte[] data; // 节点数据
Long acl; // ACL 权限
StatPersisted stat; // 持久化状态
private Set<String> children; // 子节点名称集合
}
对应到用户可见的路径:
/ (DataNode: children=["config", "services", "locks"])
├── config
│ ├── db (DataNode: data="jdbc:mysql://...")
│ └── mq (DataNode: data="...")
├── services
│ └── order-service (DataNode: ephemeralOwner=sessionId)
└── locks
└── lock-0000000001 (DataNode: EPHEMERAL_SEQUENTIAL)
关键设计点:
- ZK 将所有数据全量加载在内存中(这也是为什么 ZNode 限制 1MB、不适宜做大容量存储)
- 节点间的关系通过
children集合维护,查找子节点是 O(1) HashMap 操作 ConcurrentHashMap保证读操作的并发安全,写操作通过 ZAB 保证全局有序
三、ZAB 协议 —— ZooKeeper 的灵魂
ZAB (ZooKeeper Atomic Broadcast) 是整个 ZK 最核心的协议。它解决了分布式系统中两个根本问题:
- Leader 正常工作时:如何高效地将写操作复制到所有节点(原子广播)
- Leader 崩溃后:如何选出新 Leader 并让所有节点数据一致(崩溃恢复)
ZAB 不是 Paxos,也不是 Raft。它是 ZooKeeper 为了满足自身需求专门设计的协议。核心诉求是:保证所有副本以相同的顺序执行相同的写操作。
3.1 zxid —— 全局事务 ID
理解 ZAB 的第一步是理解 zxid(ZooKeeper Transaction ID):
zxid 是一个 64 位的 Long 整数
┌──────────────────────┬──────────────────────────────────────┐
│ 高 32 位: epoch │ 低 32 位: counter │
│ (Leader 任期编号) │ (事务递增序号) │
└──────────────────────┴──────────────────────────────────────┘
示例:
epoch=1, counter=0 → zxid = 0x0000000100000000
epoch=1, counter=1 → zxid = 0x0000000100000001
...
epoch=1, counter=999 → zxid = 0x00000001000003E7
--- Leader 切换 ---
epoch=2, counter=0 → zxid = 0x0000000200000000
epoch=2, counter=1 → zxid = 0x0000000200000001
epoch 的作用:
- 每次选举产生新 Leader,epoch +1
- 用于区分不同 Leader 任期的事务,防止旧 Leader 的”僵尸”事务污染集群
- epoch 值越大,代表数据越新(选举时用于比较)
counter 的作用:
- 在同一个 epoch 内单调递增
- 反映了写操作的全序关系:zxid 越大,操作越晚
源码定义(3.4.10):
// src/java/main/org/apache/zookeeper/server/quorum/QuorumPeer.java
public class QuorumPeer extends ZooKeeperThread {
// 当前 epoch (每次选举成功后更新)
long currentEpoch;
// 已接受的最新 epoch
long acceptedEpoch;
// 从快照恢复时获取最新 zxid
public long getLastLoggedZxid() {
// 从事务日志中读取最后一条记录的 zxid
}
}
// zxid 的构造
// src/java/main/org/apache/zookeeper/server/util/ZxidUtils.java
public class ZxidUtils {
public static long getEpochFromZxid(long zxid) {
return zxid >> 32;
}
public static long getCounterFromZxid(long zxid) {
return zxid & 0xFFFFFFFFL;
}
public static long makeZxid(long epoch, long counter) {
return (epoch << 32) | (counter & 0xFFFFFFFFL);
}
}
3.2 原子广播 —— 一个写请求的完整旅程
这是 ZAB 协议最核心的流程。假设 Client 连接在 Follower-2 上,发起一个 create /config/app {"key":"value"} 请求:
时间线
│
▼
Client Follower-2 (myid=2) Leader (myid=1) Follower-3,4 (myid=3,4)
───── ────────────────── ────────────── ──────────────────────
│ ① Proposal
│ 1.写请求 ┌──────────┐
├──────────────►│ │ 生成zxid │
│ │ 2.转发请求 │ 写入日志 │
│ ├──────────────────────────►│ 构造Proposal│
│ │ └────┬─────┘
│ │ │
│ │ ② 广播 Proposal │
│ │◄───────────────────────────────┤
│ │◄───────────────────────────────┼──────────────────►│
│ │ │ │
│ │ ③ Follower 写日志+ACK │ │
│ │ ┌──────────┐ │ ┌──────────┐ │
│ │ │写事务日志 │ │ │写事务日志 │ │
│ │ │写入内存 │ │ │写入内存 │ │
│ │ │(可选) │ │ │(可选) │ │
│ │ └────┬─────┘ │ └────┬─────┘ │
│ │ │ ACK │ │ ACK │
│ │ ├──────────────────────►│ │────────►│
│ │ │ │ │
│ │ │ ④ 过半 ACK → Commit │ │
│ │ │ ┌──────────┐ │ │
│ │ │ │ 统计票数 │ │ │
│ │ │ │ >= 半数? │ │ │
│ │ │ └────┬─────┘ │ │
│ │ │ │ │ │
│ │ │ ⑤ 广播 Commit │ │
│ │◄───────┼────────────────────────┤ │
│ │◄───────┼────────────────────────┼─────────────────►│
│ │ │ │ │
│ │ ⑥ 应用数据到内存 │ ⑥ 应用数据到内存│
│ │ ┌──────────┐ │ ┌──────────┐ │
│ │ │写入DataTree│ │ │写入DataTree│ │
│ │ └──────────┘ │ └──────────┘ │
│ │ │ │ │
│ ⑦ 返回结果 │ │ │ │
│◄──────────────┤ │ │ │
│ │ │ │ │
关键源码走读(Leader 端 Proposal 流程):
// src/java/main/org/apache/zookeeper/server/quorum/Leader.java
public class Leader {
// 已提交的 Proposal 队列,用于 Follower 同步
ConcurrentLinkedQueue<Proposal> outstandingProposals;
/**
* 发起 Proposal:Leader 收到写请求后调用
*/
public Proposal propose(Request request) {
// 1. 生成 zxid
long zxid = (epoch << 32) | (++lastProposed);
// 2. 构造 Proposal 包
Proposal p = new Proposal();
p.packet = new QuorumPacket(NEWLEADER, zxid, ...);
p.request = request;
// 3. 写入本地事务日志
request.zxid = zxid;
request.hdr = new TxnHeader(sessionId, ..., zxid, ...);
txnLog.append(request);
// 4. 广播 Proposal 给所有 Follower
for (QuorumServer server : self.getVotingView().values()) {
QuorumPacket qp = new QuorumPacket(PROPOSAL, zxid, ...);
server.write(qp);
}
return p;
}
/**
* 收到 Follower 的 ACK 后,检查是否过半
*/
synchronized public void processAck(long sid, long zxid, ...) {
Proposal p = outstandingProposals.get(zxid);
p.acks.add(sid);
// 核心判断:ACK 数量 >= 半数 ?
if (p.acks.size() >= self.getQuorumVerifier().getVotingMembers().size() / 2) {
// 过半!发起 Commit
commit(zxid);
}
}
}
为什么是”过半”而不是”全部”?
5节点集群,过半 = ceil(5/2) = 3
情况 1:收到 3 个 ACK(含自己)→ Commit ✓
情况 2:只收到 2 个 ACK → 等待(可能超时触发新一轮选举)
"过半"保证了:
- 任意两次"过半"的集合一定有交集
- 交集节点知道上一次 Commit 的状态
- 新 Leader 一定来自"过半"的交集 → 拥有最新数据
3.3 ZAB 的流水线化 —— 为什么比朴素 2PC 快
朴素的 2PC(Two-Phase Commit)是串行的:Proposal → Commit → 下一个 Proposal → Commit ……
ZAB 做了关键优化——流水线化:
朴素 2PC (串行):
│ Proposal#1 │ Commit#1 │ │ Proposal#2 │ Commit#2 │ │ Proposal#3 │ Commit#3 │
─────────────────────────────────────────────────────────────────────────────► 时间
ZAB (流水线化):
│ Proposal#1 │ Proposal#2 │ Proposal#3 │ Commit#1 │ Commit#2 │ Commit#3 │
─────────────────────────────────────────────────────────────────────────────► 时间
关键差异:
- 不需要等 Commit#1 完成才 Proposal#2
- 多个 Proposal 可以同时在网络中飞行
- 提高吞吐量约 30%-50%(取决于网络延迟)
但 ZAB 保证了一个关键约束:Commit 消息必须按 zxid 顺序发送。即使 Proposal#3 先于 Proposal#2 到达 Follower,Leader 也必须保证 Commit#2 在 Commit#3 之前发送。
源码体现:
// Leader.java: commit() 方法保证顺序
synchronized public void commit(long zxid) {
Proposal p = outstandingProposals.get(zxid);
// 关键:通过 lastCommitted 追踪已提交的最大 zxid
if (zxid != lastCommitted + 1) {
// 有更小的 zxid 还没 Commit,必须等!
pendingCommits.add(p);
return;
}
// 可以按序提交
doCommit(p);
lastCommitted = zxid;
// 检查 pendingCommits 有没有可以提交的
while (!pendingCommits.isEmpty()) {
Proposal next = pendingCommits.peek();
if (next.packet.getZxid() == lastCommitted + 1) {
pendingCommits.poll();
doCommit(next);
lastCommitted = next.packet.getZxid();
} else {
break;
}
}
}
3.4 Follower 与 Leader 的请求处理器链
Leader 的处理器链:
┌──────────────────┐
│ Network │
│ (Client/其他节点) │
└────────┬─────────┘
│
▼
┌─────────────────────────┐
│ PrepRequestProcessor │
│ - 生成 TxnHeader │
│ - 设置 sessionId │
│ - 分配 zxid │
└────────┬────────────────┘
│
▼
┌─────────────────────────┐
│ ProposalRequestProcessor │
│ - Leader.propose() │
│ - 广播 Proposal │
│ - 等待 ACK │
└────────┬────────────────┘
│
┌───────────┴───────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ SyncRequest │ │ AckRequest │
│ Processor │ │ Processor │
│ - 写事务日志 │ │ - 处理 Follower │
│ - 生成快照(条件) │ │ 的 ACK │
└──────────────────┘ │ - 判断过半→Commit │
└────────┬─────────┘
│
▼
┌─────────────────────┐
│ CommitProcessor │
│ - 保证按序提交 │
│ - 协调 pendingCommits│
└────────┬────────────┘
│
▼
┌─────────────────────┐
│ FinalRequestProcessor│
│ - 应用更改到 DataTree │
│ - 触发 Watcher │
│ - 返回结果给 Client │
└─────────────────────┘
Follower 的处理器链:
┌──────────────────┐
│ Network │
└────────┬─────────┘
│
┌─────────────┴─────────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│FollowerRequest │ │ CommitProcessor │
│Processor │ │ (处理来自 Leader │
│ │ │ 的 Commit 消息) │
│ - 读请求:直接处理 │ └──────────┬───────────┘
│ - 写请求:转发到 │ │
│ Leader │ ▼
└──────────────────┘ ┌──────────────────────┐
│ FinalRequestProcessor│
│ - 应用 DataTree │
│ - 触发 Watcher │
│ - 返回结果 │
└──────────────────────┘
Follower 处理写请求时的转发逻辑(源码):
// src/java/main/org/apache/zookeeper/server/quorum/FollowerRequestProcessor.java
public class FollowerRequestProcessor extends ZooKeeperCriticalThread
implements RequestProcessor {
@Override
public void processRequest(Request request) {
if (request.isRead()) {
// 读请求:直接交给 CommitProcessor → FinalRequestProcessor
nextProcessor.processRequest(request);
} else {
// 写请求:必须转发给 Leader
request.setStale(); // 标记为待转发
// 通过 Learner 的链接发送给 Leader
zks.getFollower().request(request);
}
}
}
3.5 ACK 的语义
Follower 发送 ACK 的含义是:我已经把这个 Proposal 持久化到事务日志了。这是一个非常重要的承诺。
// src/java/main/org/apache/zookeeper/server/quorum/Learner.java
// Follower/Observer 的基类,处理来自 Leader 的 Proposal
protected void processPacket(QuorumPacket qp) {
switch (qp.getType()) {
case Leader.PROPOSAL:
// 收到 Proposal
TxnHeader hdr = new TxnHeader();
Record txn = SerializeUtils.deserializeTxn(qp.getData());
// 关键:先写事务日志!
zk.getZKDatabase().append(hdr, txn);
// 再发 ACK
QuorumPacket ack = new QuorumPacket(Leader.ACK, hdr.getZxid(), null);
leaderOs.writeRecord(ack, null);
break;
case Leader.COMMIT:
// 收到 Commit:应用到内存 DataTree
zk.getZKDatabase().commit();
break;
}
}
为什么 ACK 前必须先写日志?
如果 Follower 先发 ACK 再写日志:
1. Follower ACK ✓
2. Leader 收到过半 ACK → Commit
3. Follower 在写日志前崩溃了
4. Follower 重启后,它已经 ACK 了但没有这条数据
5. Leader 认为它已经有了,不再同步
→ 数据丢失!
正确做法(Follower 先写日志再 ACK):
1. Follower 写事务日志 ✓
2. Follower 发送 ACK
3. Follower 在 Commit 前崩溃
4. Follower 重启后,日志中有这条记录但没 Commit
5. 与 Leader 重新同步时,Leader 会告诉它 Commit 或回滚
→ 数据安全
四、崩溃恢复 —— 当 Leader 挂了
Leader 崩溃后,ZAB 进入崩溃恢复模式,做完三件事后回到广播模式:
- 选举新 Leader
- 数据同步(让所有节点追平到新 Leader 的状态)
- 恢复广播
┌─────────────┐
│ 广播模式 │
│ (正常工作) │
└──┬──────┬───┘
│ │
Leader崩溃 │ │ 新Leader就绪+数据同步完成
│ │
▼ │
┌─────────┴───┐
│ 崩溃恢复模式 │
│ 1.选举Leader │
│ 2.数据同步 │
└─────────────┘
4.1 Leader 活跃性检测
ZooKeeper 3.4.10 中,Follower 通过心跳检测 Leader 是否存活:
// QuorumPeer.java 中的 main loop
@Override
public void run() {
while (running) {
switch (getPeerState()) {
case LOOKING:
// 当前无主,参与选举
setCurrentVote(makeLEStrategy().lookForLeader());
break;
case FOLLOWING:
// 作为 Follower 运行
setFollower(makeFollower(logFactory));
follower.followLeader();
break;
case LEADING:
// 作为 Leader 运行
setLeader(makeLeader(logFactory));
leader.lead();
break;
}
}
}
Follower 的 followLeader() 方法中,如果发现与 Leader 的连接断开且超过 syncLimit * tickTime,Follower 会认为 Leader 已经挂了,将自己的状态改回 LOOKING,发起新一轮选举。
4.2 恢复后的数据同步(预告)
数据同步是 ZK 保证一致性的关键环节,三种策略:
- SNAP(全量同步):Follower 落后太多,Leader 发整个 DataTree 快照
- DIFF(增量同步):Follower 和 Leader 的差距在 Leader 的缓存范围内,只同步差异 Proposal
- TRUNC(截断):Follower 比 Leader 还新(上一轮 Commit 未完成),截断多余的
详细的数据同步过程将在中篇和下篇展开。
上篇小结
上篇核心要点回顾:
1. ZK 定位:分布式协调服务,CP 系统,不是数据库/缓存/消息队列
2. 集群角色:Leader(写+协调)、Follower(参与投票+读)、Observer(仅读)
3. 数据模型:DataTree + DataNode,全量内存存储,ConcurrentHashMap
4. ZAB 原子广播:
- zxid = epoch(32bit) + counter(32bit)
- 写流程:Proposal → ACK(过半) → Commit(支持流水线化)
- ACK 前必须写事务日志(保证不丢数据)
5. 崩溃恢复:Leader 挂 → 选举 → 同步 → 恢复广播
接下来中篇将深入 ZK 的数据模型细节、Session 机制(这是理解 ZK 客户端行为的基石)和 Watcher 通知机制。