1. 背景
系统上线后,一直运行的很稳。 但随着时间的推移,逐渐暴露出一些问题:
1.1. 服务灰度上线后,zk上的服务路由没有同步到服务节点,导致灰度节点收到很多计划外的请求
1.2. 服务升级的时候,明明某服务节点已经从zk上摘除了, 可还是有些请求来到该节点,引发异常。
种种矛头指向了zookeeper的watch。莫非watch失效了?
2. 相关概念
zk session
每个zk 客户端都有个sessionId,用以表明该客户端在zkServer的唯一标识符。
zk客户端跟服务端断开的时候, 会带着这个sessionId去重连zk服务端。
在session 没有timeout之前连上的话, 那么SyncConnected事件会在客户端触发, 且该客户端所有的watcher都会自动重新注册上。
如果session timeout了,那么一方面服务端会清理该session注册的临时节点以及所有的watcher,且如果这时候,客户端带着已经超时的sessionId连接上来的话, 服务端会引发客户端一个Expired事件,然后该客户端的两个线程(io线程跟事件线程)会终止。客户端能做的就是关闭当前客户端,然后重新创建一个客户端再去连接zk。
session是否超时是有服务端判断的。
zk的线程
主要有两个,
- IO线程(SendThread),负责跟zk服务端的通讯,包括数据收发以及心跳。
- 事件线程(EventThread),事件产生的时候负责通知用户并回调用户的事件处理逻辑
zk的watcher
watcher有两种,
连接状态监控watcher
这种watcher是创建客户端的时候作为参数的,它负责管理zk客户端跟服务端的连接状态,一般的状态有synconnected, disconnected,expired,AuthFailed
zk数据监控watcher
监控zk上的目录或者节点变化
3. dapeng-soa zk 结构
dapeng-soa 使用zookeeper作为注册中心跟配置中心的核心组件。它包括如下几种节点:
3.1. 服务运行时信息
存放了服务的运行时信息,包括服务名,ip,端口, 版本号以及序号
其中,固定前缀+服务全限定名作为目录:
/soa/runtime/services/com.github.dapeng.service.DemoService
目录下存放具体的节点以及运行时信息:
192.168.10.126:9099:1.0.0:0000000300
192.168.20.133:9099:1.0.0:0000000304
192.168.10.130:9099:1.0.0:0000000305
192.168.20.102:9099:1.0.0:0000000303
192.168.10.131:9099:1.0.0:0000000302
服务运行时信息是zk临时节点,一旦某节点下线,则该节点信息从服务目录中消失。
序号作为服务节点选举master的依据。最早注册的服务节点序号最小,同时也是master节点
3.2. 服务配置信息
服务配置信息存放了dapeng-soa所需要的一些配置项,可通过命令行工具或者配置中心进行管理。
配置信息包括服务路由信息,服务限流配置信息以及其它配置信息(例如负载均衡策略、权重以及超时等)
3.2.1. 服务路由信息
目录路径:
/soa/config/routes
目录中存放各服务节点:
com.github.dapeng.service.DemoService
com.github.dapeng.service.OrderService
而路由信息作为data直接写在服务节点上。例如下面是订单服务的路由信息:
method match r"create.*" => ip"192.168.10.0/24"
cookie_storeId match %"10n+1..6" => ip"192.168.20.128"
3.2.2. 限流配置
目录路径:
/soa/config/freq
目录中存放各服务节点:
com.github.dapeng.service.DemoService
com.github.dapeng.service.OrderService
同样,限流信息作为data直接写在服务节点上。例如下面是订单服务的限流信息:
[rule1]
match_app = listOrder # 针对具体方法限流
rule_type = callerIp # 对每个请求端IP
min_interval = 60,5 # 每分钟请求数不超过5次
mid_interval = 3600,100 # 每小时请求数不超过100次
max_interval = 86400,200 # 每天请求数不超过200次
[rule2]
match_app = * # 针对服务限流
rule_type = callerIp # 对每个请求端IP
min_interval = 60,600 # 每分钟请求数不超过600
mid_interval = 3600,10000 # 每小时请求数不超过1万
max_interval = 86400,80000 # 每天请求数不超过8万
3.2.3. 服务白名单
只有在白名单中的服务,才对外(三方合作方)开放访问权限。一般应用在服务网关中(dapengMesh)。
目录路径:
/soa/whitelist/services/
目录下存放具体的白名单:
com.github.dapeng.service.DemoService
com.github.dapeng.service.OrderService
4. dapeng-soa zk 变迁史
我们先来了解一下dapeng-soa zk的几个重大重构。
4.1 阶段一 粗放型
新建某服务的客户端的时候,同步该服务在zk上的运行时信息到客户端,并对zk上的运行时目录以及服务配置目录保持同步(新建一个watch,通过watch来监听服务信息的变化)。由于zk的一次性监听机制,处理同步的watch在消费完zk事件后需再次注册。
该方案简单清晰, 但由于我们的服务客户端很轻,且我们鼓励无状态的、对GC友好的编码方式,我们建议应用在调用服务的时候新建客户端,用完丢弃,而不是预先创建客户端缓存在应用中并多次重用。
那么在客户端空闲甚至被gc后,应用无需再继续保持对服务的监听。尤其是集群内部服务很多的情况下,海量的无用的watch监听会让zk不堪重负。
4.2 阶段二 精细型
由于阶段一的粗放型方式会导致无用的zk watch,我们通过java的虚引用机制,让客户端被gc后,通知清理线程取消对zk的watch,从而有效减少了不必要的同步。
4.3 阶段三 watch重用
阶段二的想法是服务客户端被gc后,取消对zk的watch。然而在实际应用中,取消watch并没有直接的api,只能等监听的事件触发消费后,watch才会真正消失。 经过一段时间的观察,我们发现线上watch的数量越来越多,不加节制的创建watch以及watch难以快速真正取消,甚至造成了几次内存溢出。
其实watch本质上也是无状态的,只是对zk 事件的简单同步处理而已。理解了这点后,我们果断以服务为单位缓存watch,一个服务有且仅有一个watch。
经过这个处理之后,watch的数量得到了真正有效的控制, 再也没有出现内存溢出的情况了。
自从进入阶段三以来,原本以为系统进入了坚如磐石的阶段,直到出现本文开头背景介绍中的故障
5. zk watch失效分析
首先查了一下最后一次watch生效之后的日志,发现zk客户端跟服务端的连接短暂的断开了(网络抖动)。
我们对zk连接事件的处理如下:
/**
* 连接zookeeper
*/
private void connect() {
try {
CountDownLatch semaphore = new CountDownLatch(1);
// default watch
zk = new ZooKeeper(zkHost, 30000, e -> {
LOGGER.info("ClientZk::connect zkEvent:" + e);
switch (e.getState()) {
case Expired:
LOGGER.info("Client's host: {} 到zookeeper Server的session过期,重连", zkHost);
zk.close();
zk = null;
connect();
break;
case SyncConnected:
semaphore.countDown();
LOGGER.info("Client's host: {} 已连接 zookeeper Server", zkHost);
config.clear();
break;
case Disconnected:
LOGGER.error("Client's host: {} 到zookeeper的连接被断开, do nothing.", zkHost);
zk.close();
zk = null;
connect();
break;
case AuthFailed:
LOGGER.error("Zookeeper connection auth failed ...");
zk.close();
zk = null;
break;
default:
break;
}
});
semaphore.await(10000, TimeUnit.MILLISECONDS);
} catch (Exception e) {
LOGGER.info(e.getMessage(), e);
}
}
当收到zk连接给断开的事件后,首先会调用close方法主动断开跟zk的连接,然后置空zk客户端,最后重新创建zk客户端。
实际上这个Disconnected事件的处理逻辑是画蛇添足的,收到该事件后,zk客户端会自动重连zk并重新注册所有的watch。 而现在我们是把zk客户端关闭了,那么也就不会有自动重连并注册watch这个过程了。 然后这里做了主动
重连。
需要注意的是,主动重连zk后,产生了一个新的zk客户端。 而我们的watch,是注册在老的zk客户端上的。 随着老zk客户端的消亡,这些watch也就失效了。
解决方案很简单, 不要处理Disconnected
,同时在处理Expired
事件的时候清理所有已存在的watch,关闭zk连接并重新创建zk客户端。
网友评论