ZooKeeper 原理 | ZooKeeper 状态变化应对法
本文基于 ZooKeeper(ZK) 3.6.0 版本介绍应对状态变化的策略。
ZK 的常见用途包括同步配置、服务发现和协同分布式过程等,这些用途都要求应用程序能够监听 ZK 节点集合的状态。
为了达到这个目的,ZK 客户端可以轮询 ZK 集合以获取状态。然而,轮询并不是最佳的状态监听方式。对于频繁变化的状态,轮询可能会错过某些状态变化;对于偶尔变化的状态,轮询可能会导致额外的开销。
基于这样的观察,ZK 提供了 Watcher 机制,能够避开轮询来应对状态变化。这个机制允许应用程序在特定的 znode 上注册 Watcher 以在 znode 的状态发生变化的时候收到通知。
通过 Watcher 机制应对状态变化可以采用如下的框架代码。
zk.exist("/myZnode", myWatcher, existsCallback, ctx);
Watcher myWatcher = new Watcher() {
public void process(WatchedEvent e) {
// process the watch event
}
}
StatCallback existsCallback = new StatCallback() {
public void processResult(int rc, String path, Object ctx, Stat stat) {
// process the result of the exist call
}
}
这里我们设置了两个回调逻辑。
•StatCallback 对应 ZK 客户端异步请求的回调,具体地说,是 exist 请求。•Watcher 对应节点变化时 ZK 客户端收到 WatchedEvent 后的回调。
这是两个不同的回调逻辑,一个在客户端请求完成是被调用,一个在监视 znode 状态发生变化是被调用。
WatchedEvent 的类型
可以看到,Watcher 的回调逻辑主要处理的是 WatchedEvent 对象,它是一个包括 KeeperState 和 EventType 两个字段的数据对象。我们从分类的角度来看需要处理的 WatchedEvent 对象都有哪些可能。
不同 KeeperState 的事件
从 KeeperState 的角度来说,总共包含这几种会话状态。
•SyncConnected•ConnectedReadOnly•Disconnected•Expired•Closed•AuthFailed•SaslAuthFailed
所有的会话状态从名字即可看出其含义。除了 SyncConnected 之外,所有的 KeeperState 类型都会对应到 EventType.None 事件类型,这代表没有节点状态变化,而是会话状态发生了变化。
ZK 使用了相同的 Watcher 机制来处理应用程序相关事件的通知。这是某种程度的重载,在简化了类别区分和工程实现的同时也增加了用户区分重载的心智负担。
不同 EventType 的事件
SyncConnected 状态代表会话正常,除了第一次连接上 ZK 时有一个 EventType.None 事件类型的通知,其后都会与下列事件类型的某一种相关联。
•NodeCreated•NodeDeleted•NodeDataChanged•NodeChildrenChanged•DataWatchRemove•ChildWatchRemoved•PersistentWatchRemoved
其中最后三种事件对应 3.5 和 3.6 版本以后支持的 Watcher 移除功能和永久 Watcher 对象的移除功能,我们稍后展开其细节。
前四种事件从名字即可看出其内容,分别代表节点被创建、删除、改变内容或节点的子节点发生改变。对于 ZK 对节点状态变化的抽象,有三点需要注意。
第一点,节点状态变化事件实现上是一个单纯的不带信息的枚举。
换句话说,ZK 产生的节点状态变化事件仅仅表达某事件已发生,而无法确定事件的具体内容。特别是对于 NodeChildrenChanged 事件,仅代表节点的子节点发生改变,到底是新增子节点、子节点删除还是子节点数据变化,都不清楚。这就要求 ZK 客户端在收到事件时必须主动再次发出请求查询节点的状态或数据内容。
这不同于 etcd 中带有变更内容的事件。如果通知中包含变更内容,我们就有可能在客户端仅接受一次通知,即在本地维护数据缓存的状态,从而无需再次请求查询节点。
第二点,由于上述原因,ZK 原始的 Watcher 机制可能导致应用程序错过中间过程的节点状态变化。
由于 ZK 实现上采用单次触发语义,即设置的 Watcher 在出现状态变化时触发一次并被移除,Watcher 被移除后该节点发生的事件不再被监视。即使 ZK 客户端在收到事件后重新设置 Watcher 监视,由于网络传输天然的异步性质,仍然有可能错过事件。
在《ZooKeeper 分布式过程协同技术详解》一书中为这一点开脱时提到,既然每次都需要重新拉取状态,那么单次触发能够在事件频发的情况下减少事件平均产生的通知数量。但是实践当中这几乎不成为一个好处,而从用户角度来说却要麻烦地处理复杂的异步情况。
ZK 3.6.0 版本引入了 Persistent (Recursive) Watcher 类型,能够支持 Watcher 多次触发,使 ZK 客户端不会错过任何一个事件。
第三点,Watcher 在服务器分为 data/child 两类,在客户端对其注册分为 exist/data/child 三类。
从客户端角度来看,exist 请求能够对任意路径尤其是尚未创建的路径设置 Watcher 监视,因此独立拥有一类 Watcher 来处理。getData 和 getChildren 只能对已经存在的节点路径设置 Watcher 监视,又根据其监视的是节点本身或节点的子节点,分别拥有一类 Watcher 来处理。
从服务器角度来看,DataWatcher 仅监视确切路径对应的节点,在节点创建、删除或者数据更改时产生通知,而 ChildWatcher 只有在节点的子节点发生变化时产生通知。由于服务器端的 DataWatcher 无所谓是否路径对应的节点是否存在,因此就少了一个分类。换个角度看,也可以认为在 ZK 客户端驱动设置 Watcher 时已经进行过检查,所以服务器端的 DataWatcher 可以合并情况而不会错误的产生节点状态变化事件。
服务器的实现里,DataWatcher 和 ChildWatcher 由不同的集合管理,它们会响应不同的事件。在移除 Watcher 时,可以指定 Watcher 的类型是 DataWatcher 还是 ChildWatcher 又或者不做区分来移除。另外,上面提到的 PersistentWatcher 首先属于 DataWatcher 类型,如果设置了 Recursive 参数,则同时还是 ChildWatcher 类型。
Watcher 的生命周期
接下来,我们针对 Watcher 的设置到触发的整个生命周期及其中可能遇到的异常做一个介绍。
客户端上 Watcher 的生命周期
Watcher 的设置从 ZK 客户端开始,具体的分类不再做阐述,主要追踪代码的调用路径。
以 exist 为例,用户传入的 Watcher 对象或创建此客户端时设置的默认 Watcher 被使用时,有两个代码路径被激活。
其之一是发送到服务器的请求的 watch 字段将被设置为 true 以代表这次请求包含一个 Watcher 设置的请求。
其之二是 Watcher 被包装在 ExistsWatchRegistration 中。经过网络层面的等候,在设置 Watcher 的请求成功时,调用链进入ClientCnxn#finishPacket 方法中,调用 ExistsWatchRegistration 的 register 方法在客户端缓存 Watcher 的信息。
缓存的 Watcher 信息主要用于支持 Watcher 在网络错误的情况下重新设置以及 Watcher 的移除。
重新设置 Watcher 具体来说,是在发生 ConnectionLoss 异常时,当 ZK 客户端成功重新连接到服务器之后,根据自己缓存的 Watcher 信息,向服务器发送一个 SetWatches 请求以重新设置此前还未触发的 Watcher 集合。
这个功能避免了用户必须为了重新设置 Watcher 而响应 ConnectionLoss 异常的负担,尤其是在 ConnectionLoss 异常发生时,无法预知节点发生了何种变化。
另一方面,服务器端会比对 SetWatches 请求的各个 Watcher 对应路径节点的信息,根据是否存在节点以及节点最后处理的事务 ID 和子节点事务 ID 来判断是否应该产生事件。
这里有两个需要注意的点。
一个是,服务器判断是否应该产生事件存在 False Negative 误判的情况。具体来说,在通过 exist 设置 Watcher 时,对应节点如果此前不存在,对应 Watcher 将被分类为 SetWatches 请求中的 existWatcher 类型。此时,如果该节点经历了创建后又删除的事件,则无法被 Watcher 所感知。这也是分布式系统常见问题中所谓的 ABA 问题。
另一个是 SetWatchers 包含的 Watcher 集合是设置 Watcher 的成功请求的 Watcher 集合。前文提到,如果使用 ZK 客户端的异步请求接口,实际上会有两个回调。虽然 ZK 能够自动处理已经注册上的 Watcher 的容错,但是如果异步请求本身没有成功,则无法被这个自动重设机制覆盖。在这种情况下,应用程序应该自行重新执行请求和设置 Watcher 的操作。
关于 Watcher 的移除,这是在 3.5.0 版本引入的新功能。
在此前的 ZK 版本中,Watcher 一旦设置就无法主动移除。Watcher 的移除只有两种途径,一是 Watcher 被触发,二是会话超时或被关闭。应用程序在某些情况下希望根据外部状态的变化主动移除 Watcher,尤其是 Watcher 数量巨大,而外部状态指示这些 Watcher 再也不可能被触发的情况下防止内存泄漏的场景。移除 Watcher 指令确定移除集合也依赖于前文提到的本地缓存信息。同时需要定义新的客户端和服务器的通信文本,在成功执行时将产生前文所提的几种 WatchRemoved 事件。
服务器上 Watcher 的生命周期
前面提到,在客户端调用接口是设置 Watcher 的场景下,网络请求包中的 watch 字段将被设置。这一信息将在服务器被处理。
简单来说,经过一系列序列化和请求处理责任链的检查和装饰后,设置 Watcher 的请求最终被发往 ZKDatabase 并委托给 DataTree 处理。DataTree 是服务器管理节点视图的类。在进行请求响应的调用时,会根据请求中 watch 字段的布尔值来判断是否要设置一个服务器一侧的 Watcher 对象。
服务器一侧的 Watcher 对象实际上是一个 ServerCnxn 的实例,它继承自 Watcher 接口。当然,概念上来说这样的继承有点勉强,更好的实现方式是采用组合来替代继承,以明确 ServerCnxn 本身不是 Watcher 接口,而是代行 Watcher 的职责。
具体来说,ServerCnxn 是服务器一侧维护的网络连接对象,DataTree 在处理设置 Watcher 的请求时会将路径和 ServerCnxn 这个 Watcher 相关联,并且在 DataTree 的内部维护一系列的 Watcher 对象的信息。在处理节点创建、删除或数据改变等操作时,根据节点视图和 Watcher 信息判断应该触发哪些 Watcher,而 Watcher 的触发正是执行 ServerCnxn 的 process 方法的逻辑,即向客户端发送相应的事件。
DataTree 触发相应事件将委托到服务器一侧的 WatchManager 处理,WatcherManager 在事件产生时调用 triggerWatch 方法来调用 ServerCnxn 的 process 方法。同时对于单次触发的 Watcher 对象,将其从 Watcher 集合中移除,而对于 Persistent 的 Watcher 对象,则进行保留。
关于 Persistent Watcher,最后还有几个需要讨论的点。具体内容可以参考 ISSUE [^1][^2] 和 GitHub PR[^3] 上的讨论。
第一个,Persistent Watcher 提供是否是 Recursive 的选项,设置 Recursive 将同时监听给定路径对应的节点及其子节点,否则只监听给定路径对应的节点。对于监听子节点的情况,为了减少每次递归查找的负担,实现上有一个 PathParentIterator 的类来迭代获取变更节点的父节点的优化。
第二个,Persistent Watcher 对于前文和 etcd 做对比的情况,仅解除了单次触发的限制,能够对多个状态变化对应的产生事件。但是,事件的内容仍然仅是事件已发生,而不包括事件的具体变更内容。客户端在收到事件后仍然要重新请求获取节点状态。为了追溯变更内容,通常来说需要引入某种 MVCC 的存储。
第三个,Persistent Watcher 和前文所提的 Watcher 自动容忍网络错误有一定的冲突。在具体的实现中,发生网络错误并重设 Watcher 集合的时候,仅仅把 Persistent Watcher 重新设置,而不会检测是否需要触发期间错过的事件。这并没有什么功能上的考量或者优点,仅仅是实现上会更复杂而需要 Persistent Watcher 这个特性的 Curator 库能够主动的处理这个问题。
[^1] https://issues.apache.org/jira/browse/ZOOKEEPER-153
[^2] https://issues.apache.org/jira/browse/ZOOKEEPER-1416
[^3] https://github.com/apache/zookeeper/pull/1106