ZooKeeper
ZooKeeper 是保存少量协调状态的分布式服务。应用可以用它记录配置、服务实例和选主信息,再通过版本检查、会话及变更通知协调多个进程。
理解它要同时看两层:ZooKeeper 集群怎样保持有序的状态,应用拿到这些状态后能做出什么判断。集群不会随意提交两套冲突历史,但一个已经失去资格的业务进程仍可能继续运行;这两个问题不能混为一谈。本文以 ZooKeeper 3.8.4 的常规投票集群与全局会话为基线。
1. 面试核心
先记住这几条,再读正文- znode 保存协调数据和版本信息。节点路径像目录树,但 ZooKeeper 不是存储大文件的分布式文件系统,也不是业务查询数据库。
- 临时节点属于创建它的会话,断开连接不等于会话已经过期。会话过期由服务端集群判断,断连的客户端可能暂时不知道自己已经失去资格。卡 · 会话与临时节点
- 通过读取注册的标准 Watch 是一次性变化通知,不是每次修改的历史记录。收到通知后重新读取当前状态,并按需要重新注册。卡 · Watch 与状态恢复
- ZAB 通过领导者激活、历史同步和多数派确认后的有序提交保持副本历史;选出候选领导者后,还不能跳过同步直接接收新写入。卡 · ZAB 与有序复制
- 多数派约束阻止互不相交的分区各自正常提交新写入,节点自认为是 Leader 不等于它仍有提交资格。应用选主还要处理旧执行者。卡 · 多数派、脑裂与应用选主
- 普通读取可能落后,不能把
sync后读取一概称为严格线性一致读。读取契约应与实际协议和版本对齐。卡 · 读取与请求结果
2. znode 与版本
znode 是 ZooKeeper 命名空间中的数据节点。/services/payments/instance-1 是一个绝对路径;节点可以保存一小段字节数据,也可以拥有子节点。路径里的“节点”与运行 ZooKeeper 的服务器是两种不同对象。
ZooKeeper 适合保存地址、状态和配置等较小数据。大对象可以存到专门的存储系统,ZooKeeper 只记录必要的引用。增加大量数据或很大的节点内容,会增加复制、持久化和恢复成本。
2.1 持久、临时与顺序节点
| 机制 | 含义 |
|---|---|
| 持久节点 | 创建后持续存在,直到被显式删除等操作改变;不会因创建客户端断开而消失 |
| 临时节点 | 绑定创建会话,会话关闭或过期后删除;不能再创建子节点 |
| 顺序节点 | 创建时在指定前缀后追加序号;可与持久或临时类型组合 |
顺序编号由父节点维护,适合给同一父节点下的候选者排序,不是跨所有路径的永久全局 ID。它还存在计数范围与父节点重建等条件,不能不加说明地当作永远不回退的外部 fencing token。
2.2 Stat、version 与 zxid
Stat 是读取节点时返回的元信息。需要先区分节点自己的版本和集群事务顺序:
| 字段 | 表达什么 |
|---|---|
version | 节点数据的修改版本,用于条件更新或删除 |
cversion | 子节点集合的变化版本 |
aversion | ACL 的变化版本 |
czxid、mzxid | 创建节点、最近修改节点数据的事务编号 |
ephemeralOwner | 临时节点所属会话的 ID;普通持久节点为 0 |
zxid 是 ZooKeeper 事务 ID,用来标识状态变更在复制历史中的顺序。它与一个节点的 version 不同:其他节点也可能发生事务,而当前节点的数据版本保持不变。
客户端读取数据及 version 后,可以把该版本交给 setData。版本匹配才更新,否则得到 BadVersion。这样能拒绝“基于旧值覆盖新值”的操作。传入 -1 表示不检查数据版本,不应把它当作条件更新。
版本属于当前节点的生命周期。路径被删除后重新创建,并不意味着旧的版本号还能证明它是原来那个对象。需要识别对象代次时,应在路径或协议中明确保存身份,不能只比较一个可能重新出现的数字。
3. 会话与临时节点
会话是客户端与 ZooKeeper 服务之间的逻辑关系,通常比一条 TCP 连接活得更久。客户端可以从一台服务器重新连接到另一台,并在条件满足时继续使用同一会话。
正常全局会话通过请求或心跳保持活跃。会话超时由客户端请求、服务端按配置协商,集群在足够长时间没有收到该会话活动后将其过期,并删除它拥有的临时节点。
这里有三个不同状态:
- 连接断开:客户端暂时不能可靠取得新状态,不能据此断定会话已经过期,也不能继续假定自己一定持有业务资格。
- 原会话恢复:在会话仍有效时重连,客户端可以继续使用原会话。
- 会话过期:原会话不能恢复;应用需要创建新会话,并重新建立注册、候选节点等状态。
过期是服务端判断,客户端断连期间收不到消息。等客户端重新连上时,才可能得知此前已经过期。因此,临时节点消失后,旧业务进程不会自动被杀死。使用它做锁或选主时,还要阻止旧执行者继续修改外部资源。
主动关闭会话也会清理临时节点。正常退出应明确关闭客户端;进程崩溃时则依赖集群的会话失效处理,两者不能都描述为“进程一停,节点立刻删除”。
4. Watch 与状态恢复
Watch 是状态变化通知。客户端在 exists、getData 或 getChildren 等读取中注册标准 Watch,相关变化发生后收到通知,这次注册随之消耗。
| 读取方式 | 关注的主要变化 |
|---|---|
exists | 路径创建、删除或数据修改 |
getData | 当前节点的数据修改或删除 |
getChildren | 子节点列表变化,或所观察节点被删除 |
通知告诉客户端“需要重新看状态”,不携带每次变化的完整历史。节点在通知到达和重新注册之间修改多次,客户端重新读取时可能直接看到最新值,无法还原中间每一次修改。
通常把读取当前状态与注册下一次 Watch 放在同一次 API 操作中,收到通知后再执行这一过程。客户端重连时会处理已有 Watch 的重新注册,但不能据此宣称断线期间的所有事件都能补齐。例如对尚不存在路径的存在性 Watch,可能错过断连期间的创建后又删除。
3.8.4 还提供持久及递归 Watch,它们不会在每次触发后移除;这改变了重新注册的方式,仍不等于拥有可按任意历史位置重放的变更日志。不要把 ZooKeeper Watch 与 etcd 的修订历史和 Watch当作相同契约。
4.1 读取、监听与条件更新示例
下面使用 ZooKeeper 3.8.4 的 Java 客户端,在专用本地实验集群中执行。args[0] 是该实验的连接串,例如三个本机端口的逗号分隔列表;/coding101-example 初始不存在。开放 ACL 仅用于这个隔离示例,真实服务应按客户端身份设置权限。
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.ZooDefs;
import org.apache.zookeeper.ZooKeeper;
import org.apache.zookeeper.data.Stat;
import static java.nio.charset.StandardCharsets.UTF_8;
public class ZkExample {
public static void main(String[] args) throws Exception {
CountDownLatch connected = new CountDownLatch(1);
try (ZooKeeper client = new ZooKeeper(args[0], 4000, event -> {
if (event.getState() == Watcher.Event.KeeperState.SyncConnected) {
connected.countDown();
}
})) {
if (!connected.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("connection timeout");
}
String path = "/coding101-example";
client.create(path, "v1".getBytes(UTF_8),
ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
CountDownLatch changed = new CountDownLatch(1);
Stat before = new Stat();
client.getData(path, event -> {
if (event.getType() == Watcher.Event.EventType.NodeDataChanged) {
changed.countDown();
}
}, before);
Stat after = client.setData(path, "v2".getBytes(UTF_8), before.getVersion());
if (!changed.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("watch timeout");
}
System.out.println(before.getVersion() + " -> " + after.getVersion());
System.out.println(new String(client.getData(path, false, null), UTF_8));
try {
client.setData(path, "stale".getBytes(UTF_8), before.getVersion());
throw new AssertionError("stale version accepted");
} catch (KeeperException.BadVersionException expected) {
System.out.println("BadVersion");
}
client.delete(path, -1);
}
}
}
输出依次为 0 -> 1、v2、BadVersion。getData 同时取得版本并注册监听,第一次 setData 使用版本 0 成功,节点数据版本变为 1;再次使用旧版本 0 就会失败。
这个示例只等待一次数据变化通知。持续维护本地状态的程序还要处理重新读取、连接状态和异常,不能只把回调次数当作成功写入次数。
5. ZAB 与有序复制
ZAB(ZooKeeper Atomic Broadcast)是 ZooKeeper 用于复制状态变更的原子广播协议。它需要让副本按同一顺序交付事务,并在领导者变化后保留已经提交的历史。
5.1 事务编号与正常写入
zxid 是一个 64 位编号,高 32 位表示领导时期 epoch,低 32 位表示该时期内的计数。新的领导时期使用新的 epoch,避免把新领导者的提案与旧时期混在一起。
正常写入大致经历以下过程:
- 客户端可以连接任一服务节点;需要修改状态的请求转交给 Leader 协调。
- Leader 为事务安排顺序,发送提案给副本。
- 副本记录提案,按持久化配置完成日志落盘后确认。协议的故障恢复保证依赖正确的持久化前提。
- Leader 获得所需法定人数确认后,按顺序提交,副本再按该顺序更新状态。
本文讨论普通固定成员的多数派配置,三个投票成员至少需要两个。源码既检查提案的确认集合,也维护提交顺序;不能把“某条提案收到足够 ACK”解释为可以绕过前面的事务直接应用。
5.2 选举与领导者激活
Leader 故障以后,需要先选出候选者,再同步历史并完成领导者激活。不是只找一个最大服务器 ID,就让它覆盖所有副本。
3.8.4 的快速选举比较候选者携带的 peerEpoch,再比较日志 zxid,最后才用服务器 ID 打破平局。选举轮次、候选历史与激活阶段共同参与恢复,不能只记住最后一个比较条件。
新 Leader 与跟随者交换和同步历史,必要时补日志、发送快照或截断不兼容的未提交后缀。已经提交的事务必须保留;未提交事务是否进入恢复后的历史,取决于恢复协议,不能简单概括为“全部保留”或“全部丢弃”。
跟随者完成所需同步后确认新的领导时期,Leader 取得相应法定人数确认,才能进入正常广播阶段。这个步骤把“被选中”和“已经准备好提交新写入”区分开来。
ZAB 与 Raft都处理复制历史及故障恢复,但它们的选举、恢复阶段和消息规则不同。不能直接把 Raft 的任期与提交规则替换几个名称,当作 ZAB 的完整解释。
6. 多数派、脑裂与应用选主
脑裂通常指发生故障或网络分区后,多个部分都认为自己有权继续服务。分析 ZooKeeper 时,要区分服务端能否提交事务,以及业务进程是否仍在执行。
6.1 集群提交资格
在正常协议、固定成员和持久化假设下,两个互不相交的分区不能同时各自取得多数。三节点拆成两台与一台时,两台互通的一侧可以完成必要的选举和恢复;单台一侧不能正常继续提交新事务。
旧 Leader 可能短时间仍认为自己是 Leader,但没有足够确认,就不能据此提交新写入。恢复连接后,它要服从有效的领导时期并同步历史。多数集合相交还需要协议对时期、历史和确认的规则配合,不能单靠“多数一定相交”替代完整恢复解释。
只读模式或暂时保留的本地数据也不能证明节点仍有写入资格。读取和写入的可用性与一致性应分别说明。
6.2 应用选主与旧执行者
应用可以在同一父路径下创建临时顺序节点,把最小序号的候选者视为当前执行者,其他候选者监听各自前驱,减少所有客户端同时被唤醒的竞争。前驱消失后重新读取和判断,不能只收到一个事件就直接开始工作。
这个模式依然需要处理“创建成功但响应丢失”、候选节点身份、会话过期及重连后的重新判断。尤其是旧执行者长时间暂停后恢复时,它可能不知道自己的临时节点已经消失,而新的执行者早已开始工作。
要保护外部资源,可以让资源端原子校验代次或 fencing token,并拒绝落后请求。编号本身也要满足相应的单调与故障恢复条件,不能随便取一个局部序号就认为完成了保护。机制见分布式锁。
7. 读取与请求结果
普通 getData 由连接的服务器读取本地已应用状态,不为每次读取执行一次多数派确认。因此,一个客户端已经完成写入,不代表另一个客户端立即就能从另一台服务器读到相同的新值。
同一有效会话内,ZooKeeper 维护客户端操作顺序及不倒退的观察关系;这仍不等于所有客户端在同一个时刻看到完全相同的状态。
7.1 sync 的条件
sync 用来协调所连服务器的进度,但 ZooKeeper 3.8.4 的 Internals 文档明确说明,它不是一次法定人数操作。极端故障时,认为自己仍是 Leader 的旧节点可能处理 sync,之后的读仍可能陈旧。因此,不能无条件宣称“sync 加普通读就严格线性一致”。
该文档将实际的法定人数操作,例如同一客户端成功完成写入后,再按顺序发起读取,作为更强保证的依据。这里不能省略客户端操作之间的顺序关系,换成“任意客户端写过,其他客户端就能立即读到”。面试回答应说明所需的读取目标与实现契约;不要为了获得一个术语保证,随意在业务中添加无意义写入。
7.2 响应丢失与恢复
成功响应能确认操作结果,连接丢失或超时则可能意味着结果未知。例如临时顺序节点已经创建,响应却丢失;直接重新创建会多出一个候选节点。
在一次三节点本地实验中,停止 Leader 之外的两个服务进程后,客户端更新 /durable,得到 ConnectionLoss。两个进程恢复后,这个更新值仍进入了最终状态。这个观察说明网络错误不能证明操作最终没有生效;它不意味着所有失败请求恢复后都会提交。
应用可以把请求身份写入可识别的路径或数据,重连后核对结果,再决定重试。普通网络异常、节点不存在、版本冲突和会话过期也需要分开处理:它们不是同一种失败,不能统一无限重试。
服务发现读取失败时,调用方是否继续使用已有快照,还要结合快照年龄、业务实例健康与恢复策略。读取成功但列表为空则是另一种结果,不能悄悄改写成“读取失败”。相关判断集中在服务发现,ZooKeeper 不替应用决定降级策略。
8 · 面试题卡片
本页题目与参考回答
8.1 ZAB 怎样完成正常复制和领导者故障恢复?
考察重点:事务顺序、epoch、法定人数、历史同步与领导者激活。
相关内容:ZAB 与有序复制。
参考回答
ZAB 让状态变更按统一顺序提交,并在更换领导者时恢复安全的历史。 正常阶段由 Leader 安排 zxid 和提案顺序,副本记录日志后确认,达到所需法定人数后按序提交。
故障恢复时先选举候选领导者,再同步副本历史并完成新领导时期的确认,之后才能处理正常新提案。已提交历史必须保留,未提交后缀按恢复规则处理。zxid 中的 epoch 区分领导时期,不能只靠最大服务器 ID 或单个副本的局部状态决定恢复结果。
8.2 ZooKeeper 怎样处理脑裂,能否因此保证业务只有一个执行者?
考察重点:多数派与有效领导权、服务端安全和应用执行之间的边界。
相关内容:多数派、脑裂与应用选主。
参考回答
正常协议下,互不相交的分区不能各自取得多数并提交冲突新历史;这不等于旧业务进程已经停止。 旧 Leader 自认为有效,也必须满足协议的确认条件才能提交。恢复后要按有效领导时期同步。
应用通过临时节点选主时,会话过期可以让新执行者取得资格,但旧进程暂停后恢复,仍可能向外部资源发送请求。需要在资源端校验有效代次或 fencing token,并结合业务幂等拒绝旧请求;单靠 ZooKeeper 中的节点变化不能终止旧进程。
8.3 客户端连接断开,临时节点会立即删除吗?
考察重点:连接与会话、服务端过期判断、客户端观察延迟。
相关内容:会话与临时节点。
参考回答
不会仅因为一次连接断开就立即删除,临时节点绑定的是会话。 有效会话可以重连;会话被集群判定过期或主动关闭后,相应临时节点才被删除。
断连客户端可能直到重连才知道自己已经过期。因此,不能把断连简单等同于失效,也不能在断连时继续无条件假定自己仍持有锁或执行资格。已经过期的会话需要重新创建,并重新建立应用状态。
8.4 Watch 能否保证客户端收到节点的每一次修改?
考察重点:一次性注册、通知与当前状态、断连和重新订阅。
相关内容:Watch 与状态恢复。
参考回答
标准 Watch 是一次性通知,不能保证逐次还原所有修改。 收到事件后应读取当前状态并重新注册;在这段间隔内发生的多次变化,可能只体现为读取到的最终状态。
断连时收不到通知,恢复后仍要重新确认状态。持久 Watch 减少重新注册工作,但也不是任意历史位置可重放的日志接口。需要完整变更历史时,应选择具有相应契约的机制。
8.5 ZooKeeper 普通读和 sync 后读取是否一定是最新值?
考察重点:本地读取、客户端顺序、法定人数操作与线性一致性。
参考回答
普通读可能返回落后的本地状态;在 3.8.4 的协议说明中,sync 后读取也不能无条件承诺严格线性一致。 sync 本身不是法定人数操作,极端故障下旧 Leader 可能处理它。
同一有效会话的顺序保证与全局实时最新并不是同一件事。需要更强保证时,应核对具体操作的法定人数与排序契约;同一客户端成功完成写入等实际法定人数操作,再按顺序读取,与单独的本地读有不同依据。
9. 参考资料
- ZooKeeper 3.8.4:Programmer's Guide
- ZooKeeper 3.8.4:ZAB 与一致性说明
- ZooKeeper 3.8.4:管理员配置与持久化条件
- ZooKeeper 3.8.4:选主与锁的应用模式
- ZooKeeper 3.8.4:FastLeaderElection 的候选比较
- ZooKeeper 3.8.4:Leader 的提交与激活
- ZooKeeper 3.8.4:SyncRequestProcessor 的日志提交与后续处理
- ZooKeeper 3.8.4:Java 客户端接口
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明