Zookeeper Watcher——数据变更的通知

ZooKeeper提供了分布式数据的发布订阅功能。一个典型的发布订阅模型系统定义了一种一对多的订阅关系,能够让多个订阅者同时监听某个主题对象,当这个主题对象自身状态变化时,会通知所有订阅者,使它们能够做出相应的处理。在 ZooKeeper中,引入了 Watcher机制来实现这种分布式的通知功能。

ZooKeeper允许客户端向服务端注册一个 Watcher监听,当服务端的一些指定事件触发了这个 Watcher,那么就会向指定客户端发送一个事件通知来实现分布式的通知功能。整个 Watcher注册与通知过程如图所示。

从图中,我们可以看到, ZooKeeper的 Watcher机制主要包括客户端线程、客户端WatchManager和 ZooKeeper服务器三部分。在具体工作流程上,简单地讲,客户端在向 ZooKeeper服务器注册 Watcher的同时,会将 Watcher对象存储在客户端的WatchManager中。当 ZooKeeper服务器端触发 Watcher事件后,会向客户端发送通知,客户端线程从 WatchManager中取出对应的 Watcher对象来执行回调逻辑。

Watcher接口

在 ZooKeeper中,接口类 Watcher用于表示一个标准的事件处理器,其定义了事件通知相关的逻辑,包含 Keeperstate和 EventType两个枚举类,分别代表了通知状态和事件类型,同时定义了事件的回调方法: process(Watchedevent event)。

Watcher事件

同一个事件类型在不同的通知状态中代表的含义有所不同,表7-3列举了常见的通知状态和事件类型。

KeeperStateEventType触发条件说明
SyncConnected(3)None(-1)客户端与服务器成功建立会话此时客户端和服务器处于连接状态
 NodeCreated(1)Watcher监听的对应数据节点被创建 
 NodeDeleted(2)Watcher监听的对应数据节点被删除 
 NodeDataChanged(3)Watcher监听的对应数据节点的数据内容发生变更 
 NodeChildrenChanged(4)Watcher监听的对应数据节点的子节点列表发生变更 
Disconnected(0)None(-1)客户端与ZooKeeper服务器断开连接此时客户端和服务器处于断开连接状态
Expired(-112)None(-1)会话超时此时客户端会话失效,通常同时也会收到SessionExpiredException异常
AuthFailed(4)None(-1)通常有两种情况:
  • 使用错误的scheme进行权限检查。
  • SASL权限检查失败。
通常同时也会收到AuthFailedException异常
Unknown(-1)  从3.1.0版本开始废弃
NoSyncConnected(1)  从3.1.0版本开始废弃

上表中列举了ZooKeeper中最常见的几个通知状态和事件类型。其中,针对NodeDataChanged事件,此处说的变更包括节点的数据内容和数据的版本号dataVersion。因此即使使用相同的数据内容来更新,还是会触发这个事件通知,因为对于ZooKeeper来说,无论数据内容是否变更,一旦有客户端调用了数据更新的接口,且更新成功,就会更新dataVersion值。

NodeChildrenChanged事件会在数据节点的子节点列表发生变更的时候被触发,这里说的子节点列表变化特指子节点个数和组合情况的变更,即新增子节点或删除子节点,而子节点内容的变化是不会触发这个事件的。

对于AuthFailed这个事件,需要注意的地方是,他的触发条件并不是简简单单因为当前客户端会话没有权限,而是授权失败。我们首先通过下面的两个例子来看看AuthFailed事件。

// 使用正确的Scheme进行授权
zkClient = new ZooKeeper(SERVER_LIST, 3000, new Sample_AuthFailed1());
zkClient.addAuthInfo("digest", "taokeeper:true".getBytes());
zkClient.create("/zk-book", "".getBytes(), acls, CreateMode.EPHEMERAL );

zkClient_error = new ZooKeeper(SERVER_LIST, 3000, new Sample_AuthFailed1());
zkClient_error.addAuthInfo("digest", "taokeeper:error".getBytes() );
zkClient_error.getData("/zk-book", true, null);

// 使用错误的Scheme进行授权
zkClient = new ZooKeeper(SERVER_LIST, 3000, new Sample_AuthFailed2());
zkClient.addAuthInfo("digest", "taokeeper:true".getBytes());
zkClient.create("/zk-book", "".getBytes(), acls, CreateMode.EPHEMERAL );

zkClient_error = new ZooKeeper(SERVER_LIST, 3000, new Sample_AuthFailed2());
zkClient_error.addAuthInfo("digest2", "taokeeper:error".getBytes() );
zkClient_error.getData("/zk-book", true, null);

上面两个示例程序都创建了一个受到权限控制的数据节点,然后使用了不同的权限Scheme进行权限检查。在第一个示例程序中,使用了正确的权限Scheme:digest;而第二个示例程序中使用了错误的Scheme:digest2。另外,无论哪个程序,都使用了错误的Auth:taokeeper:error,因此在运行第一个程序的时候,会抛出NoAuthException异常,而第二个程序运行后,抛出的是AuthFailedException异常,同时,会收到对应的Watcher事件通知:(AuthFailed, None)。

回调方法process()

process方法是Watcher接口中的一个回调方法,当ZooKeeper向客户端发送一个Watcher事件通知时,客户单就会对相应的process方法进行回调,从而实现对事件的处理。process方法的定义如下:

abstract public void process(WatchedEvent event);

这个回调方法的定义非常简单,我们重点看下方法的参数定义:WatchedEvent。

WatchedEvent包含了每一个事件的三个基本方法:通知状态(keeperState)、事件类型(eventType)和节点路径(path)。ZooKeeper使用WatchedEvent对象来封装服务端事件并传递给Watcher,从而方便回调方法process对服务端事件进行处理。

提到 WatchedEvent,不得不讲下 WatcherEvent实体。笼统地讲,两者表示的是同个事物,都是对一个服务端事件的封装。不同的是, WatchedEvent是一个逻辑事件,用于服务端和客户端程序执行过程中所需的逻辑对象,而 WatcherEvent因为实现了序列化接口,因此可以用于网络传输。

服务端在生成 WatchedEvent事件之后,会调用 getWrapper方法将自己包装成一个可序列化的 WatcherEvent事件,以便通过网络传输到客户端。客户端在接收到服务端的这个事件对象后,首先会将 WatcherEvent事件还原成一个 WatchedEvent事件,并传递给ρ rocess方法处理,回调方法 process根据入参就能够解析出完整的服务端事件了。

需要注意的一点是,无论是 WatchedEvent还是 Watcherevent,其对 ZooKeeper服务端事件的封装都是极其简单的。举个例子来说,当zk-book这个节点的数据发生变更时,服务端会发送给客户端一个“ ZNode数据内容变更”事件,客户端只能够接收到如下信息:

从上面展示的信息中,我们可以看到,客户端无法直接从该事件中获取到对应数据节点的原始数据内容以及变更后的新数据内容,而是需要客户端再次主动去重新获取数据——这也是 ZooKeeper Watcher机制的一个非常重要的特性。

工作机制

ZooKeeper的 Watcher机制,总的来说可以概括为以下三个过程:客户端注册 Watcher、服务端处理 Watcher和客户端回调Watcher,,其内部各组件之间的关系如图所示。

客户端注册 Watcher

我们提到在创建一个 ZooKeeper客户端对象实例时,可以向构造方法中传入一个默认的 Watcher:

public ZooKeeper(String connectString, int sessionTimeout, Watcher watcher)

这个 Watcher将作为整个 ZooKeeper会话期间的默认 Watcher,会一直被保存在客户端ZKWatchManager的 defaulwatcher中。另外, ZooKeeper客户端也可以通过getData、 getChildren和 exist三个接口来向 ZooKeeper服务器注册 Watcher,无论使用哪种方式,注册 Watcher的工作原理都是一致的,这里我们以 getData这个接口为例来说明。 getData接口用于获取指定节点的数据内容,主要有两个方法:

public byte[] getData(String path, boolean watch, Stat stat)
public byte[] getData(final String path, Watcher watcher, Stat stat)

在这两个接口上都可以进行 Watcher的注册,第一个接口通过一个 boolean参数来标识是否使用上文中提到的默认 Watcher来进行注册,具体的注册逻辑和第二个接口是一致的在向 getData接口注册 Watcher后,客户端首先会对当前客户端请求 request进行标记,
将其设置为“使用 Watcher监听”,同时会封装一个 Watcher的注册信息 WatchRegistration对象,用于暂时保存数据节点的路径和 Watcher的对应关系,具体的逻辑代码如下:

在 ZooKeeper中, Packet可以被看作一个最小的通信协议单元,用于进行客户端与服务端之间的网络传输,任何需要传输的对象都需要包装成一个 Packet对象。因此,在ClientCnxn中 WatchRegistration又会被封装到 Packet中去,然后放入发送队列中等待客户端发送:

在 ZooKeeper中, Packet可以被看作一个最小的通信协议单元,用于进行客户端与服务端之间的网络传输,任何需要传输的对象都需要包装成一个 Packet对象。因此,在ClientCnxn中 WatchRegistration又会被封装到 Packet中去,然后放入发送队列中等待客户端发送:

随后,Zooκeper客户端就会向服务端发送这个请求,同时等待请求的返回。完成请求发送后,会由客户端 SendThread线程的readResponse方法负责接收来自服务端的响应,finishPacket方法会从 Packet中取出对应的 Watcher并注册到 ZKWatchManager中去:

从上面的内容中,我们已经了解到客户端已经将 Watcher暂时封装在了 WatchRegistration对象中,现在就需要从这个封装对象中再次提取出 Watcher来:

在 register方法中,客户端会将之前暂时保存的 Watcher对象转交给 ZKWatchManager,并最终保存到 datawatches中去。 ZKWatchManager, datawatches是一个Map<String,set<Watcher>>类型的数据结构,用于将数据节点的路径和 Watcher对象进
行一一映射后管理起来。整个客户端 Watcher的注册流程如图所示。

极端情况下,客户端毎调用一次 getData()接口,就会注册上个 Watcher,那么这些 Watcher实体都会随着客户端请求被发送到服务端去吗?
答案是否定的。如果客户端注册的所有 Watcher都被传递到服务端的话,那么服务端肯定会出现内存紧张或其他性能问题了,幸运的是,在 ZooKeeper的设计中充分考虑到了这个问题。在上面的流程中,我们提到把 WatchRegistration封装到了 Packet对象中去,但事实上,在底层实际的网络传输序列化过程中,并没有将 WatchRegistration对象完全地序列化到底层字节数组中去。为了证实这一点,我们可以看下 Packet内部的序列化过程:

从上面的代码片段中,我们可以看到,在 Packet.createD()方法中, ZooKeeper只会将 requestReader和 request两个属性进行序列化,也就是说,尽管WatchRegistration被封装在了 Packet中,但是并没有被序列化到底层字节数组中去,因此也就不会进行网络传输了。

服务端处理 Watcher

上面主要讲解了客户端注册 Watcher的过程,并且已经了解了最终客户端并不会将Watcher对象真正传递到服务端。那么,服务端究竟是如何完成客户端的 Watcher注册,又是如何来处理这个 Watcher的呢?本节将主要围绕这两个问题展开进行讲解。

ServerCnxn存储

我们首先来看下服务端接收 Watcher并将其存储起来的过程,如图所示是ZooKeeper服务端处理 Watcher的序列图。

从图中我们可以看到,服务端收到来自客户端的请求之后,在 FinalRequestProcessor.processRequest()中会判断当前请求是否需要注册 Watcher:

从getData请求的处理逻辑中,我们可以看到,当 getDataReques.getwatch()为true的时候, ZooKeeper就认为当前客户端请求需要进行 Watcher注册,于是就会将当前的 ServerCnxn对象和数据节点路径传入 getData方法中去。那么为什么要传入ServerCnxn呢?

ServerCnxn是一个 ZooKeeper客户端和服务器之间的连接接口,代表了一个客户端和服务器的连接。 ServerCnxn接口的默认实现是 NIOServerCnxn,同时从3.4.0版本开始,引入了基于Netty的实现: NettyCnxn。无论采用哪种实现方式,都实现了 Watcher的process接口,因此我们可以把 ServerCnxn看作是一个 Watcher对象。数据节点的节点路径和 ServerCnxn最终会被存储在WatchManager的watchTable和 watch2Paths中。

WatchManager是 ZooKeeper服务端 Watcher的管理者,其内部管理的 watchTable和 watch2Paths两个存储结构,分别从两个维度对 Watcher进行存储。

  • watchTable是从数据节点路径的粒度来托管 Watcher
  • watch2Paths是从 Watcher的粒度来控制事件触发需要触发的数据节点。

同时, Watch№ anager还负责 Watcher事件的触发,并移除那些已经被触发的 Watcher。注意, WatchManager只是一个统称,在服务端, Datatree中会托管两个 WatchManager,分别是 datawatches和 childwatches,分别对应数据变更 Watcher和子节点变更 Watcher。

在本例中,因为是 getData接口,因此最终会被存储在 dataWatches中,其数据结构如图所示。

Watcher触发

在上面的讲解中,我们了解了对于标记了 Watcher注册的请求, ZooKeeper会将其对应的 ServerCnxn存储到 WatchManager中,下面我们来看看服务端是如何触发 Watcher的。NodeDataChanged事件的触发条件是“Watcher监听的对应数据节点的数据内容发生变更”,其具体实现如下:

在对指定节点进行数据更新后,通过调用 WatchManager的 triggerwatch方法来触发相关的事件:

无论是 dataWatches还是 childWatches管理器, Watcher的触发逻辑都是致的,基本步骤如下

1.封装 WatchedEvent

首先将通知状态( KeeperState)、事件类型(EventType)以及节点路径(Path)封装成一个WatchedEvent对象。

2.查询 Watcher。

根据数据节点的节点路径从 watchTable中取出对应的 Watcher。如果没有找到 Watcher,说明没有任何客户端在该数据节点上注册过 Watcher,直接退出。而如果找到了这个 Watcher,会将其提取出来,同时会直接从 watchTable和 watch2Paths中将其删除——从这里我们也可以看出, Watcher在服务端是一次性的,即触发一次就失效了。

3.调用 process方法来触发 Watcher

在这一步中,会逐个依次地调用从步骤2中找出的所有 Watcher的 process方法。那么这里的 process方法究竟做了些什么呢?在上文中我们已经提到,对于需要注册 Watcher的请求, ZooKeeper会把当前请求对应的ServerCnxn作为一个 Watcher进行存储,因此,这里调用的 process方法,事实上就是 ServerCnxn的对应方法:

  • 从上面的代码片段中,我们可以看出在 process方法中,主要逻辑如下
  • 在请求头中标记“-1”,表明当前是一个通知。
  • 将 WatchedEvent包装成 WatcherEvent,以便进行网络传输序列化向客户端发送该通知。

从以上几个步骤中可以看到, Servercnxn的 process方法中的逻辑非常简单,本质上并不是处理客户端 Watcher真正的业务逻辑,而是借助当前客户端连接的ServerCnxn对象来实现对客户端的 WatchedEvent传递,真正的客户端 Watcher回调与业务逻辑执行都在客户端。

客户端回调 Watcher

上面我们已经讲解了服务端是如何进行 Watcher触发的,并且知道了最终服务端会通过使用 ServerCnxn对应的TCP连接来向客户端发送一个 Watcher Event事件,下面我们来看看客户端是如何处理这个事件的。

SendThread接收事件通知

首先我们来看下 ZooKeeper客户端是如何接收这个客户端事件通知的:

对于一个来自服务端的响应,客户端都是由 SendThread.readResponse(ByteBuffer incomingBuffer)方法来统一进行处理的,如果响应头replyHdr中标识了XID为-1,表明这是一个通知类型的响应,对其的处理大体上分为以下4个主要步骤。

1.反序列化。

ZooKeeper客户端接收到请求后,首先会将字节流转换成WatcherEvent对象。

2.处理chrootPath。

如果客户端设置了chrootPath属性,那么需要对服务端传过来的完整的节点路径进行chrootPath处理,生成客户端的一个相对节点路径。例如客户端设置了chrootPath为/app1,那么针对服务端传过来的响应包含的节点路径为/app1/locks,经过chrootPath处理后,就会变成一个相对路径:/locks。

3.还原WatchedEvent。

process接口的参数定义是WatchedEvent,因此这里需要将WatcherEvent对象转换成WatchedEvent。

4.回调Watcher。

最后将WatchedEvent对象交给EventThread线程,在下一个轮询周期中进行Watcher回调。

EventThread处理事件通知

在上面内容中我们讲到,服务端的Watcher事件通知,最终交给了EventThread线程来处理,现在我们就来看看EventThread的一些核心逻辑。EventThread线程是ZooKeeper客户端中专门用来处理服务端通知事件的线程,其数据结构如下图所示。

在上文中,我们讲到SendThread接收到服务端的通知事件后,会通过调用EventThread.queueEvent方法将事件传给EventThread线程,其逻辑如下:

queueEvent方法首先会根据该通知事件,从ZKWatchManager中取出所有相关的Watcher:

客户端在识别出事件类型EventType后,会从相应的Watcher存储(即dataWatches、existWatches或childWatches中的一个或多个,本例中就是从dataWatches和existWatches两个存储中获取)中去除相应的Watcher。注意,此处使用的是remove接口,因此也表明了客户端的Watcher机制同样也是一次性的,即一旦被触发后,该Watcher就失效了。

获取到相关的所有Watcher之后,会将其放入waitingEvents这个队列中去。WaitingEvents是一个待处理Watcher的队列,EventThread的run方法会不断对该队列进行处理:

从上面的代码片段中我们可以看出,EventThread线程每次都会从waitingEvent队列中取出一个Watcher,并进行串行同步处理。注意,此处processEvent方法中Watcher才是之前客户端真正注册的Watcher,调用其process方法就可以实现Watcher的回调了。

Watcher特性总结

一次性

从上面的介绍中可以看到,无论是服务端还是客户端,一旦一个 Watcher被触发,ZooKeeper都会将其从相应的存储中移除。因此,开发人员在 Watcher的使用上要记住的一点是需要反复注册。这样的设计有效地减轻了服务端的压力。试想,如果注册一个 Watcher之后一直有效,那么,针对那些更新非常频繁的节点,服务端会不断地向客户端发送事件通知,这无论对于网络还是服务端性能的影响都非常大。

客户端串行执行

客户端 Watcher回调的过程是一个串行同步的过程,这为我们保证了顺序,同时,需要开发人员注意的一点是,千万不要因为一个 Watcher的处理逻辑影响了整个客户端的 Watcher回调。

轻量

WatchedEvent是 ZooKeeper整个 Watcher通知机制的最小通知单元,这个数据结构中只包含三部分内容:通知状态、事件类型和节点路径。也就是说, Watcher通知非常简单,只会告诉客户端发生了事件,而不会说明事件的具体内容。例如针对 NodeDataChanged事件, ZooKeeper的 Watcher只会通知客户端指定数据节点的数据内容发生了变更,而对于原始数据以及变更后的新数据都无法从这个事件中直接获取到,而是需要客户端主动重新去获取数据—这也是 ZooKeeper的 Watcher机制的一个非常重要的特性。

另外,客户端向服务端注册 Watcher的时候,并不会把客户端真实的 Watcher对象传递到服务端,仅仅只是在客户端请求中使用 boolean类型属性进行了标记,同时服务端也仅仅只是保存了当前连接的 ServerCnxn对象。如此轻量的 Watcher机制设计,在网络开销和服务端内存开销上都是非常廉价的。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值