Zookeeper 面试八股文 25 道|深度详解版(傻子都能看懂)

本文是 Zookeeper 面试八股文深度版,共 25 道题,全程中文讲解,配合代码示例和 ASCII 图解,力求让每一个概念都从原理到实践彻底讲清楚。建议收藏后反复阅读,面试前过一遍即可。


Q1. Zookeeper 是什么?解决什么问题?

一句话定位:ZooKeeper 是 Apache 开源的分布式协调服务,专门解决分布式系统中的一致性协调难题。

背景故事:分布式系统最头疼的问题不是性能,而是”协调”——多台机器同时干活,怎么保证大家步调一致?比如:

  • 多个节点同时抢同一把锁,谁抢到算谁的?
  • 集群里哪台机器是 Master,大家怎么达成共识?
  • 配置文件改了,怎么通知所有节点及时更新?

这些问题每个系统都要解决,与其各自造轮子,不如交给 ZooKeeper 统一处理。

ZooKeeper 能做什么:

能力 典型使用场景
分布式锁 多节点抢占同一资源
Master 选举 Kafka Controller 选举、HBase Master
服务注册发现 早期 Dubbo 注册中心
配置中心 配置热更新
分布式屏障 多节点同步等待
命名服务 全局唯一 ID 生成

核心设计哲学:ZooKeeper 并不自己实现这些功能,而是提供一套原语(树形数据模型 + Watch 机制 + Session 管理),让开发者在这些原语上组合出各种协调逻辑。就像乐高积木,基础块很简单,但能拼出各种复杂形状。

ZooKeeper 的保证:

  1. 顺序一致性:同一客户端的操作按提交顺序执行
  2. 原子性:每个操作要么成功要么失败,没有中间状态
  3. 单一系统映像:客户端看到的视图是一致的
  4. 可靠性:一旦写入成功,数据不丢失
  5. 实时性:客户端会在有限时间内看到最新数据(有界滞后)

Q2. ZNode 的4种类型

ZNode 是 ZooKeeper 数据模型的基本单元,类似文件系统中的”文件+目录”的混合体。ZNode 有 4 种类型,理解它们是理解一切 ZK 应用的基础。

1
2
3
4
5
ZNode 四种类型
├── 持久节点 (PERSISTENT) ← 创建后永久存在,需手动删除
├── 临时节点 (EPHEMERAL) ← Session 断开自动删除,不能有子节点
├── 持久顺序节点 (PERSISTENT_SEQUENTIAL) ← 永久存在 + 自动追加序号
└── 临时顺序节点 (EPHEMERAL_SEQUENTIAL) ← Session 断开删除 + 自动追加序号

逐类详解:

1. 持久节点(PERSISTENT)

最普通的节点,创建后一直存在,直到你主动删除它。适合存储配置信息、命名空间定义等需要长期保留的数据。

1
2
3
4
5
// 用 Curator 创建持久节点
client.create()
.creatingParentsIfNeeded()
.withMode(CreateMode.PERSISTENT)
.forPath("/config/database", "jdbc:mysql://localhost:3306/db".getBytes());

2. 临时节点(EPHEMERAL)

Session 断开后自动删除,是实现服务注册发现和分布式锁的核心原语。重要限制:临时节点不能有子节点(因为 Session 断开时 ZK 无法保证原子地删除整棵子树)。

1
2
3
4
5
// 服务上线时创建临时节点
client.create()
.withMode(CreateMode.EPHEMERAL)
.forPath("/services/order-service/192.168.1.100:8080", "UP".getBytes());
// 服务宕机后,ZK 自动删除这个节点,其他节点收到 Watch 通知

3. 持久顺序节点(PERSISTENT_SEQUENTIAL)

创建时 ZK 自动在节点名后追加一个全局单调递增的 10 位数字序号(如 /lock/node-0000000001)。适合实现需要记录操作顺序的场景。

4. 临时顺序节点(EPHEMERAL_SEQUENTIAL)

最常用的高级类型!同时具备”Session 断开自动删除”和”顺序编号”两个特性。分布式公平锁就是靠它实现的(详见 Q12)。

1
2
3
4
5
// 创建临时顺序节点
String path = client.create()
.withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
.forPath("/lock/node-", null);
// 返回类似:/lock/node-0000000003

一张表总结:

类型 Session 断开删除 顺序编号 能有子节点
PERSISTENT 否 否 是
EPHEMERAL 是 否 否
PERSISTENT_SEQUENTIAL 否 是 是
EPHEMERAL_SEQUENTIAL 是 是 否

Q3. Zookeeper 的数据模型

ZooKeeper 的数据模型是一棵层级树(类似 Unix 文件系统),根节点是 /,每个路径都是绝对路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ZooKeeper 数据树示意图

/
├── zookeeper/ ← ZK 内部使用,存储配额等信息
│ └── quota/
├── config/ ← 配置中心数据
│ ├── database ← 叶子节点,存储实际数据
│ └── redis
├── services/ ← 服务注册中心
│ ├── order-service/
│ │ ├── 192.168.1.100:8080 ← 临时节点,服务实例
│ │ └── 192.168.1.101:8080
│ └── user-service/
│ └── 192.168.1.102:8080
└── lock/ ← 分布式锁
├── node-0000000001 ← 临时顺序节点
└── node-0000000002

每个 ZNode 包含的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ZNode 结构
├── data ← 实际数据(字节数组),最大 1MB
├── stat ← 元数据统计信息
│ ├── czxid ← 创建时的 ZXID
│ ├── mzxid ← 最后修改时的 ZXID
│ ├── ctime ← 创建时间(毫秒)
│ ├── mtime ← 最后修改时间(毫秒)
│ ├── version ← 数据版本号(每次修改 +1)
│ ├── cversion ← 子节点版本号
│ ├── aversion ← ACL 版本号
│ ├── ephemeralOwner ← 临时节点的 Session ID(持久节点为0)
│ ├── dataLength ← 数据长度
│ └── numChildren ← 子节点数量
└── children ← 子节点列表(只存路径名,不存数据)

关键限制:每个节点最大存储 1MB 数据

这个限制是有意为之的设计。ZooKeeper 是协调服务,不是数据库,不应该存储大量数据。1MB 的限制防止开发者滥用 ZK 存储业务数据。如果需要存大数据,只在 ZK 存路径/地址,真正的数据放 MySQL/Redis。

版本号(version)用于乐观锁:

1
2
3
4
5
6
7
8
// 读取当前版本
Stat stat = new Stat();
byte[] data = client.getData().storingStatIn(stat).forPath("/config/database");

// 带版本号更新,如果版本不匹配则抛出 BadVersionException
client.setData()
.withVersion(stat.getVersion()) // 乐观锁
.forPath("/config/database", "new-value".getBytes());

Q4. Watch 机制:一次性触发、事件类型、持续监听

Watch 机制是 ZooKeeper 最核心的特性之一,它让客户端能够监听节点变化并异步接收通知。

一次性触发原理:

Watch 是一次性的。每次 Watch 触发后自动失效,如果想继续监听,必须重新注册 Watch。这个设计是为了减少 ZK 服务端的内存开销——如果 Watch 永久有效,服务端需要维护海量长连接订阅关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
Watch 工作流程:

客户端 ZooKeeper 服务端
| |
|-- getData("/config", true) -->| 注册 Watch
|<--- 返回当前数据 ------------|
| |
| (其他客户端修改了 /config)
| |
|<--- WatchedEvent 通知 -------| Watch 触发,自动失效!
| |
|-- getData("/config", true) -->| 必须重新注册 Watch
|<--- 返回最新数据 ------------|

Watch 事件类型:

事件类型 触发条件
NodeCreated 监听的节点被创建
NodeDeleted 监听的节点被删除
NodeDataChanged 节点数据发生变化
NodeChildrenChanged 子节点列表发生变化(增加或删除子节点)

不同 API 注册的 Watch 监听不同事件:

  • getData(path, watch) → 监听 NodeDeleted、NodeDataChanged
  • exists(path, watch) → 监听 NodeCreated、NodeDeleted、NodeDataChanged
  • getChildren(path, watch) → 监听 NodeDeleted(父节点删除)、NodeChildrenChanged

如何实现持续监听(递归Watch):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 方法一:手动递归重注册(原生客户端常用写法)
public void watchConfig(String path) {
try {
// 读取数据的同时注册 Watch
byte[] data = client.getData()
.usingWatcher((WatchedEvent event) -> {
// Watch 触发后,处理事件
System.out.println("配置变化:" + event.getType());
// 重新注册 Watch(关键!)
watchConfig(path);
})
.forPath(path);
System.out.println("当前值:" + new String(data));
} catch (Exception e) {
e.printStackTrace();
}
}

// 方法二:Curator 的 NodeCache(推荐,自动重注册)
NodeCache nodeCache = new NodeCache(client, "/config/database");
nodeCache.getListenable().addListener(() -> {
ChildData currentData = nodeCache.getCurrentData();
if (currentData != null) {
System.out.println("配置更新:" + new String(currentData.getData()));
}
});
nodeCache.start(true); // true 表示立即拉取当前数据

Watch 的注意事项:

  1. Watch 通知是异步的,客户端收到通知时数据可能已经被修改多次
  2. Watch 通知不携带变化后的数据,需要重新调用 getData 获取
  3. 网络断开重连后,之前注册的 Watch 会丢失,需要应用层重新注册

Q5. Session 管理:超时、临时节点与 Session 的关系、Session 重连

Session 是客户端与 ZooKeeper 服务端之间的逻辑连接,是 ZK 很多核心特性的基础。

Session 的建立过程:

1
2
3
4
5
6
7
客户端                         ZooKeeper 集群
| |
|--- ConnectRequest (timeout=30s) ->| 发起连接,协商超时时间
|<-- ConnectResponse (sessionId) ---| 服务端返回 Session ID
| |
|--- Ping (每 sessionTimeout/3) -->| 心跳保活
|<-- Ping Response -----------------|

Session 超时的含义:

sessionTimeout 不是连接超时,而是Session 失效的等待时间。客户端断开后,ZK 服务端不会立刻删除 Session,而是等待 sessionTimeout 时间,给客户端重连的机会。超时后才真正删除 Session 及其关联的所有临时节点。

1
2
3
4
5
6
7
// 创建客户端时设置 Session 超时
CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString("localhost:2181")
.sessionTimeoutMs(30000) // Session 超时:30秒
.connectionTimeoutMs(5000) // 连接超时:5秒(TCP 层面)
.retryPolicy(new ExponentialBackoffRetry(1000, 3))
.build();

临时节点与 Session 的绑定关系:

1
2
3
4
5
6
7
Session A (sessionId=0x1234)
├── 创建了 /services/order/192.168.1.100:8080 (临时节点)
└── 创建了 /lock/node-0000000001 (临时顺序节点)

当 Session A 超时:
├── /services/order/192.168.1.100:8080 → 自动删除
└── /lock/node-0000000001 → 自动删除

Session 重连的三种状态:

状态 含义
CONNECTED 正常连接
SUSPENDED 连接断开,等待重连(Session 未超时)
LOST Session 超时,所有临时节点已删除

处理 Session 重连的正确姿势:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
client.getConnectionStateListenable().addListener((c, newState) -> {
switch (newState) {
case SUSPENDED:
// 连接断开,暂停对外服务,等待重连
log.warn("ZK 连接断开,暂停服务...");
break;
case RECONNECTED:
// 重连成功且 Session 仍有效,无需重新注册临时节点
log.info("ZK 重连成功,Session 仍有效");
break;
case LOST:
// Session 超时!临时节点已被删除,必须重新注册
log.error("ZK Session 丢失!重新初始化...");
reRegisterServices(); // 重新注册所有临时节点
break;
}
});

面试陷阱:Session 重连成功(RECONNECTED 状态)时,如果 sessionTimeout 内重连,临时节点不会被删除,不需要重建。只有 LOST 状态才需要重建。


Q6. ZAB 协议(Zookeeper Atomic Broadcast)

ZAB 是 ZooKeeper 的核心一致性协议,全称 ZooKeeper Atomic Broadcast(ZooKeeper 原子广播协议)。它保证了 ZK 集群的数据一致性和高可用性。

ZAB 协议分为两个阶段:崩溃恢复和消息广播。

阶段一:崩溃恢复(Leader 选举)

当集群启动或 Leader 宕机时,触发崩溃恢复,通过选举选出新 Leader(详见 Q7)。

阶段二:消息广播(正常工作阶段)

一旦有了 Leader,就进入消息广播阶段,所有写请求都通过 Leader 处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
写请求广播流程(2PC 简化版):

客户端 Leader Follower1 Follower2
| | | |
|-- setData -->| | |
| |-- Propose ---->| |
| |-- Propose ---------------------->|
| | | |
| |<-- ACK --------| |
| |<-- ACK ----------------------------|
| | | |
| (收到过半 ACK,提交) | |
| |-- Commit ----->| |
| |-- Commit ----------------------->|
| | | |
|<-- Success --| | |

关键特性:两阶段提交 + 过半写成功

Leader 发出 Proposal 后,只需要收到过半节点(包含自身)的 ACK,就可以发出 Commit,不需要等所有节点确认。这是 ZK 在一致性和可用性之间的权衡——牺牲了强一致性(少数节点可能滞后),换来了高可用性。

ZAB 的数据恢复保证:

崩溃恢复阶段,ZAB 必须满足两个条件:

  1. 已提交的事务必须恢复:如果事务已在 Leader 上 Commit 并发给了 Follower,选出新 Leader 后这条事务不能丢
  2. 未提交的事务必须丢弃:如果 Leader 只是 Propose 了事务但还没 Commit 就宕机了,新 Leader 必须丢弃这条事务

这就是 ZXID 中 epoch 字段的作用——新 Leader 的 epoch 比旧 Leader 大,旧 epoch 的未提交事务会被自动丢弃。


Q7. Leader 选举流程(FastLeaderElection)

ZooKeeper 默认使用 FastLeaderElection(快速 Leader 选举) 算法,依赖三个核心参数:

  • myid:每台机器的唯一标识,配置在 myid 文件中(整数,通常 1~255)
  • ZXID:最新事务 ID,ZXID 越大说明数据越新
  • epoch:选举轮次,每次选举 epoch 加 1

选举规则(谁能当 Leader):

优先选 epoch 最大的 → epoch 相同选 ZXID 最大的 → ZXID 相同选 myid 最大的

完整选举流程图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
集群:Server1(myid=1), Server2(myid=2), Server3(myid=3)
假设三台同时启动,ZXID 都为 0

Step 1: 每台服务器先投票给自己
Server1 投票:(epoch=1, zxid=0, myid=1)
Server2 投票:(epoch=1, zxid=0, myid=2)
Server3 投票:(epoch=1, zxid=0, myid=3)

Step 2: 交换投票,PK 规则:epoch > zxid > myid
Server1 收到 Server2 的票:myid=2 > myid=1,更新投票为 Server2
Server1 收到 Server3 的票:myid=3 > myid=2,更新投票为 Server3

Server2 收到 Server1 的票:myid=1 < myid=2,不更新
Server2 收到 Server3 的票:myid=3 > myid=2,更新投票为 Server3

Server3 收到 Server1/2 的票:myid=3 是最大,不更新,继续投 Server3

Step 3: 统计选票
Server3 获得票数:3票(Server1、Server2、Server3 都投了 Server3)
过半节点数 = floor(3/2) + 1 = 2
Server3 票数 3 >= 2,当选 Leader!

Step 4: 状态变更
Server3 → LEADING(Leader)
Server1 → FOLLOWING(Follower)
Server2 → FOLLOWING(Follower)

选举期间集群不可用:选举过程中,集群不处理任何读写请求。典型的选举时间是 200ms ~ 几秒,取决于网络状况和节点数量。

Leader 宕机后的重新选举:Follower 检测到与 Leader 的连接断开(超过一定时间收不到心跳),立刻进入 LOOKING 状态,触发新一轮选举,epoch 自动加 1,旧 Leader 的未提交事务被丢弃。


Q8. Leader、Follower、Observer 三种角色

Leader:

  • 集群唯一的写入节点,所有写请求必须经过 Leader
  • 负责发起事务 Proposal,收到过半 ACK 后 Commit
  • 维护与所有 Follower/Observer 的心跳连接
  • 参与 Leader 选举投票

Follower:

  • 处理客户端读请求(本地直接返回)
  • 转发写请求给 Leader
  • 接收 Leader 的 Proposal 并 ACK,接收 Commit 后提交到本地
  • 参与 Leader 选举投票(这是与 Observer 最大的区别)

Observer:

  • 职责与 Follower 基本相同(处理读、转发写、同步数据)
  • 不参与 Leader 选举投票
  • 不参与写事务的 Quorum(不需要给 Proposal 发 ACK)

为什么需要 Observer:

假设你想扩大集群规模提升读性能,直接加 Follower 会有问题:Follower 越多,Leader 等待 ACK 的时间越长,写性能下降。Observer 的引入解决了这个矛盾——加 Observer 可以提升读性能,又不影响写性能。

1
2
3
4
典型部署:3 Follower(保障高可用)+ N Observer(提升读吞吐)

客户端读 → 任意 Follower/Observer 直接返回(快)
客户端写 → Leader → 过半 Follower ACK → 提交(Observer 不计入过半)

配置 Observer:

1
2
3
4
5
# zoo.cfg
server.1=zoo1:2888:3888
server.2=zoo2:2888:3888
server.3=zoo3:2888:3888
server.4=zoo4:2888:3888:observer # 末尾加 :observer 即为 Observer 角色

Q9. ZXID 的结构:高32位(epoch)+ 低32位(事务计数)

ZXID(ZooKeeper Transaction ID,事务ID)是一个 64 位长整数,结构如下:

1
2
3
4
5
6
7
8
9
10
11
ZXID 结构(64位):

63 32 31 0
┌──────────────────┬─────────────────────┐
│ epoch (32位) │ counter (32位) │
└──────────────────┴─────────────────────┘
选举轮次 事务计数

示例:ZXID = 0x0000000200000045
epoch = 0x00000002 = 2(第2任 Leader)
counter = 0x00000045 = 69(第69条事务)

epoch(高32位):

每次 Leader 选举成功,epoch 加 1。新 Leader 上任后,所有事务的 ZXID 中 epoch 部分都会更新为新值。这确保了:

  • 新 Leader 的事务 ZXID 一定大于旧 Leader 的所有事务 ZXID
  • 旧 Leader 生成的未提交事务(带旧 epoch)在新 Leader 上会被识别并丢弃

counter(低32位):

同一任 Leader 内,每产生一条事务,counter 加 1,严格单调递增。这保证了同一任 Leader 期间,事务有全局顺序。

ZXID 的作用:

1
2
3
4
5
6
7
// 伪代码:新 Leader 处理 epoch 不匹配的事务
for (Transaction tx : pendingTransactions) {
if (getEpoch(tx.zxid) < currentEpoch) {
// 旧 epoch 的未提交事务,直接丢弃
discard(tx);
}
}

在选举中的作用:ZXID 越大说明节点见过的事务越多,数据越新,优先当 Leader,避免数据回滚。


Q10. Zookeeper 的一致性保证:顺序一致性,读到旧数据的场景

这是面试高频考点,很多人以为 ZK 是强一致性,其实不然。

ZooKeeper 提供的是顺序一致性(Sequential Consistency),不是强一致性(Linearizability)。

区别:

  • 强一致性:写入完成后,任意节点立刻能读到最新值
  • 顺序一致性:同一客户端的操作有序,但不同客户端可能读到旧值

为什么会读到旧数据:

1
2
3
4
5
6
7
8
9
时间轴:

Leader Follower1 Follower2(慢)
| | |
|-- Commit A --->| |
| | |
| (Follower2 还未收到 Commit A) |
| | |
客户端读 Follower2 → 读到 Commit A 之前的旧数据!

Follower 接收到 Commit 是异步的,Leader commit 后 Follower 可能有几毫秒到几十毫秒的延迟才能同步到。

如何强制读到最新数据(sync 操作):

1
2
3
4
5
// 方法:在 getData 之前先调用 sync,强制 Follower 与 Leader 同步
client.sync().forPath("/config/database");
byte[] data = client.getData().forPath("/config/database");

// Curator 中的 Watcher 机制也会自动 sync

sync() 的原理:客户端发送一个 sync 请求给连接的 Follower,Follower 将这个请求转发给 Leader,Leader 处理后告知 Follower 已同步到最新,然后客户端再发 getData 请求。

ZK 适合做什么:

由于存在读旧数据的可能性,ZK 不适合做要求强一致读的场景。ZK 的典型应用(分布式锁、选举、配置中心)都能容忍短暂的不一致,因为 Watch 机制会在数据变化后及时通知。


Q11. Zookeeper 集群规模:为什么推荐奇数节点?过半原则

过半原则(Quorum):

ZK 的写操作需要超过半数节点(含 Leader)确认才能提交:

1
2
3
4
5
过半 = floor(N/2) + 1

3 节点集群:过半 = 2,最多容忍 1 个节点故障
5 节点集群:过半 = 3,最多容忍 2 个节点故障
7 节点集群:过半 = 4,最多容忍 3 个节点故障

为什么推荐奇数节点:

关键是比较容错能力与节点数量的性价比:

1
2
3
4
5
6
节点数  过半数  最大容错  说明
3 2 1 推荐:最小高可用配置
4 3 1 不推荐:容错数与3节点相同,却多用一台机器
5 3 2 推荐:比3节点多容1个故障
6 4 2 不推荐:容错数与5节点相同,多一台机器
7 4 3 推荐:大规模部署

结论:4 节点的容错能力 == 3 节点,6 节点的容错能力 == 5 节点,偶数节点纯属浪费。所以推荐 3/5/7 奇数节点。

脑裂(Split Brain)问题:

网络分区时,两个分区可能各自认为自己是多数派,同时选出 Leader,数据不一致。过半原则解决了这个问题:

1
2
3
4
5
5 节点集群,网络分区为 [2节点] 和 [3节点]:
分区1(2节点):2 < 过半数(3),无法选出 Leader,停止服务
分区2(3节点):3 >= 过半数(3),可以正常工作

任意时刻,只有一个分区能拥有过半节点,天然防止脑裂。

实际推荐:生产环境一般用 3 节点(低成本高可用)或 5 节点(关键业务),超过 7 节点后写性能明显下降(Leader 需要等更多 ACK),不建议继续扩大 Follower 数,可用 Observer 扩读能力。


Q12. 分布式锁的实现:临时顺序节点 + Watch 前一个节点

用 ZK 实现分布式锁是面试最高频考题之一,必须吃透原理。

实现原理(公平锁):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
锁路径:/lock

Step 1: 所有竞争者都在 /lock 下创建临时顺序节点
客户端A 创建 /lock/node-0000000001
客户端B 创建 /lock/node-0000000002
客户端C 创建 /lock/node-0000000003

Step 2: 每个客户端检查自己是否是序号最小的节点
客户端A:序号最小(node-1),直接获取锁!
客户端B:序号不是最小,Watch node-1 的删除事件
客户端C:序号不是最小,Watch node-2 的删除事件

Step 3: 锁的释放
客户端A 业务完成,删除 node-1
→ node-1 删除,触发客户端B 的 Watch
→ 客户端B 重新检查,自己是序号最小,获取锁!

Curator InterProcessMutex 实现(推荐直接用):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CuratorFramework client = CuratorFrameworkFactory.newClient(
"localhost:2181", new ExponentialBackoffRetry(1000, 3));
client.start();

InterProcessMutex lock = new InterProcessMutex(client, "/lock/mylock");

try {
// 尝试获取锁,最多等待10秒
if (lock.acquire(10, TimeUnit.SECONDS)) {
try {
// 执行临界区代码
doBusinessLogic();
} finally {
lock.release(); // 必须在 finally 中释放
}
}
} catch (Exception e) {
Thread.currentThread().interrupt();
}

羊群效应(Herd Effect)问题及解决:

错误实现:所有等待者都 Watch 同一个父节点 /lock,每次锁释放,所有等待者都被唤醒,但只有一个能获得锁,其余都白忙了。

1
2
3
4
5
6
7
8
9
错误方式:
/lock 下有 100 个等待节点
node-1 删除 → 99个 Watch 全部触发!
99个客户端全部抢锁 → 只有一个成功,98个白跑

正确方式(Watch 前一个节点):
node-1 删除 → 只触发 node-2 的 Watch
node-2 客户端检查,确认自己是最小,获锁
其他 97 个节点继续安静等待

Watch 前一个节点就是解决羊群效应的关键:每个等待者只关注比自己序号小 1 的节点,形成链式唤醒,每次只唤醒一个等待者。


Q13. 分布式锁 vs Redis 分布式锁

这是常见对比题,考察你对两种方案权衡的理解。

ZooKeeper 分布式锁:

优点:

  • 天然公平锁:顺序节点保证先来先得,不会饥饿
  • 自动释放:客户端宕机后,临时节点自动删除,锁自动释放,不存在死锁风险
  • Watch 机制高效:等待者不需要轮询,Watch 触发即唤醒
  • 强一致性保证:ZAB 协议确保锁操作的原子性

缺点:

  • 性能较低:每次加锁都需要创建 ZNode,涉及一次或多次写操作,需要 Leader 广播,RT 较高(通常 5~20ms)
  • ZK 集群本身的稳定性:选举期间不可用

Redis 分布式锁:

优点:

  • 极高性能:单节点 Redis 内存操作,QPS 轻松 10w+,延迟 < 1ms
  • 简单易用:SET key value NX PX timeout 一条命令搞定

缺点:

  • 非公平锁:多个客户端竞争时,谁先请求 Redis 就谁得到锁,不保证先来先得
  • 锁超时问题:业务执行时间超过锁超时时间,锁自动释放,可能出现两个客户端同时持有锁
  • 主从切换风险(Redlock 问题):Redis 主从异步复制,主节点宕机后从节点可能没同步到锁,新主节点上锁丢失

选型建议:

场景 推荐方案
高并发、对延迟敏感、允许偶尔异常 Redis 分布式锁
对公平性要求高、不能有死锁风险 ZK 分布式锁
金融/支付等严格场景 ZK 或 Redlock(Redis 多节点方案)

Q14. 服务注册与发现:ZK 如何实现,与 Eureka/Nacos 对比

ZK 实现服务注册与发现的原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
服务提供者(Provider)启动时:
1. 在 /services/order-service/ 下创建临时节点
路径:/services/order-service/192.168.1.100:8080
数据:{"weight":1, "version":"v1.0"}

服务消费者(Consumer)启动时:
1. getChildren("/services/order-service") 获取所有实例列表
2. 注册 Watch,监听子节点变化

服务提供者宕机时:
1. 临时节点自动删除(Session 超时后)
2. Consumer 的 Watch 触发 NodeChildrenChanged 事件
3. Consumer 重新 getChildren 获取最新实例列表

Curator 实现服务发现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 服务注册(Provider 侧)
ServiceInstance<String> instance = ServiceInstance.<String>builder()
.name("order-service")
.port(8080)
.address("192.168.1.100")
.payload("v1.0")
.build();

ServiceDiscovery<String> discovery = ServiceDiscoveryBuilder
.builder(String.class)
.client(curatorClient)
.basePath("/services")
.thisInstance(instance)
.build();
discovery.start();

// 服务发现(Consumer 侧)
ServiceProvider<String> provider = discovery
.serviceProviderBuilder()
.serviceName("order-service")
.build();
provider.start();
ServiceInstance<String> chosen = provider.getInstance(); // 随机或轮询

三种注册中心对比:

维度 ZooKeeper Eureka Nacos
一致性模型 CP(ZAB,强一致) AP(最终一致) 支持 CP 和 AP
服务健康检测 临时节点(被动) 心跳(主动) 心跳 + 健康检查
推送机制 Watch 推送 轮询(30s) 长轮询/UDP 推送
选举期间 不可用 仍可读(旧数据) 可降级
适用场景 强一致要求 追求高可用 云原生推荐

重要结论:ZK 是 CP 系统,选举期间(通常几百毫秒)拒绝所有写请求,服务注册会短暂失败。Eureka 是 AP 系统,即使节点挂了也返回旧列表,不影响服务调用(虽然调用地址可能已下线)。对于服务注册发现这种场景,AP 往往比 CP 更实用,这也是 Eureka 和 Nacos 在微服务领域更受欢迎的原因。


Q15. Master 选举:如何用 ZK 实现主节点选举

ZK 实现 Master 选举有两种常见方式:

方式一:抢占式(争抢创建固定节点)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 所有节点都尝试创建同一个临时节点
// 只有一个会成功(ZK 保证创建的原子性)
try {
client.create()
.withMode(CreateMode.EPHEMERAL)
.forPath("/master", myAddress.getBytes());
// 创建成功,我是 Master!
becomeMaster();
} catch (NodeExistsException e) {
// 节点已存在,说明已有 Master,我是 Slave
becomeFollower();
// Watch Master 节点,一旦删除(Master 宕机),重新竞选
client.getData()
.usingWatcher(event -> reElect())
.forPath("/master");
}

优点:简单直接。缺点:所有节点同时竞争,如果 Master 宕机,所有 Slave 同时触发 Watch,羊群效应。

方式二:顺序节点式(Curator LeaderLatch)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 所有节点都创建临时顺序节点,序号最小的当 Master
// Curator 封装了这个逻辑

LeaderLatch leaderLatch = new LeaderLatch(client, "/election");
leaderLatch.addListener(new LeaderLatchListener() {
@Override
public void isLeader() {
System.out.println("我成为 Master 了!");
startMasterWork();
}

@Override
public void notLeader() {
System.out.println("我失去 Master 资格,变为 Slave");
stopMasterWork();
}
});
leaderLatch.start();

// 检查当前是否是 Leader
boolean isMaster = leaderLatch.hasLeadership();

Curator LeaderSelector(更灵活的方式):

1
2
3
4
5
6
7
8
9
10
11
LeaderSelector selector = new LeaderSelector(client, "/election",
new LeaderSelectorListenerAdapter() {
@Override
public void takeLeadership(CuratorFramework client) throws Exception {
System.out.println("获得领导权,开始工作...");
// 此方法返回时,自动放弃领导权(适合任务轮转场景)
doLeaderWork();
}
});
selector.autoRequeue(); // 失去领导权后自动重新竞选
selector.start();

两种 Curator API 的区别:

  • LeaderLatch:持续持有领导权直到手动关闭,适合需要长期 Master 的场景
  • LeaderSelector:takeLeadership() 返回后自动释放,多个节点轮流当 Master,适合任务调度

Q16. 分布式配置中心:getData + Watch 实现配置热更新

配置中心是 ZK 最经典的使用场景之一,原理非常清晰:

实现原理:

1
2
3
4
5
6
7
8
9
10
11
12
1. 配置管理系统将配置写入 ZK:
/config/app/database.url = "jdbc:mysql://prod:3306/db"
/config/app/redis.host = "redis-cluster:6379"

2. 应用启动时,读取配置并注册 Watch:
getData("/config/app/database.url", watch=true)

3. 配置变更时:
管理员修改 ZK 节点数据
→ Watch 触发(NodeDataChanged)
→ 应用收到通知,重新 getData 获取新配置
→ 应用动态更新配置,无需重启

完整代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class DynamicConfig {
private final CuratorFramework client;
private volatile String dbUrl; // volatile 保证可见性

public DynamicConfig(CuratorFramework client) {
this.client = client;
}

public void init() throws Exception {
// 使用 Curator NodeCache 自动处理 Watch 重注册
NodeCache nodeCache = new NodeCache(client, "/config/app/database.url");
nodeCache.getListenable().addListener(() -> {
byte[] data = nodeCache.getCurrentData().getData();
String newUrl = new String(data, StandardCharsets.UTF_8);
log.info("数据库配置更新:{} -> {}", dbUrl, newUrl);
dbUrl = newUrl;
// 可以在这里重新初始化数据库连接池
refreshConnectionPool(newUrl);
});
nodeCache.start(true); // true: 立即拉取当前值
dbUrl = new String(nodeCache.getCurrentData().getData());
}

public String getDbUrl() {
return dbUrl;
}
}

批量监听多个配置(PathChildrenCache):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 监听整个 /config/app 目录下的所有配置
PathChildrenCache childrenCache = new PathChildrenCache(
client, "/config/app", true); // true: 缓存数据内容

childrenCache.getListenable().addListener((client, event) -> {
switch (event.getType()) {
case CHILD_ADDED:
System.out.println("新增配置:" + event.getData().getPath());
break;
case CHILD_UPDATED:
System.out.println("更新配置:" + event.getData().getPath()
+ " = " + new String(event.getData().getData()));
break;
case CHILD_REMOVED:
System.out.println("删除配置:" + event.getData().getPath());
break;
}
});
childrenCache.start(PathChildrenCache.StartMode.BUILD_INITIAL_CACHE);

Q17. 分布式屏障(Barrier):多节点同步等待

分布式屏障(Distributed Barrier)用于让多个节点在某个条件满足前全部阻塞,条件满足后同时继续执行。典型场景:分布式批处理任务,所有 Worker 准备好后再同时开始。

实现原理:

1
2
3
4
5
6
7
8
9
10
11
场景:5个 Worker 必须全部就绪后才能开始处理任务

Step 1: 每个 Worker 启动时在 /barrier/workers/ 下创建临时顺序节点
Worker1 创建 /barrier/workers/node-0000000001
Worker2 创建 /barrier/workers/node-0000000002
...
Worker5 创建 /barrier/workers/node-0000000005

Step 2: 每个 Worker 检查当前子节点数量
数量 < 5:等待(Watch /barrier/workers/ 的子节点变化)
数量 = 5:屏障解除,所有 Worker 同时开始工作!

Curator DistributedBarrier 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 方式一:双重屏障(适合所有节点同时开始、同时结束的场景)
DistributedDoubleBarrier barrier = new DistributedDoubleBarrier(
client, "/barrier/batch-job", 5); // 5个节点

// Worker 代码
barrier.enter(); // 等待所有5个节点 enter(),然后同时通过
try {
doBatchWork(); // 所有节点同时工作
} finally {
barrier.leave(); // 等待所有5个节点 leave(),然后同时结束
}

// 方式二:简单屏障(适合等待某个条件的场景)
DistributedBarrier barrier = new DistributedBarrier(client, "/barrier/start-signal");

// 等待方(Worker)
barrier.waitOnBarrier(); // 阻塞,直到屏障被移除

// 触发方(Coordinator)
barrier.removeBarrier(); // 删除屏障节点,所有等待者同时通过

手动实现双重屏障的原理:

1
2
3
4
5
6
7
8
9
10
11
// 进入屏障(enter)
// 1. 创建临时顺序节点
// 2. 检查当前节点数是否达到预期数量
// - 未达到:Watch 子节点变化,等待
// - 达到:所有等待的节点收到通知,同时继续

// 离开屏障(leave)
// 1. 删除自己创建的节点
// 2. 检查当前节点数是否为0
// - 不为0:Watch 最小节点,等待
// - 为0:所有节点已退出,同时继续

Q18. Paxos vs ZAB:两者关系

这道题考察你对分布式一致性协议历史的理解。

Paxos 是什么:

Paxos 是 Leslie Lamport 在 1989 年提出的分布式一致性算法,被认为是分布式系统领域最重要的算法之一。它解决了如何在不可靠网络中的多台机器上对某个值达成共识的问题。

Paxos 的角色:

  • Proposer:提出提案
  • Acceptor:接受/拒绝提案
  • Learner:学习已达成共识的值

ZAB 与 Paxos 的关系:

ZAB 的设计者(Flavio Junqueira 等)明确表示 ZAB 借鉴了 Paxos 的思想,但专门为 ZooKeeper 的使用场景做了定制优化。

主要区别:

维度 Paxos ZAB
目的 通用分布式共识 专为 ZK 设计(Primary-Backup 模式)
操作单元 对单个值达成共识 对有序事务序列广播
顺序保证 不保证(需要 Multi-Paxos 扩展) 严格保证全局顺序
Leader 概念 Paxos 有 Primary 角色 Leader 是核心,所有写必须经过 Leader
崩溃恢复 通用处理 专门设计的崩溃恢复阶段

ZAB 的核心创新:

ZAB 相比 Paxos 多了一个核心设计——主备模式下的有序广播。ZK 的所有数据变更都是有序的,ZXID 严格单调递增,这在 Paxos 的通用框架里不是天然保证的。

简单说:Paxos 是通用算法,ZAB 是 Paxos 的特化版本,专门优化了 ZK 的主备复制和有序事务场景。


Q19. ZAB vs Raft:两种共识算法的对比

Raft 是 Diego Ongaro 在 2014 年提出的共识算法,目标是比 Paxos 更易于理解和实现。etcd、TiKV、CockroachDB 等都用了 Raft。

核心对比:

维度 ZAB(ZooKeeper) Raft(etcd/TiKV)
设计目标 专为 ZK Primary-Backup 设计 通用,易于理解
Leader 选举 FastLeaderElection(基于 ZXID + epoch + myid) 基于 Term + Log Index
日志复制 2PC 广播(Propose + Commit) AppendEntries RPC
成员变更 需手动配置,不支持动态 支持 JointConsensus 动态成员变更
工程实现 ZK 特有,难复用 易于移植,etcd/Raft 库可直接使用
读性能 Follower 可读(可能旧数据) 默认 Leader 读,可配置 Follower 读
生态 Kafka(已迁移)、Hadoop etcd、Consul、TiKV

选举机制的区别:

1
2
3
4
5
6
7
8
ZAB FastLeaderElection:
选举优先级:epoch > ZXID > myid
投票通过 UDP 广播,收到过半同意即当选

Raft:
选举依赖 Term(任期)和 Log Index
Candidate 发起 RequestVote,收到过半同意即当选
优先选日志更新的节点(通过比较 lastLogTerm 和 lastLogIndex)

Raft 更易理解的原因:Raft 将共识问题分解为 Leader 选举、日志复制、安全性三个独立子问题,每个子问题有清晰的边界和规则,便于实现和验证。ZAB 的实现细节更多地与 ZK 的特定架构耦合。

结论:如果你要在系统中嵌入共识模块,优先选 Raft(有成熟库);ZK/ZAB 作为现成的协调服务,不需要关心其内部算法实现。


Q20. ZK 的性能瓶颈

写操作必须经过 Leader:

所有写操作(create、setData、delete)都必须由 Leader 处理并广播,即使你的客户端连接到 Follower,Follower 也会将写请求转发给 Leader。这意味着:

  • 写操作的延迟 = 客户端到 Follower 的延迟 + Follower 到 Leader 的延迟 + Leader 广播到过半节点的延迟
  • Leader 是写操作的单点,Leader 的吞吐量上限就是集群的写吞吐量上限
  • 典型写 QPS:单集群 1000~5000 ops/s(视机器配置和网络情况)

读操作可能读到旧数据:

读操作由 Follower 本地直接返回,不经过 Leader,因此:

  • 读性能高(本地内存读取,延迟 < 1ms)
  • 但可能读到过期数据(Follower 未完全同步最新写入)

其他性能限制:

1
2
3
4
1. 内存限制:ZK 将所有数据保存在内存中,数据量受限于内存大小
2. 日志刷盘:每次写操作都需要 fsync 到事务日志,磁盘 I/O 是瓶颈
3. 快照:数据量大时,快照文件的生成和恢复耗时较长
4. Watch 数量:大量 Watch 会消耗服务端内存

性能调优建议:

1
2
3
4
5
6
7
8
9
# zoo.cfg 调优参数
tickTime=2000 # 基础时间单位(ms)
syncLimit=5 # Leader 与 Follower 同步超时(syncLimit * tickTime)
autopurge.snapRetainCount=3 # 保留的快照数量
autopurge.purgeInterval=1 # 自动清理周期(小时)

# JVM 参数(避免 GC 暂停影响选举)
-Xmx4g -Xms4g # 堆内存固定,避免动态扩展
-XX:+UseG1GC # G1 GC 减少 Stop-The-World 时间

ZK 不适合的场景:

  • 大数据量存储(数据量应 < 内存大小)
  • 高频写入(写 QPS > 5000)
  • 强一致性读(需要加 sync)

Q21. ZK 的 ACL 权限控制

ZooKeeper 提供了 ACL(Access Control List,访问控制列表)机制来保护数据安全。

ACL 的三个组成部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ACL = Scheme:ID:Permission

Scheme(认证方案):
- world:任何人(world:anyone 表示所有人)
- auth:当前已认证的用户
- digest:用户名:密码(SHA1 加密后 Base64 编码)
- ip:IP 地址或 IP 段(如 192.168.1.0/24)
- sasl:SASL 认证(Kerberos)

Permission(权限,5种):
- CREATE (c):创建子节点
- READ (r):读取节点数据和子节点列表
- WRITE (w):修改节点数据
- DELETE (d):删除子节点
- ADMIN (a):设置 ACL

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 方式1:digest 认证(用户名密码)
String credentials = "admin:password";
client.addAuthInfo("digest", credentials.getBytes());

// 创建只有当前认证用户才能读写的节点
List<ACL> acl = Arrays.asList(
new ACL(ZooDefs.Perms.READ | ZooDefs.Perms.WRITE, ZooDefs.Ids.AUTH_IDS)
);
client.create().withACL(acl).forPath("/secure-data", "secret".getBytes());

// 方式2:IP 白名单限制(只允许内网访问)
List<ACL> ipAcl = Arrays.asList(
new ACL(ZooDefs.Perms.ALL, new Id("ip", "192.168.1.0/24"))
);

// 方式3:world:anyone(公开节点,不推荐用于生产)
List<ACL> openAcl = ZooDefs.Ids.OPEN_ACL_UNSAFE; // 任何人有所有权限

Curator 简化 ACL 设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Curator 提供了更友好的 ACL 构建器
ACLProvider aclProvider = new DefaultACLProvider() {
@Override
public List<ACL> getDefaultAcl() {
return ZooDefs.Ids.CREATOR_ALL_ACL; // 创建者有所有权限
}
};

CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString("localhost:2181")
.authorization("digest", "admin:password".getBytes())
.aclProvider(aclProvider)
.retryPolicy(new ExponentialBackoffRetry(1000, 3))
.build();

生产环境注意事项:

  • 避免使用 OPEN_ACL_UNSAFE(任何人有所有权限)
  • 服务内部通信建议用 IP 白名单 + digest 双重认证
  • ZK 的 ACL 不会递归继承,子节点需要单独设置 ACL

Q22. ZK 客户端(Curator):为什么用 Curator 而不是原生客户端

原生 ZooKeeper 客户端的痛点:

1
2
3
4
5
6
7
8
9
10
// 原生客户端创建会话(复杂且易错)
ZooKeeper zk = new ZooKeeper("localhost:2181", 30000, event -> {
if (event.getState() == Event.KeeperState.SyncConnected) {
// 连接成功,才能开始操作
// 但这里是异步回调,主线程怎么知道连接好了?
// 需要自己写 CountDownLatch 等待...
}
});
// 连接是异步的,这里可能还没连上就开始操作了
zk.getData("/path", false, null); // 可能抛异常

原生客户端存在的问题:

  1. 连接是异步的,需要手动等待连接建立
  2. 没有自动重试,网络抖动时请求失败需要自己处理
  3. Watch 是一次性的,持续监听需要手动重注册,代码复杂且容易漏
  4. 没有高级抽象,分布式锁、Leader 选举、服务发现都要从零实现
  5. Session 管理复杂,Session 过期后的恢复需要自己处理

Curator 的解决方案:

Curator 是 Netflix 开源的 ZooKeeper 客户端封装库,现已成为 Apache 项目。它解决了原生客户端的所有痛点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Curator 的优雅使用
CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString("localhost:2181")
.sessionTimeoutMs(30000)
.connectionTimeoutMs(5000)
.retryPolicy(new ExponentialBackoffRetry(1000, 3)) // 自动重试
.namespace("myapp") // 命名空间隔离(所有操作前自动加前缀)
.build();
client.start(); // 同步等待连接,简单!

// 自动重试的操作(网络抖动时自动重试)
byte[] data = client.getData().forPath("/config"); // 失败自动重试3次

// NodeCache 自动重注册 Watch
NodeCache cache = new NodeCache(client, "/config/db");
cache.getListenable().addListener(() -> {
// Watch 触发,自动重注册,不用手写递归
System.out.println("配置变化: " + new String(cache.getCurrentData().getData()));
});
cache.start();

Curator 提供的高级抽象:

功能 Curator API
分布式锁 InterProcessMutex、InterProcessReadWriteLock
Leader 选举 LeaderLatch、LeaderSelector
分布式队列 DistributedQueue、DistributedPriorityQueue
分布式屏障 DistributedBarrier、DistributedDoubleBarrier
服务发现 ServiceDiscovery
持续监听 NodeCache、PathChildrenCache、TreeCache

Q23. Curator 的重试策略(RetryPolicy)和分布式锁(InterProcessMutex)

Curator 重试策略(RetryPolicy):

Curator 提供了多种重试策略,适应不同的业务需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 1. 指数退避重试(推荐):重试间隔指数增长
RetryPolicy policy1 = new ExponentialBackoffRetry(
1000, // 初始等待时间(ms)
3, // 最大重试次数
5000 // 最大等待时间上限(ms)
);
// 重试间隔:1000ms, 2000ms, 4000ms(不超过5000ms)

// 2. 固定间隔重试
RetryPolicy policy2 = new RetryNTimes(
3, // 最大重试次数
1000 // 每次等待时间(ms)
);

// 3. 只重试一次
RetryPolicy policy3 = new RetryOneTime(1000); // 等待1秒后重试一次

// 4. 直到超时为止
RetryPolicy policy4 = new RetryUntilElapsed(
5000, // 最大总等待时间(ms)
500 // 每次重试间隔(ms)
);

// 5. 不重试(调试时用)
RetryPolicy policy5 = new RetryPolicy() {
@Override
public boolean allowRetry(int retryCount, long elapsedTimeMs, RetrySleeper sleeper) {
return false;
}
};

InterProcessMutex 深度解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
InterProcessMutex lock = new InterProcessMutex(client, "/locks/order");

// 方式一:阻塞等待(直到获得锁)
lock.acquire();
try {
// 临界区
} finally {
lock.release();
}

// 方式二:超时等待(推荐)
boolean acquired = lock.acquire(10, TimeUnit.SECONDS);
if (acquired) {
try {
// 临界区
} finally {
lock.release();
}
} else {
throw new RuntimeException("获取锁超时");
}

// InterProcessMutex 的可重入特性(同一线程可以多次 acquire)
lock.acquire(5, TimeUnit.SECONDS);
lock.acquire(5, TimeUnit.SECONDS); // 同一线程,直接通过(可重入)
lock.release();
lock.release(); // 需要 release 两次,与 acquire 次数对应

InterProcessMutex 底层实现原理:

1
2
3
4
5
6
7
8
9
1. acquire() 被调用时:
- 在 /locks/order 下创建临时顺序节点:/locks/order/_c_xxx-lock-0000000001
- 获取父节点所有子节点,排序
- 如果自己的节点是最小的,获锁成功
- 否则,Watch 比自己小一位的节点,进入等待

2. release() 被调用时:
- 删除自己创建的临时顺序节点
- 唤醒 Watch 了该节点的下一个等待者

读写锁(InterProcessReadWriteLock):

1
2
3
4
5
6
7
8
9
InterProcessReadWriteLock readWriteLock = new InterProcessReadWriteLock(client, "/locks/data");

// 读锁(共享,多个读者可以同时持有)
InterProcessMutex readLock = readWriteLock.readLock();
readLock.acquire();

// 写锁(独占,写者持有时,读者和其他写者都阻塞)
InterProcessMutex writeLock = readWriteLock.writeLock();
writeLock.acquire();

Q24. ZK 在 Kafka 中的作用(已向 KRaft 迁移)

历史上,Kafka 重度依赖 ZooKeeper 来管理集群元数据。理解这段历史有助于理解 ZK 的典型使用场景。

ZK 在 Kafka 中承担的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/kafka
├── /brokers
│ ├── /ids ← Broker 注册(临时节点)
│ │ ├── 0 ← Broker 0 的信息(host、port、版本)
│ │ ├── 1
│ │ └── 2
│ └── /topics ← Topic 元数据
│ └── /order-topic
│ └── /partitions
│ └── /0/state ← Partition 0 的 Leader 和 ISR 信息
├── /controller ← Kafka Controller(临时节点)
│ └── 1 ← 当前 Controller 是 Broker 1
└── /admin
└── /delete_topics ← 待删除的 Topic 列表

具体作用:

  1. Broker 注册:Broker 启动时在 /brokers/ids/ 下创建临时节点,宕机后自动删除
  2. Controller 选举:所有 Broker 争抢创建 /controller 临时节点,成功者为 Controller(负责 Partition Leader 选举)
  3. Topic 元数据管理:Topic 创建/删除/配置变更写入 ZK
  4. Consumer 组管理(旧版本):Consumer Offset 存储在 ZK(新版本已迁移到 __consumer_offsets Topic)

KRaft 迁移(Kafka 2.8+ / 正式 GA 在 3.3+):

Kafka 社区推进了 KRaft(Kafka Raft Metadata Mode) 项目,用内置的 Raft 共识协议替代 ZooKeeper,解决了以下问题:

  • ZK 成为外部依赖,增加了运维复杂度
  • ZK 的扩展性限制了 Kafka 的 Partition 数量上限
  • Controller 切换期间依赖 ZK 导致恢复时间较长
1
2
3
4
5
6
7
8
9
# Kafka 3.x 无 ZK 模式启动(KRaft 模式)
# 生成 Cluster UUID
kafka-storage.sh random-uuid

# 格式化存储目录
kafka-storage.sh format -t <uuid> -c config/kraft/server.properties

# 启动(不再需要 ZooKeeper)
kafka-server-start.sh config/kraft/server.properties

面试建议:提到 ZK 在 Kafka 的作用时,要同时提到 KRaft 迁移,显示你的知识是最新的。


Q25. 面试高频:ZK Leader 宕机,服务调用会受到什么影响?

这道题考察你对 ZK 可用性的深刻理解,是区分背八股文和真正理解的分水岭。

影响分析(不可用时间窗口):

1
2
3
4
5
6
7
8
9
10
11
时间线:

T0: Leader 宕机
T1: Follower 检测到 Leader 心跳超时(tickTime * syncLimit,默认 2s * 5 = 10s)
注意:实际可能更快,因为 TCP 断连会立刻触发
T2: Follower 进入 LOOKING 状态,开始 FastLeaderElection
T3: 新 Leader 选出(通常 200ms ~ 2s)
T4: 新 Leader 与 Follower 完成数据同步(几百 ms ~ 几秒,取决于未同步数据量)
T5: 集群恢复正常服务

不可用时间窗口 = T5 - T0,通常 1s ~ 30s(极端情况更长)

不同操作的影响程度:

写操作(100% 受影响):

Leader 宕机后,所有写操作(create、setData、delete)都会失败,客户端收到 ConnectionLoss 或 SessionExpiredException。业务系统如果依赖 ZK 写操作(如服务注册、配置更新),这段时间会失败。

读操作(Follower 仍可提供):

如果客户端连接的是 Follower,Follower 在选举期间仍然可以处理读请求(返回本地缓存数据,但可能是稍旧的数据)。

Watch 通知(可能延迟或丢失):

选举期间,新的 Watch 事件通知可能延迟。如果 Watch 事件发生在选举期间,理论上可能丢失(ZK 不保证 100% 不丢通知,但实际极少丢失)。

服务调用的影响(以 Dubbo + ZK 注册中心为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
场景一:ZK 宕机前已完成服务发现
→ 服务消费者本地缓存了提供者列表
→ ZK 选举期间,消费者继续用本地缓存调用提供者
→ 几乎无感知(前提:提供者本身仍在运行)

场景二:ZK 宕机时,有新服务实例上线/下线
→ 新实例的注册失败(写操作失败)
→ 旧实例下线信息未广播(Watch 无法触发)
→ 消费者可能调用到已下线的提供者,出现调用失败

场景三:ZK 宕机时,消费者也在重启
→ 消费者无法从 ZK 获取提供者列表
→ 服务无法初始化,消费者启动失败

最佳实践(降低 ZK 不可用的影响):

  1. 本地缓存:客户端框架(Dubbo、Spring Cloud ZK)应该将服务列表缓存到本地文件,ZK 不可用时从本地文件恢复
  2. 优雅降级:ZK 不可用期间,只影响注册/发现,不影响已建立的调用链路
  3. 奇数节点 + 异地容灾:3个机房各放 1~2 个 ZK 节点,避免单机房故障导致整个集群不可用
  4. 监控告警:监控 ZK 选举次数,选举频繁说明集群不稳定

一句话总结回答:Leader 宕机后,ZK 集群进入选举阶段,选举期间(通常 1~30 秒)所有写操作失败,读操作可能返回旧数据。对于已完成服务发现并有本地缓存的服务消费者,影响较小;对于正在注册或需要实时感知服务变更的场景,会有短暂不可用。这也是为什么生产环境推荐 ZK 集群 + 本地缓存兜底的组合方案。


总结:面试重点速查表

分类 核心考点
数据模型 ZNode 4种类型、1MB限制、树形结构
核心机制 Watch 一次性原理、Session 临时节点绑定
一致性协议 ZAB = 崩溃恢复 + 消息广播;ZXID = epoch + counter
Leader 选举 epoch > ZXID > myid;选举期间不可用
分布式锁 临时顺序节点 + Watch 前一个(解决羊群效应)
与竞品对比 ZK(CP) vs Eureka(AP);ZAB vs Raft;ZK锁(公平) vs Redis锁(性能)
性能 写瓶颈在Leader;读可能脏读;推荐奇数节点
工程实践 Curator 必用;RetryPolicy;LeaderLatch;NodeCache

如果本文帮到了你,欢迎点赞收藏,有问题可以在评论区交流。面试加油!


Zookeeper 面试八股文 25 道|深度详解版(傻子都能看懂)
https://whyalwaysme.lol/2026/09/05/2026-09-05-zookeeper-interview-deep/
作者
Cassiur
发布于
2026年9月5日
许可协议