ZooKeeper的系统模型
1.数据模型
ZooKeeper的视图结构和标准的Unix文件系统非常类似,但没有引入传统文件系统中目录和文件等相关概念,而是使用了其特有的“数据节点”概念,称之为ZNode。ZNode是ZooKeeper中数据的最小单元,每个ZNode上都可以保存数据,同时还可以挂载节点,构成一个层次化的命名空间,是个树。其数据模型如下图所示:
在ZooKeeper中,事务是指能够改变ZooKeeper服务器状态的操作,称之为事务操作或更新操作。对每一个事务请求,ZooKeeper都会为其分配一个全局唯一的事务 ID,用ZXID来表示,是一个64位的数字。每一个ZXID对应一次更新操作,从这些ZXID中可以间接地识别出ZooKeeper处理这些更新操作请求的全局顺序。
2.节点特性
节点类型
持久节点(PERSISTENT)
该数据节点被创建后,就会一直存在于ZooKeeper服务器上,直到被删除。
持久顺序节点(PERSISTENT_SEQUENTIAL)
每个父节点会为它的第一级子节点维护一份顺序,用于记录下每个子节点创建的先后顺序。在创建子节点的时候,可以设置这个标记,那么在创建顺序子节点过程中,ZooKeeper会自动给定节点名加上一个在当前父节点下唯一的数字后缀,作为一个新的、完整的节点名。注意这个数字后缀并不是按照“1234…"这样的顺序,而是随机的,但是可以保证它不重复。
临时节点(EPHEMERAL)
临时节点的生命周期和客户端的会话绑定在一起,如果客户端会话失效,那么这个节点就会被自动清理掉。临时节点只能作为叶子节点,不能在其下面创建子节点。
临时顺序节点(EPHEMERAL_SEQUENTIAL)
在临时节点的基础上,添加了顺序特性。
状态信息
使用stat命令获取一个数据节点的内容,可以得到如下信息。
下表对上面数据节点下的这些状态信息进行说明。
状态属性 | 说明 |
---|---|
cZxid | Created ZXID,表示该数据节点被创建时的事务ID |
ctime | Created Time,表示节点被创建时的时间 |
mZxid | Modified ZXID,表示该节点最后一次被更新时的事务ID |
mtime | Modified Time,表示该节点最后一次被更新的时间 |
pZxid | 表示该节点的子节点列表最后一次被修改时的事务ID |
cversion | 节点的子节点版本号 |
dataVersion | 节点的数据版本号 |
aclVersion | 节点的ACL版本号 |
ephemeralOwner | 创建该临时节点的会话的sessionID,持久节点该值为0 |
dataLength | 数据内容的长度 |
numChildren | 该节点的子节点个数 |
3.版本
如上面的表所示,ZooKeeper中每个数据节点都具有三种数据类型的版本信息,对数据节点的更新操作会引起版本号的变化。分别是dataVersion,cversion,aclVersion。
ZooKeeper中的版本概念表示的是对数据节点的数据内容、子节点列表,或是节点ACL信息的修改次数。比如,在一个数据节点/zk_node被创建完毕之后,节点的dataVersion值是0,含义是当前节点自从创建之后,被更新过0次。如果现在对该节点的数据内容进行更新操作,dataVersion值就会变成1。这里的数据内容即便更新为原来的内容,dataVersion仍然会+1。
ZooKeeper中版本的作用是用来实现乐观锁机制中的“写入校验”的。在写入校验阶段,事务会检查数据在读取阶段后是否有其他事务对数据进行过更新,以确保数据更新的一致性。
在ZooKeeper服务器的PrepRequestProcessor处理器类中,在处理每一个数据更新请求时,会进行如下所示的版本检查。
vsersion = setDataRequest.getVersion();
int currentVersion = nodeRecord.stat.getVersion();
if(version != -1 && version != currentVersion){
throw new KeeperException.BadVersionException(path);
}
version = currentVersion + 1;
从上面的执行逻辑中,可以看出,在进行一次setDataRequest请求处理时,首先进行了版本检查:ZooKeeper会从setDataRequest请求中获取到当前请求的版本version,同时从数据记录nodeRecord中获取到当前服务器上该数据的最新版本currentVersion。如果version为-1,说明客户端并不要求使用乐观锁,可以忽略版本比对。如果version不是-1,那么就比对version和currentVersion,如果两个版本不匹配,就会抛出BadVersionException异常。
4.Watcher
在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事件
下表列出了ZooKeeper中最常见的几个通知状态和事件类型。
回调方法process( )
当ZooKeeper向客户端发送一个Watcher事件通知时,客户端就会对相应的process方法进行回调,从而实现对事件的处理。process( )方法的定义如下。
abstarct public void process(WatchedEvent event)
参数WatchedEvent包含了每一个事件的三个基本属性:KeeperState、eventType和path。ZooKeeper使用WatchedEvent对象来封装服务端事件并传递给Watcher,从而方便回调方法process对服务端进行处理。
服务端在生成WatchedEvent事件之后,会调用getWrapper方法将自己包装成为一个可序列化的WatcherEvent事件,以便通过网络传输到客户端。客户端在接收到服务端的这个事件对象后,首先会将WatcherEvent事件还原成一个WatchedEvent事件,并传递给process方法处理,回调方法process根据参数就能够解析出完整的服务端事件了。
WatcherEvent其实就是将WatchedEvent封装的keeperState和eventType变成更容易使用的基本数据类型int,其对应的是上面表格中每项其后面的括号中的数字。
工作机制
ZooKeeper的Watcher机制,可以概况为以下三个过程:客户端注册Watcher、服务端处理Watcher和客户端回调Watcher。
1.客户端注册Watcher
在创建一个ZooKeeper客户端对象实例时,可以向构造方法中传入一个默认的Watcher:
public ZooKeeper(String connectString, int sessionTimeout, Watcher watcher)
这个Watcher将作为整个ZooKeeper会话期间的默认Watcher,会一直被保存在客户端ZKWatcheManager的defaultWatcher中。另外,ZooKeeper客户端也可以通过getData、getChildren和exist三个接口来向ZooKeeper服务器注册Watcher,无论使用哪种方式,注册Watcher的工作原理都是一致的。
这里以getData接口为例来说明,getData接口主要有两个方法:
public byte[] getData(String path, boolean watch, Stat stat)
public byte[] gteData(final String path, Watcher watcher, Stat stat)
在这两个接口上都可以进行Watcher的注册,第一个接口通过一个boolean参数来标识是否使用上文中提到的默认Watcher来进行注册,第二个接口使用一个新的Watcher来进行注册,两个接口的注册逻辑都是一样的。
在向getData接口注册Watcher后,客户端首先会对当前客户端请求request进行标记,将其设置为“使用Watcher监听”,同时会封装一个Watcher的注册信息WatchRegistration对象,用于暂时保存数据节点的路径和Watcher的对应关系,具体的逻辑代码如下:
public Stat getData(final String path, Watcher watcher, Stat stat){
...
WatchRegistration wcb = null;
if(watcher != null){
wcb = new DataWatchRegistration(watcher, clientPath);
}
...
request.setWatch(watcher != null);
ReplyHeader r = cnxn.submitRequest(h, request, reponse, wcb);
...
}
在ZooKeeper中,Packet可以被看作是一个最小的通信协议单元,用于进行客户端与服务端之间的网络传输,任何需要传输的对象都需要包装成一个Packet对象。因此,在ClientCnxn中WatchRegistration又会被封装到Packet中去,然后放入发送队列中等待客户端发送。
Packet queuePacket(RequestHeader h, ReplyHeader r, Record request, Record response, AsyncCallback cb, String clientPath, String serverPath, Object ctx, WatchRegistration watchRegistration){
Packet packet = null;
...
synchronized(outgoingQueue){
packet = new Packet(h, r, request, response, watchRegistration);
...
outgoingQueue.add(packet);
...
}
}
随后,ZooKeeper客户端就会向服务端发送这个请求,同时等待请求的返回。完成请求发送后,会由客户端SendThread线程的readResponse方法负责接收来自服务端的响应,finishPacket方法会从Packet中取出对应的Watcher并注册到ZKWatchManager中去:
private void finishPacket(Packet p){
if(p.watchRegistration != null){
p.watchRegistration.register(p.replyHeader.getErr());
}
}
客户端已经将Watcher暂时封装在了WatchRegistration对象中,现在就需要从这个封装对象中再次提取出Watcher来:
protected Map<String, Set<Watcher>> getWatches(int rc){
return watchManager.dataWatches;
}
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);
}
}
}
在register方法中,客户端会将之前暂时保存的Watcher对象转交给ZKWatchManager,并最终保存到dataWatches中去。
客户端Watcher注册流程如下:
上面提到把WatchRegistration封装到了Paket对象中去,但事实上,在底层实际的网络传输序列化过程中,并没有将WatchRegistration对象完全地序列化到底层字节数组中去。下面可以看下Packet内部的序列化过程:
public void createBB(){
try{
ByteArrayOutputStream baos = new ByteArrayOutputStream();
BinaryOutputArchive boa = BinaryOutputArchive.getArchive(baos);
boa.writeInt(-1, "len");
if(requestHeader != null){
requestHeader.serialize(boa, "header");
}
if(request instanceof ConnectRequest){
request.serialize(boa, "header");
}
if(request instanceof ConnectRequest){
request.serialize(boa, "connect");
boa.writeBool(readOnly, "readOnly");
}else if(request != null){
request.serialize(boa, "request");
}
}
...
}
可以看到,在Packet.createBB()方法中,ZooKeeper只会将requestHeader和request两个属性进行序列化,也就是说,尽管WatchRegistration被封装在了Packet中,但是并没有被序列化到底层数组中去,因此也就不会进行网络传输了。
2.服务端处理Watcher
ServerCnxn存储
对于标记了Watcher注册的请求,ZooKeeper会将其对应的ServerCnxn存储到WatchManager中。服务端收到来自客户端的请求之后,在FinalRequestProcessor.processRequest()中会判断当前请求是否需要注册Watcher:
case OpCode.getData:{
...
byte b[] = zks.getZKDatabase().getData(getDataRequest.getPath(), stat, getDataRequest.getWatch() ? cnxn : null);
rsp = new GetDataResponse(b, stat);
break;
}
当getDataRequest.getWatch()为true时,ZooKeeper就认为当前客户端请求需要进行Watcher注册,于是就会将当前的ServerCnxn对象和数据节点路径传入getData方法中去。ServerCnxn是一个ZooKeeper客户端和服务器之间的连接接口,代表了一个客户端和服务器的连接。ServerCnxn接口的默认实现是NIOServerCnxn,实现了Watcher的process接口,因此可以把ServerCnxn看作是一个Watcher对象。数据节点的节点路径和ServerCnxn最终会被存储在WatchManager的watchTable和watch2path中。
WatchManager是ZooKeeper服务端Watcher的管理者,其内部管理的watchTable和watch2Paths两个存储结构,分别从两个维度对Watcher进行存储。watchTable从数据节点的粒度来托管Watcher,watch2Paths从Watcher的粒度来控制事件触发需要触发的数据节点。
WatchManager还负责Watcher事件的触发,并移除那些已经被触发的Watcher。
Watcher触发
接下来看看服务端是如何触发Watcher的。
NodeDataChanged事件的触发条件是“Watcher监听的对应数据节点的数据内容发生变更”,具体实现如下:
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);
}
...
dataWatches.triggerWatch(path, EventType.NodeDataChanged);
return s;
}
在对指定节点进行数据更新后,通过调用WatchManager的triggerWatch方法来触发相关的事件:
public Set<Watcher> triggerWatch(String path, EventType type){
return triggerWatch(path, type, null);
}
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);
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;
}
3.客户端回调Watcher
服务端会通过使用ServerCnxn对应的TCP连接来向客户端发送一个WatcherEvent事件,接下来看客户端是如何处理这个事件的。
SendThread接收事件通知
ZooKeeper客户端接收这个客户端事件通知:
class SendThread extends Thread{
void readResponse(ByteBuffer incomingBuffer) throws IOException{
if(replyHdr.getXid() == -1){
WatcherEvent event = new WatcherEvent();
event.deserialize(bbia, "response");
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()));
}
WatchedEvent we = new WatchedEvent(event);
eventThread.queueEvent(we);
return;
}
}
}
EventThread处理事件通知
EventThread线程是ZooKeeper客户端中专门用来处理服务端通知事件的线程,其数据结构如下所示:
SendThread接收到服务端的通知事件后,会通过调用EventThread.queueEvent方法将事件传给EventThread线程,逻辑如下:
public void queueEvent(WatchedEvent event){
if(event.getType() == EventType.None && sessionState == event.getState()){
return;
}
sessionState = event.getState();
WatcherSetEventPair pair = new WatcherSetEventPair(watcher.materialize(event.getState(), event.getType(), event.getPath()), event);
waitingEvents.add(pair);
}
queueEvent方法首先会根据该通知事件,从ZKWatchManager中取出所有相关的Watcher:
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;
}
final private void addTo(Set<Watcher> from, Set<Watcher> to){
if(from != null){
to.addAll(from);
}
}
}
获取到相关的所有Watcher之后,会将其放入waitingEvents这个队列中去。WaitingEvents是一个待处理Watcher的队列,EventThread的run方法会不断对该队列进行处理:
public void run(){
try{
isRunning = true;
while(true){
Object event = waitingEvents.take();
if(event == eventOfDeath){
wasKilled = true;
}else{
processEvent(event);
}
}
private void processEvent(Object event){
try{
if(event instanceof WatcherSetEventPair){
WatcherSetEventPair pair = (WatcherSetEventPair) event;
for(Watcher watcher : pair.wathcers){
try{
watcher.process(pair.event);
}catch(Throwable t){
}
}
}
}
}
}
}
可以看到,EventThread线程每次都会从waiting Events队列中取出一个Watcher,并进行串行同步处理。
Watcher特性
1.一次性
无论是服务端还是客户端,一旦一个Watcher被触发,ZooKeeper都会将其从对应的存储中移除。
2.客户端串行执行
客户端Watcher回调的过程是一个串行同步的过程,保证了顺序。
3.轻量
WatchedEvent是ZooKeeper整个Watcher通知机制的最小通知单元,Watcher通知非常简单,只会告诉客户端发生了事件,而不会说明事件的具体内容。客户端向服务端注册Watcher时,并不会把客户端真实的Watcher对象传递到服务端,仅仅只是在客户端请求中使用boolean类型属性进行标记,同时服务端也仅仅只保存了当前连接的ServerCnxn对象。这样轻量的Watcher机制设计,在网络开销和服务端内存开销上都是非常廉价的。
5.ACL
ACL是权限控制机制,用来保障数据的安全。可以从三方面来理解ZooKeeper的ACL机制,分别是权限模式(Scheme)、授权对象(ID)和权限(Permission),通常使用“scheme: id : permission”来标识一个有效的ACL信息。
权限模式:Scheme
权限模式用来确定验证过程中使用的检验策略,在ZooKeeper中,使用最多的是以下四种权限模式:
权限模式 | 说明 |
---|---|
IP | 通过IP地址粒度来进行权限控制,例如配置了“ip:192.168.0.1”表示权限控制都是针对这个IP地址的 |
Digest | 最常用的权限控制模式,用类似于"username:password"形式的权限标识来进行权限配置 |
World | 最开放的权限控制模式,数据节点的访问权限对所有用户开放,也可以看作是一个特殊的Digest模式,只有一个权限标识“world:anyone” |
Super | 超级用户模式,在Super模式下,超级用户可以对任意ZooKeeper上的数据节点进行任何操作 |
授权对象:ID
授权对象指的是权限赋予的用户或一个指定实体,在不同权限模式下,授权对象是不同的。下表列出了各个权限模式和授权对象之间的对应关系。
权限模式 | 授权对象 |
---|---|
IP | 通常是一个IP地址或是IP段 |
Digest | 自定义,通常是“username:BASE64” |
World | 只有一个ID:“anyone” |
Super | 与Digest模式一样 |
权限:Permission
权限就是指那些通过权限检查后可以被允许执行的操作,在ZooKeeper中,对数据的操作权限如下表所示。
权限 | 说明 |
---|---|
CREATE(C) | 数据节点的创建权限,允许授予对象在该数据节点下创建子节点 |
DELETE(D) | 子节点的删除权限,允许授权对象删除该数据节点的子节点 |
READ(R) | 数据节点的读取权限,允许授权对象访问该数据节点并读取其数据内容或子节点列表 |
WRITE(W) | 数据节点的更新权限,允许授权对象对该数据节点进行更新操作 |
ADMIN(A) | 数据节点的管理权限,允许授权对象对该数据节点进行ACL相关的设置操作 |