《从Paxos到ZooKeeper 分布式一致性原理与实践》阅读【Watcher】
ZooKeeper 允许客户端向服务端注册一个 Watcher 监听,当服务端的一些指定事件触发了这个 Watcher,那么就会向指定客户端发送一个事件通知来实现分布式的通知功能。
ZooKeeper 的 Watcher 机制主要包括客户端线程、客户端 WatchManager 和 ZooKeeper 服务器三部分。在具体工作流程上,简单地讲,客户端在向 ZooKeeper 服务器注册 Watcher 的同时,会将 Watcher 对象存储在客户端的 WatchManager 中。当 ZooKeeper 服务器端触发 Watcher 事件后,会向客户端发送通知,客户端线程从 WatchManager 中取出对应的 Watcher 对象来执行回调逻辑。
主要会涉及下面这些类
1. Watcher 接口
在 ZooKeeper 中,接口类 Watcher
用于表示一个标准的事件处理器,其定义了事件通知相关的逻辑,包含 KeeperState
和 EventType
两个枚举类,分别代表了通知状态和事件类型,同时定义了事件的回调方法:process(WatchedEvent event)
。
1.1 Watcher 事件
KeeperState | EventType | 触发条件 | 说明 |
---|---|---|---|
SyncConnected(0) | None(-1) | 客户端与服务端成功建立连接 | 此时客户端和服务器处于连接状态 |
NodeCreated(1) | Watcher 监听的对应数据节点被创建 | ||
NodeDeleted(2) | Watcher 监听的对应数据节点被删除 | ||
NodeDataChanged(3) | Watcher 监听的对应数据节点的数据内容发生变更 | ||
NodeChildChanged(4) | Wather 监听的对应数据节点的子节点列表发生变更 | ||
Disconnected(0) | None(-1) | 客户端与 ZooKeeper 服务器断开连接 | 此时客户端和服务器处于断开连接状态 |
Expired(-112) | Node(-1) | 会话超时 | 此时客户端会话失效,通常同时也会受到 SessionExpiredException 异常 |
AuthFailed(4) | None(-1) | 通常有两种情况。(1)使用错误的 schema 进行权限检查 (2)SASL 权限检查失败 | 通常同时也会收到 AuthFailedException 异常 |
1.2 回调方法 process()
process
方法是 Watcher
接口中的一个回调方法,当 ZooKeeper 向客户端发送一个 Watcher
事件通知时,客户端就会对相应的 process
方法进行回调,从而实现对事件的处理。
org.apache.zookeeper.Watcher#process
abstract public void process(WatchedEvent event);
在这里提一下
包含了每一个事件的三个基本属性:通知状态(WathcerEvent
实体。笼统地讲,两者表示的是同一个事物,都是对一个服务端事件的封装。不同的是,WatchedEvent
是一个逻辑事件,用于服务端和客户端程序执行过程中所需的逻辑对象,而 WatcherEvent
因为实现了序列化接口,因此可以用于网络传输。WatchedEventkeeperState
),事件类型(EventType
)和节点路径(path
)。
org.apache.zookeeper.proto.WatcherEvent
public class WatcherEvent implements Record { private int type; private int state; private String path; }
2. 工作机制
服务端在生成 WatchedEvent
事件之后,会调用 getWrapper
方法将自己包装成一个可序列化的 WatcherEvent
事件,以便通过网络传输到客户端。客户端在接收到服务端的这个事件对象后,首先会将 WatcherEvent
还原成一个 WatchedEvent
事件,并传递给 process
方法处理,回调方法 process
根据入参就能够解析出完整的服务端事件了。
2.1 客户端注册 Watcher
在创建一个 ZooKeeper 客户端的实例时可以向构造方法中传入一个默认的 Watcher:
public ZooKeeper(String connectString,int sessionTimeout,Watcher watcher);
以 org.apache.zookeeper.ZooKeeper#getData(java.lang.String, org.apache.zookeeper.Watcher, org.apache.zookeeper.data.Stat)
为例:这个 Watcher 将作为整个 ZooKeeper 会话期间的默认 Watcher,会一直被保存在客户端 ZKWatchManager
的 defaultWatcher
中。另外,ZooKeeper 客户端也可以通过 getData
,getChildren
和 exist
三个接口来向 ZooKeeper 服务器注册 Watcher,无论使用哪种方式,注册 Watcher 的工作原理都是一致的。
public byte[] getData(final String path, Watcher watcher, Stat stat) throws KeeperException, InterruptedException { final String clientPath = path; PathUtils.validatePath(clientPath); // the watch contains the un-chroot path WatchRegistration wcb = null; if (watcher != null) { wcb = new DataWatchRegistration(watcher, clientPath); } final String serverPath = prependChroot(clientPath); RequestHeader h = new RequestHeader(); h.setType(ZooDefs.OpCode.getData); GetDataRequest request = new GetDataRequest(); request.setPath(serverPath); request.setWatch(watcher != null); GetDataResponse response = new GetDataResponse(); ReplyHeader r = cnxn.submitRequest(h, request, response, wcb); if (r.getErr() != 0) { throw KeeperException.create(KeeperException.Code.get(r.getErr()), clientPath); } if (stat != null) { DataTree.copyStat(response.getStat(), stat); } return response.getData(); }
在 ZooKeeper 中,Packet
可以被看作一个最小的通信协议单元,用于进行客户端与服务端之间的网络传输,任何需要传输的对象都需要包装成一个 Packet
对象。因此,在 ClientCnxn
中 WatchRegistration
又会被封装到 Packet
中,然后放入发送队列中等待客户端发送:在向 getData
接口注册 Watcher 后,客户端首先会对当前客户端请求 request
进行标记,将其设置为 “使用 Watcher 监听”,同时会封装一个 Watcher 的注册信息 WatchRegistration
对象,用于暂时保存数据节点的路径和 Watcher 的对应关系。
org.apache.zookeeper.ClientCnxn#submitRequest
public ReplyHeader submitRequest(RequestHeader h, Record request, Record response, WatchRegistration watchRegistration) throws InterruptedException { ReplyHeader r = new ReplyHeader(); Packet packet = queuePacket(h, r, request, response, null, null, null, null, watchRegistration); synchronized (packet) { while (!packet.finished) { packet.wait(); } } return r; }
org.apache.zookeeper.ClientCnxn#finishPacket随后,ZooKeeper 客户端就会向服务端发送这个请求,同时等待请求的返回。完成请求发送后,会由客户端 SendThread
线程的 readResponse
方法负责接收来自服务端的响应,finishPacket
方法会从 Packet
中取出对应的 Watcher 并注册到 ZkWatchManager
中去:
private void finishPacket(Packet p) { if (p.watchRegistration != null) { p.watchRegistration.register(p.replyHeader.getErr()); } if (p.cb == null) { synchronized (p) { p.finished = true; p.notifyAll(); } } else { p.finished = true; eventThread.queuePacket(p); } }
org.apache.zookeeper.ZooKeeper.WatchRegistration#register从上面的内容中,我们已经了解到客户端已经将 Watcher 暂时封装在了 WatchRegistration
对象中,现在就需要从这个封装对象中再次提取出 Watcher 来:
abstract protected Map<String, Set<Watcher>> getWatches(int rc); public void register(int rc) { if (shouldAddWatch(rc)) { Map<String, Set<Watcher>> watches = getWatches(rc); synchronized(watches) { Set<Watcher> watchers = watches.get(clientPath); if (watchers == null) { watchers = new HashSet<Watcher>(); watches.put(clientPath, watchers); } watchers.add(watcher); } } }
org.apache.zookeeper.ZooKeeper.ZKWatchManager
private static class ZKWatchManager implements ClientWatchManager { private final Map<String, Set<Watcher>> dataWatches = new HashMap<String, Set<Watcher>>(); private final Map<String, Set<Watcher>> existWatches = new HashMap<String, Set<Watcher>>(); private final Map<String, Set<Watcher>> childWatches = new HashMap<String, Set<Watcher>>(); private volatile Watcher defaultWatcher; }
在 register
方法中,客户端会将之前暂时保存的 Watcher 对象转交给 ZKWatchManager
,并最终保存到 dataWatches
中去。ZKWatchManager.dataWatches
是一个 Map<String, Set<Watcher>>
类型的数据结构,用于将数据节点的路径和 Watcher 对象进行一一映射后管理起来。
在 Packet.createBB()
中,ZooKeeper 只会将 requestHeader
和 reqeust
两个属性进行序列化,也就是说,尽管 WatchResgistration
被封装在了 Packet
中,但是并没有被序列化到底层字节数组中去,因此也就不会进行网络传输了。
2.2 服务端处理 Watcher
2.2.1 服务端注册 Watcher
服务端收到来自客户端的请求后,在 org.apache.zookeeper.server.FinalRequestProcessor#processRequest
中会判断当前请求是否需要注册 Watcher:
case OpCode.getData: { lastOp = "GETD"; GetDataRequest getDataRequest = new GetDataRequest(); ByteBufferInputStream.byteBuffer2Record(request.request, getDataRequest); DataNode n = zks.getZKDatabase().getNode(getDataRequest.getPath()); if (n == null) { throw new KeeperException.NoNodeException(); } PrepRequestProcessor.checkACL(zks, zks.getZKDatabase().aclForNode(n), ZooDefs.Perms.READ, request.authInfo); Stat stat = new Stat(); byte b[] = zks.getZKDatabase().getData(getDataRequest.getPath(), stat, getDataRequest.getWatch() ? cnxn : null); rsp = new GetDataResponse(b, stat); break; }
数据节点的节点路径和 ServerCnxn
最终会被存储在 WatcherManager
的 watchTable
和 watch2Paths
中。WatchManager
是 ZooKeeper 服务端 Watcher 的管理者,其内部管理的 watchTable
和 watch2Pashs
两个存储结构,分别从两个维度对 Watcher 进行存储。从 getData
请求的处理逻辑中,我们可以看到,当 getDataRequest.getWatch()
为 true 的时候,ZooKeeper 就认为当前客户端请求需要进行 Watcher 注册,于是就会将当前的 ServerCnxn
对象作为一个 Watcher 连同数据节点路径传入 getData
方法中去。注意到,抽象类 ServerCnxn
实现了 Watcher
接口。
watchTable
是从数据节点路径的粒度来托管 Watcher。watch2Paths
是从 Watcher 的粒度来控制事件触发需要触发的数据节点。
org.apache.zookeeper.server.WatchManager#addWatch
public synchronized void addWatch(String path, Watcher watcher) { HashSet<Watcher> list = watchTable.get(path); if (list == null) { // don't waste memory if there are few watches on a node // rehash when the 4th entry is added, doubling size thereafter // seems like a good compromise list = new HashSet<Watcher>(4); watchTable.put(path, list); } list.add(watcher); HashSet<String> paths = watch2Paths.get(watcher); if (paths == null) { // cnxns typically have many watches, so use default cap here paths = new HashSet<String>(); watch2Paths.put(watcher, paths); } paths.add(path); }
2.2.2 Watcher 触发
org.apache.zookeeper.server.DataTree#setData
public Stat setData(String path, byte data[], int version, long zxid, long time) throws KeeperException.NoNodeException { Stat s = new Stat(); DataNode n = nodes.get(path); if (n == null) { throw new KeeperException.NoNodeException(); } byte lastdata[] = null; synchronized (n) { lastdata = n.data; n.data = data; n.stat.setMtime(time); n.stat.setMzxid(zxid); n.stat.setVersion(version); n.copyStat(s); } // now update if the path is in a quota subtree. String lastPrefix; if((lastPrefix = getMaxPrefixWithQuota(path)) != null) { this.updateBytes(lastPrefix, (data == null ? 0 : data.length) - (lastdata == null ? 0 : lastdata.length)); } dataWatches.triggerWatch(path, EventType.NodeDataChanged); return s; }
在对指定节点进行数据更新后,通过调用 org.apache.zookeeper.server.WatchManager#triggerWatch
方法来触发相关的事件:
public Set<Watcher> triggerWatch(String path, EventType type, Set<Watcher> supress) { WatchedEvent e = new WatchedEvent(type, KeeperState.SyncConnected, path); HashSet<Watcher> watchers; synchronized (this) { watchers = watchTable.remove(path); if (watchers == null || watchers.isEmpty()) { return null; } for (Watcher w : watchers) { HashSet<String> paths = watch2Paths.get(w); if (paths != null) { paths.remove(path); } } } for (Watcher w : watchers) { if (supress != null && supress.contains(w)) { continue; } w.process(e); } return watchers; }
无论是 dataWatches
还是 childWatches
管理器,Watcher 的触发逻辑都是一致的,基本步骤如下。
-
封装
WatchedEvent
。首先将通知状态(
KeeperState
)、事件类型(EventType
)以及节点路径(Path
)封装成一个WatchedEvent
对象。 -
查询 Watcher。
根据数据节点的节点路径从
watchTable
中取出对应的 Watcher。如果没有找到 Watcher,说明没有任何客户端在该数据节点上注册过 Watcher,直接退出。而如果找到了这个 Watcher,会将其提取出来,同时会直接从watchTable
和watch2Paths
中将其删除——从这里我们也可以看出,Watcher 在服务端是一次性的,即触发一次就失效了。
调用 process
方法来触发 Watcher。
在这一步中,会逐个依次地调用从步骤2中找出的所有 Watcher 的 process
方法。这里的 process
方法,事实上就是 ServerCnxn
的对应方法:
org.apache.zookeeper.server.NIOServerCnxn#process
@Override synchronized public void process(WatchedEvent event) { ReplyHeader h = new ReplyHeader(-1, -1L, 0); // Convert WatchedEvent to a type that can be sent over the wire WatcherEvent e = event.getWrapper(); sendResponse(h, e, "notification"); }
在 process
方法中,主要逻辑如下。
- 在请求头中标记 “-1”,表明当前是一个通知。
- 将
WawtchedEvent
包装成WatcherEvent
,以便进行网络传输序列化。 - 向客户端发送该通知。
3. 客户端回调 Watcher
3.1 SendThread 接收事件通知
对于一个来自服务端的响应,客户端都是由 org.apache.zookeeper.ClientCnxn.SendThread#readResponse
方法来统一进行处理的,如果响应头 replyHdr
中标识了 XID 为 -1,表明这是一个通知类型的响应。
if (replyHdr.getXid() == -1) { // -1 means notification WatcherEvent event = new WatcherEvent(); event.deserialize(bbia, "response"); // convert from a server path to a client path if (chrootPath != null) { String serverPath = event.getPath(); if(serverPath.compareTo(chrootPath)==0) event.setPath("/"); else if (serverPath.length() > chrootPath.length()) event.setPath(serverPath.substring(chrootPath.length())); else { LOG.warn("Got server path " + event.getPath() + " which is too short for chroot path " + chrootPath); } } WatchedEvent we = new WatchedEvent(event); eventThread.queueEvent( we ); return; }
处理过程大体上分为以下 4 个主要步骤:
-
反序列化。
将字节流转换成
WatcherEvent
对象。 -
处理 chrootPath。
如果客户端设置了 chrootPath 属性,那么需要对服务端传过来的完整的节点路径进行
chrootPath
处理,生成客户端的一个相对节点路径。 -
还原
WatchedEvent
。将
WatcherEvent
对象转换成WatchedEvent
。 -
回调 Watcher。
将
WatchedEvent
对象交给EventThread
线程,在下一个轮询周期中进行 Watcher 回调。
3.2 EventThread 处理事件通知
SendThread
接收到服务端的通知事件后,会通过调用 EventThread.queueEvent
方法将事件传给 EventThread
线程,其逻辑如下:
org.apache.zookeeper.ClientCnxn.EventThread#queueEvent
public void queueEvent(WatchedEvent event) { if (event.getType() == EventType.None && sessionState == event.getState()) { return; } sessionState = event.getState(); // materialize the watchers based on the event WatcherSetEventPair pair = new WatcherSetEventPair( watcher.materialize(event.getState(), event.getType(),event.getPath()), event); // queue the pair (watch set & event) for later processing waitingEvents.add(pair); }
queueEvent
方法首先会根据该通知事件,从 ZKWatchManager
中取出所有相关的 Watcher:
@Override public Set<Watcher> materialize(Watcher.Event.KeeperState state, Watcher.Event.EventType type, String clientPath) { Set<Watcher> result = new HashSet<Watcher>(); switch (type) { // ... case NodeDataChanged: case NodeCreated: synchronized (dataWatches) { addTo(dataWatches.remove(clientPath), result); } synchronized (existWatches) { addTo(existWatches.remove(clientPath), result); } break; // ... } return result; } }
客户端在识别出事件类型 EventType
后,会从相应的 Watcher 存储(即 dataWatches
,existWatches
或 childWatches
中的一个或多个)中去除对应的 Watcher。注意,此处使用的是 remove
接口,因此也表明了客户端的 Watcher 机制同样也是一次性的,即一旦被触发后,该 Watcher 就失效了。
获取到相关的所有 Watcher 后,会将其放入 waitingEvents
这个队列中去。WaitingEvents
是一个待处理 Watcher 队列,EventThread
的 run
方法会不断对该队列进行处理。EventThread
线程每次都会从 waitingEvents
队列中取出一个 Watcher,并进行串行同步处理。注意,此处 processEvent
方法中的 Watcher
才是之前客户端真正注册的 Watcher,调用其 process
方法就可以实现 Watcher 的回调了。
总结
1、一次性
Watch是一次性的,每次都需要重新注册,并且客户端在会话异常结束时不会收到任何通知,而快速重连接时仍不影响接收通知。
2、客户端串行处理
Watch的回调执行都是顺序执行的,并且客户端在没有收到关注数据的变化事件通知之前是不会看到最新的数据,另外需要注意不要在Watch回调逻辑中阻塞整个客户端的Watch回调。
3、轻量
Watch是轻量级的,WatchEvent是最小的通信单元,结构上只包含通知状态、事件类型和节点路径。ZooKeeper服务端只会通知客户端发生了什么,并不会告诉具体内容。