ZooKeeper学习笔记——开发指导
数据模型
参考概述中介绍,path路径字符存在如下约束:
- 空字符(\u0000)不能使用
- \u0001 - \u0019 and \u007F - \u009F不能使用
- \ud800 -uF8FFF, \uFFF0 - uFFFF不能使用
- "."和".."不能作为名称的一部分,ZK不支持相对路径
- "zookeeper"不能使用
ZNodes
ZK树的每一个节点被称为znode,其维护的stat结构体包含了版本号(对应于数据和acl变更而变更)和时间戳。这使得ZK可以校验内存中缓存的内容并且协同更新。znode数据变更,版本号也会递增。客户端查询数据,同时会得到数据的版本号。当客户端执行更细或者删除操作,必须提供相应的版本号。如果提供的版本号和当前数据版本号不一致,操作将会失败。
Watches
客户端可以对znode设置watches,相应的修改会触发watch并且清掉watch。当watch触发,ZK会发送通知给客户端。
Data Access
znode数据读写是原子性的,并且通过ACL来约束。 ZK不是设计为普通数据库或者大对象存储。其只管理协同数据,建议为KB数量级。ZK客户端和服务器需要确保数据不要超过1M,实际使用中应该远少于这个约束。否则会影响性能。对于确实需要存储大量数据的场景,应该将数据存储在专门的块存储系统入NFS或HDFS,ZK只存储相应的偏移指针。
Ephemeral Nodes(临时节点)
可参考概述的介绍,临时节点不能有子节点。
Sequence Nodes -- Unique Naming(顺序节点)
当创建一个znode时,可以请求ZK在path结尾加上一个自增的计数值,该计数值在其父节点下是唯一的。计数值的格式是一个带0填充的十位十进制数。note:计数器计数器保存了下一个计数值,由父节点维护,计数超过2147483647会溢出(<path>-2147483648)
ZK的时间性
ZK有多种时间追踪方式:
- Zxid
ZK状态的每一次变更都将收到一个Zxid(ZooKeeper Transaction Id)形式的戳。这体现ZK所有修改的顺序性。每一次修改都会有一个唯一的zxid,并且如果zxid1比zxid2小则说明zxid1比zxid2先发生。
- version版本号
node节点的每一次变更都会导致其版本号的一次自增。有三个版本数字,version对应当前节点的变更,cversion对应其子节点的变更,aversion对应ACL的变更。
- ticks
当集群方式使用ZK,服务器使用ticks定义事件时间如状态上传、回话超时、服务器间连接超时等。
- real time
ZK不使用真实时间,只有存入stat的时间戳除外。
ZK stat结构体
- czxid
创建该znode的zxid。
- mzxid
最近修改znode的zxid。
- pzxid
最近修改该znode子节点的zxid。
- ctime
znode创建时的时间(精度ms)。
- mtime
znode最近一次修改时的时间(精度ms)。
- version
znode数据修改次数(创建后未修改过为0)。
- cversion
znode子节点修改的次数。
- aversoin
znode的ACL修改次数。
- ephemeralOwner
如果znode是临时节点,记录创建该节点的session id,如果不是临时节点则为0.
- dataLength
znode存储的数据长度。
- numChildren
znode子节点数。
ZK Sessions(会话)
ZK客户端通过创建一个句柄来建立和ZK服务的会话。具体参考如下状态图:
应用程序需要提供一个<host:port>二元组列表用于客户端创建会话,每一个二元组对应一台ZK服务器。ZK客户端随机选择一个服务器并尝试建立连接。如果连接失败或者异常断开,客户端将原子性的尝试元组列表里的下一个服务器,直到连接建立。
3.2.0版本开始支持连接串后缀指定根目录,客户端的请求命令都将解析到这个根目录下,例如"127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002/app/a"。
当客户端获得一个连接到ZK服务的句柄,ZK会创建一个会话对应id为64bit的数字并分配给该客户端。如果客户端连接到另一台服务器,客户端将发送该session id作为连接握手的一部分。此外还有一个安全机制,ZK服务器会为session id创建一个密码,集群中任一台服务器都能用于校验。客户端建立会话后,会收到session id对应的密码。在客户端和另外一台服务器重新建立会话时,客户端会将该密码和session id一起发送过去。
ZK客户端创建会话的另一个参数是会话超时时间(ms)。客户端发送一个请求的超时时间值,服务端根据所能提供的范围进行响应。超时时间有效范围是2~20个tick,具体可通过协商确定。
当一个客户端和ZK服务集群断开了,客户端将尝试连接会话创建时指定的服务器列表中的其他服务器。最终,当连接建立后,如果是在超时时间内则会话状态为connected,否则状态为expired。此外,当连接断开后,不应该立即创建新的会话,只有被通知会话过期(expiration)才需要创建新的会话。
会话过期是由ZK集群来管理的,而非客户端。ZK客户端和集群服务建立会话所提供的"timeout"值,将被用于判断客户端的会话是否过期。当集群在指定的超时时间内未收到客户端消息就认为会话过期。当一个会话过期,集群将删除该会话的所有临时节点,并立刻通知监听了这些节点的连接中的客户端。此时会话过期的客户端和集群的连接时断开的,所以收不到会话过期的通知,除非它和集群重新建立了连接。客户端将一直处于disconnected状态,直到和集群的TCP连接重新建立,这是会话过期的监听器(watcher)才能收到会话过期的通知。
客户端收到会话过期通知的过程如下:
- 'connected':会话建立
- 网络断开
- 'disconnected':连接断开
- timeout超时,集群将会话超时,客户端无感知
- 网络恢复
- 'expired':客户端和集群重新建立连接,客户端收到会话过期通知。
ZK建立会话的另一个参数是Watcher,客户端任何状态变更都将通知到Watcher。例如客户端和服务器连接断开,或者客户端会话过期等。Watcher需要考虑初始状态为disconnected。在新连接的情况下,发送给Watcher的第一个事件通常是会话连接事件。
客户端的request请求可以保活会话。如果会话空闲并可能导致会话超时,客户端将发送一个PING请求保活。PING请求不仅让服务器知道客户端是**的,也能让客户端校验其与ZK服务器的连接仍然是**的。PING的定时时间需要确保有一个合理的时间去检测死掉的连接并且重连到一个新的服务器。
连接建立后,有 两种情况客户端会生成connectionloss(C客户端返回码,java客户端为异常):
- 应用在一个无效的会话上调用操作;
- 客户端断开连接,客户端和服务端还有提交中的操作请求时,也就是异步提交调用。
3.2.0版本新增 SessionMovedException。这是一个客户端通常不会出现的异常,当会话的连接和另外一台服务器重新建立后,此时收到了请求,会触发这个异常。导致该错误的原因是一个客户端向一台服务器发送一个request请求,但是网络报文出现延迟,且客户端超时和另一台服务器建立了连接。当延迟报文到达第一台老的服务器,老的服务器检测到和他的会话已经删掉了,并关闭了和客户端的连接。客户端感知不到这个错误,因为不会再从老的连接收到信息了。但是有一种情况可以看到这种错误,就是当两个客户端用同一个缓存的session id和密码试图重新建立同一个连接。有一个客户端能成功重建连接,另一个会失败。
ZK Watches
ZK所有的读操作(getData(),getChildren(),exists())都可以选择设置一个watch参数。如下是ZK定义的一个Watch:一个watch事件是一次性触发的,通知到设置该watch的客户端,当watch的数据变化时触发。
- One-time trigger
data数据变化时会发送watch事件到客户端。例如,一个客户端执行getData("znode1",true)之后/znode1的数据变更或者节点被删除,该客户端将收到一个/znode1的watch事件。如果/znode1再次变更,除非有另一个读的操作设置了新的watch,否则该次变更客户端不会收到watch事件。
- Sent to the client
这意味着一个watch事件在发送到其客户端的路上,而进行本次修改的客户端可能先收到本次修改成功的返回值。Watche事件是异步发送到Watchers的,ZK提供的顺序保证是:客户端设置了watch,必然先收到watch事件,而后才能查看到变更后的数据。网络等因素可能导致不同的客户端在不同的时间收到watch时间。重点是每一个客户端查看到的数据都有具有一致性顺序保证。
- The data for which the watch was set
针对node变更的不同方式,可以理解为ZK有两类watch列表,data数据watches和子节点watches。getData()和exists()设置在data watches列表;而getChildren()设置在子节点watches列表。所以setData()将触发data watches,create()将触发data watches和child watches,delete()将触发data watches和child watches。
Watches是维护在客户端所连接的服务器本地的,所以是一个轻量级的机制。当客户端连接到一个新的服务器时,其watch将被任一会话事件触发(触发后watch终结),watches事件不会从断连的服务器接收到。当客户端完成重连,之前设置的watches会被重新设置并在需要时被触发,这对用户来说是透明的。有一种情况watch可能会丢失,当一个znode在连接断开时被创建和删除,则对该znode的exist watch将丢失。(注意:连接和会话的区别,会话是有事件的,开发时能感知,连接时客户端api包封装的,开发时基本不需要感知处理)
Watches的语义
有三个调用可以设置watches:exists,getData,getChildren。如下详细描述了watch触发的事件和相应的调用操作:
- Created event:
exists
- Deleted event:
exists,getData,getChildren
- Changed event:
exists,getData
- Child event:
getChildren
ZK对Watches提供的保证
- Watches按event,其他watches维持有序并异步通知。ZK客户端api库确保一切都是有序分发的。
- 客户端会先收到watch事件,才能查看到znode的最新数据。
- 来自ZK的watch事件和ZK服务器的更新操作顺序上是一致的。
Watches注意事项
- watches是一次性触发的,当收到一个watch事件还想收到后续通知,需要重新设置watch;
- 由于watches一次性触发,且重新设置的watch可能存在延迟,所以不能可靠的感知znode的发生的所有变更,要认识到接收当前watche事件与设置下一次watch之间znode可能存在多次变更。
- 当客户端和服务器的连接断开,除非连接自动重新建立了,否则客户端将收不到watch事件。但此时可以收到会话事件,进行相应处理。
ZK访问控制ACL
ACL的实现和unix文件访问权限非常相似,它使用权限bit位控制对node的操作。和标准unix权限不同处在于,一个ZooKeeper节点不受用户(文件所有者)、组和世界(其他)的三个标准范围的限制。ZK没有znode所有者的概念,ACL指定了一个id集合和这些id的权限。
需要注意,一个ACL从属于一个znode,而不适用于znode的子节点。例如:/app只能被172.16.16.1读,而/app/status可以被所有客户端读,ACL不是递归适用的。
ZK支持插件化认证方案(scheme),Ids通过scheme:id格式指定,其中scheme指id所适用的插件方案,例如ip:172.16.16.1。
当客户端连接到ZK并进行认证,ZK会将该客户端所有ids与其连接关联起来。当客户端尝试访问节点,会对这些ids进行ACL校验。ACLs由<scheme:expression, perms>构成,expression的格式由方案scheme指定。例如二元组<ip:19.22.0.0/16, READ>规定了所有ip以19.22开头的客户端的读权限。
ACL的权限
ZK支持下来权限类型
- CREATE:创建子节点
- READ:读取node的数据和其子节点列表
- WRITE:对node写数据
- DELETE:删除子节点
- ADMIN:设置权限管理
将CREATE和DELETE从WRITE权限剥离是为了更细粒度的进行访问控制,CREATE/DELETE的可能有如下使用场景:
- 期望A可以修改一个node的数据,但不能CREATE/DELETE其子节点。
- 可以CREATE不能DELETE:客户端通过在一个父目录下创建znode来发出请求。并且希望所有的客户端都能够添加请求,只有处理请求者可以执行删除(有点像文件的APPEND追加权限)。
管理权限的存在是由于znode没有所有者(owner)的概念,某种意义上ADMIN权限就像是指定owner。ZK没有查询(LOOKUP)权限管理,所有客户端都有LOOKUP权限,
ACL方案结果
- world:每个客户端都有一个唯一的id标识
- auth:不使用任何id,表示任何经过身份验证的用户
- digest:将username:password字符串生成的MD5哈希作为ACL ID,通过发送username:password明文完成身份验证,当在ACL中使用时,表达式将是用户名:base64编码的SHA1密码摘要。
- ip:使用客户端的主机ip作为ACL ID,ACL表达式是addr/bits的形式,其中最重要的addr与客户端主机IP的最重要部分匹配。
ZK C语言客户端
ZK C语言库提供如下常量定义
- const int ZOO_PERM_READ; //读取node数据和子节点列表
- const int ZOO_PERM_WRITE;// 写node数据
- const int ZOO_PERM_CREATE; //创建子节点
- const int ZOO_PERM_DELETE;// 删除子节点
- const int ZOO_PERM_ADMIN;//执行set_acl()
- const int ZOO_PERM_ALL;//前面所有标识&
ACL IDs结构体
- struct Id ZOO_ANYONE_ID_UNSAFE;//(‘world’,’anyone’)
- struct Id ZOO_AUTH_IDS;// (‘auth’,’’)
ZOO_AUTH_IDS 空id字符串应该被解读为id的创建者。
ZK客户端有如下三种标准的ACLs
-
struct ACL_vector ZOO_OPEN_ACL_UNSAFE; //(ZOO_PERM_ALL,ZOO_ANYONE_ID_UNSAFE)
-
struct ACL_vector ZOO_READ_ACL_UNSAFE;// (ZOO_PERM_READ, ZOO_ANYONE_ID_UNSAFE)
-
struct ACL_vector ZOO_CREATOR_ALL_ACL; //(ZOO_PERM_ALL,ZOO_AUTH_IDS)
ZOO_OPEN_ACL_UNSAFE 对所有ACL规则都是开放的,所有应用端都可以对node执行任何操作和创建,查询或删除子节点。ZOO_READ_ACL_UNSAFE 对所有应用都是只读的。CREATE_ALL_ACL node创建者拥有所有权限,但在创建node之前肯定得先通过服务端的认证。
ACL相关有如下操作
- int zoo_add_auth (zhandle_t zh,const char scheme,const char* cert, int certLen, void_completion_t completion, const void *data);
客户端应用通过改方法向服务端认证,改方法可以调用多次。
- int zoo_create (zhandle_t *zh, const char *path, const char *value,int valuelen, const struct ACL_vector *acl, int flags,char *realpath, int max_realpath_len);
创建一个新的node节点,acl参数是该node节点的ACLs
- int zoo_get_acl (zhandle_t *zh, const char *path,struct ACL_vector *acl, struct Stat *stat);
返回node的ACLs信息
- int zoo_set_acl (zhandle_t *zh, const char *path, int version,const struct ACL_vector *acl);
重新设置ACL list,但必须有该node的ADMIN权限
如下一个使用上述API接口的例子,使用"foo"scheme进行认证,并在只有CREATE权限情况下创建一个临时节点"/xyz"。
#include <string.h>
#include <errno.h>
#include "zookeeper.h"
static zhandle_t *zh;
/**
* In this example this method gets the cert for your
* environment -- you must provide
*/
char *foo_get_cert_once(char* id) { return 0; }
/** Watcher function -- empty for this example, not something you should
* do in real code */
void watcher(zhandle_t *zzh, int type, int state, const char *path,
void *watcherCtx) {}
int main(int argc, char argv) {
char buffer[512];
char p[2048];
char *cert=0;
char appId[64];
strcpy(appId, "example.foo_test");
cert = foo_get_cert_once(appId);
if(cert!=0) {
fprintf(stderr,
"Certificate for appid [%s] is [%s]\n",appId,cert);
strncpy(p,cert, sizeof(p)-1);
free(cert);
} else {
fprintf(stderr, "Certificate for appid [%s] not found\n",appId);
strcpy(p, "dummy");
}
zoo_set_debug_level(ZOO_LOG_LEVEL_DEBUG);
zh = zookeeper_init("localhost:3181", watcher, 10000, 0, 0, 0);
if (!zh) {
return errno;
}
if(zoo_add_auth(zh,"foo",p,strlen(p),0,0)!=ZOK)
return 2;
struct ACL CREATE_ONLY_ACL[] = {{ZOO_PERM_CREATE, ZOO_AUTH_IDS}};
struct ACL_vector CREATE_ONLY = {1, CREATE_ONLY_ACL};
int rc = zoo_create(zh,"/xyz","value", 5, &CREATE_ONLY, ZOO_EPHEMERAL,
buffer, sizeof(buffer)-1);
/** this operation will fail with a ZNOAUTH error */
int buflen= sizeof(buffer);
struct Stat stat;
rc = zoo_get(zh, "/xyz", 0, buffer, &buflen, &stat);
if (rc) {
fprintf(stderr, "Error %d for %s\n", rc, __LINE__);
}
zookeeper_close(zh);
return 0;
}
Java开发
java客户端库由两个package构成,org.apache.zookeeper和org.apache.zookeeper.data。其他package是ZK内部使用的。
java客户端主要是ZooKeeper类,它有两个构造方法,区别在于是否传入session id和密码。ZK支持应用恢复session。Java应用可以保存session id和密码,重启后通过缓存信息恢复session。
ZK对象创建后,就有了两个线程:IO线程和event线程。所有的IO在IO线程处理,所有的事件callbacks回调在事件线程处理。session在IO线程维护重连和心跳机制。同步方法的response响应也在IO线程处理。异步方法和watch事件的响应在event线程处理。居于此设计有一些特征:
- 异步调用和watcher回调是有序的,一次执行一个。调用者可以做任何他们希望的处理,但同时刻不会执行其他的回调。
- 回调的执行不会阻塞IO线程或者同步调用的处理。
- 同步调用可能不会以正确的顺序返回。例如,假设一个客户端执行以下处理:将节点/a的异步读操作与watch设置为真,然后在读取完成回调时,它会对/a进行同步读取。(也许不是很好的做法,但也不是非法的,这是一个简单的例子。)
- 注意:如果在异步读和同步读期间/a存在变更,客户端将在同步读响应之前收到/a变更的watch事件。但是如果变更的完成回调阻塞了event队列,则同步读将在/a变更的watch事件之前返回。
- 最后,与结束相关的规则很简单,一个ZK对象被close关闭或者收到一个致命的事件(session过期或者认证失败),ZK对象就变成无效的。当close关闭时,这两个现场就关闭了,任何在改ZK对象上的访问操作都是未定义的且应该避免这种操作。
错误处理
Java和C的客户端库都能报出错误。Java客户端通过抛出KeeperException实现,异常可以调用code()获取错误码。C客户端返回的错误码定义在ZOO_ERRORS枚举中。
常见故障分析及定位
以下是ZK用户可能遇到的一些陷阱:
- 如果你使用watches,你必须注意是连接时的watch事件。当ZK客户端与服务器断连,客户端在连接重新建立之前不会收到任何变更通知。如果你正在监控一个znode的存在与否,那么如果在断连期间这个znode创建和删除了,你将错过这些事件。
- 你必须测试ZK服务器故障的场景。只要大多数ZK服务器存活着ZK服务就是有效的,问题是你的应用能否处理这些服务器故障场景?当客户端和一台ZK服务器的连接断开,客户端的ZK库将尽力恢复连接并通知客户端,但是你必须确保你恢复了你的状态以及任何失败的请求。在测试环境完成这些测试,而不是生成环境。
- 客户端使用的ZK服务器列表必须和ZK服务集群的服务器吻合。尽管客户端的ZK服务器列表是ZK集群服务器列表子集时,也能勉强运行,但如果客户端使用的ZK服务器不在ZK服务集群中则不能正常运行。
- 注意配置事务日志的地方。ZK高性能的一个关键点在于事务日志。ZK服务在返回一个响应之前必须将事务内容同步到日志。一个专用的ZK事务日志设备是稳定高性能关键。将日志放在繁忙的设备上会对性能造成不利影响,如果您只有一个存储设备,在NFS上放置跟踪文件,并增加快照计数;它不能消除问题,但可以缓解。- 正确设置Java max堆大小。避免避免内存swapping(虚拟内存)非常重要,不必要的命中在磁盘上肯定会降低性能。记住,ZK上一切都是有序的,所以如果一个请求命中了磁盘,队列中的所有其他请求也将命中在磁盘。为避免swapping,尝试设置堆内存大小为实际的物理内存大小(减去操作系统和Cache所需要的内存)。最好的方式是通过压测得到这样一个最优的堆大小。否则在你的估计中保持保守,并选择一个远低于导致你的机器交换的数量的数字。比如4G的设备,3G堆是一个保守的的估计。