分析一下我司ZooKeeper客户端封装思路

  为了提高系统的可用性,我司目前也已经有不少项目采用ZooKeeper进行服务发布发现了。
  之前已经对这块内容预学习了很久,虽说ZooKeeper的C API使用起来感觉很简单,核心接口(包括同步和异步)十来个左右的函数,但是要想在C++中做到傻瓜式的使用还是有点麻烦的,因此需要同往常情况一样,在C++中也有大量针对ZooKeeper Client封装的轮子。当前我司使用的客户端库的据说是老大花了一周心血封装完成的,已经被大量使用并接受住了生产系统考验,这边文章是对其封装设计思路的总结,所有Idea及版权归原作者所有,而且抱歉目前源码无法分享。

  对于服务提供者来说,最为常见的情况就是把服务自身创建注册为一个临时节点,当服务自己挂掉、或者网络异常之后该节点自动消失,监听服务目录的服务调用者会感知到该变化并作出反应。这是一种比较直观的设计方式,而且基本所有的ZooKeeper教科书、手册也都会这么举例子,但是也带来一个问题:因为临时节点不支持创建子节点,所以这个服务提供者的所有配置信息就必须全部丢到node data域里面了,如果配置信息比较多的时候就需要使用json、xml或者类似的方式组织编码,也就意味着更新一个配置的话需要将数据全部取出来解码,修改某些字段后再编码打包,最后再将这一坨东西写回去。如果是程序自动协助完成还好,但要是想在以zkCli.sh的方式临时手动更新的话,这种困难可想而知。
  因此,我们不将服务提供者实现为一个临时节点,而将其创建为一个持久节点,然后在其下面建立属性子节点方便配置和更新,当然子节点也根据服务下线后是否需要持久化保存配置或者自动删除而对应建立持久或者临时节点,下面就是一个服务提供者所需要考虑的常见属性信息,当然还可以按照需求做出扩充,下面这些节点容我描述过来。
eink-pdf
  图中的service_name族的节点表示某个具体的服务,服务消费者可以Watch这个节点,并且以host:port方式命名的子节点代表一个个具体实际的服务提供者,在服务提供者启动的时候会尝试创建或者更新这个永久节点,这个服务提供者节点的子节点包括:
  active:是一个临时性节点,只有在其存在的时候才表示服务提供者是活着可用的,服务挂掉或者和ZooKeeper会话断开后会自动消失;
  idc:服务提供者所在的数据中心,服务消费者可以有限选择本地的服务提供者,以实现最高效快速的服务;
  priority:服务提供者的优先级,值高的表示优先级大,在消费者获取服务的时候会有限选择他,这尤其在主备形式的多实例部署情况很有用;
  weight:服务提供者的权重值,可以给多个服务者设置合适的权重,以实现合理的负载均衡,并且客户端可以根据服务提供者的质量进行动态调整优化(本地的视角而言);
  enable:可以手动关闭某个节点的访问,而不用将其杀死或者剔除出ZooKeeper连接。
  至于上面服务节点的IP地址的获取,如果通过配置文件写死的话,会让后续维护起来十分麻烦,而且容易出错。通常的做法是遍历本机网卡,然后获取一个非本地回环的地址注册上去,而且如果运维做的比较好的话,对主机的命名、网卡的命令都会有统一的规范,这对选取IDC、IP等信息就更为的方便准确了。如果上面的方式都无法获取IP地址,那么只能尝试先向注册中心连接,然后看连接后socket的本地地址是什么,再将这个地址注册上去。
  使用过程中,当服务提供者将自己注册完成后,就算是功成圆满了,但是服务调用者对于服务提供者的选择可能是千差万别的:比如主备模式、权重模式、轮训模式等,要作为一个通用的客户端库,就必须考虑好各种各样的应用场景对服务选择的需求,上面的idc、priority和weight基本满足这些常见的消费场景了。不过需要说明的是:一方面这里的属性值都是通过我们手动配置在服务提供者端的,服务调用者Watch节点后会感知这种变化,从而加载到本地中去;同时服务调用者可以根据调用结果做一些流控优化,增加或者降低某些服务提供者的权重甚至优先级,不过他们修改的数据都是本地的副本,而不应该修改全局的配置,因为每个调用者都是从自己的视角来评价服务的,造成这样的因素可能是调用者本身的问题,而不应该将自己的感受强加给其他的调用者,否则集群可能会很不稳固,当然也很不合理。
  服务调用者首次读取和Watch服务目录得到所有的服务提供者,同时也获得各个服务提供者的idc、priority、weight等参数,而在真正消费的时候会根据服务选择算法(比如IDC优先、优先级优先、权重)等因素作用下获得一个可用的节点,得到其地址后进而对其发起调用。这些数据都是缓存在本地的,所以性能上不会是问题,同时服务提供者的任何更新都会触发Watch的回调被执行,进而感知更新(比如服务下线上线等);如果事件丢失或者获取失败,调用者还可以使用本地Legacy的旧数据继续维持运转。恩,这里如果加个定时器定期去获取更新节点信息是不是更好点?
  对于服务的发布和下线,理论上我们也应该做的尽量的优雅。通常,我们应该等服务启动和初始化完毕之后,再向注册中心发布自己,确保一旦发布之后就立即可以提供服务;对于下线,我们应该首先将自己的权重将为或者关闭服务开关,当发现服务没有调用者之后,再主动向注册中心注销自己,然后才能安全下线。这里需要注意的是,一定要主动注销自己,否则服务的临时节点只有等到会话超时SESSION_TIMEOUT之后才能自动删除,然后别的服务才能感知这种变化,但是为时已晚。
  其实,大公司有其繁荣之道,小公司也有其存亡之理。以现在公司的体量,这种方式应该是很够用了,目前主要的缺陷是服务挂掉后active节点虽然会自动消失,但是整个服务提供者的节点还存在着,对于洁癖人来说有点不能忍受……

  然后,我们在使用中还做了如下功能的封装:
  (1) 在客户端Watch节点的时候,允许提供一个回调函数,那么当感知到事件的时候,提供的回调函数会被最终调用,通过这种方式上面服务、节点的属性就可以大大扩充了,让ZooKeeper可以为业务提供一个集中式的配置管理,而且在配置变化的时候业务能够获得感知,并进行自动更新;
  (2) 支持某个服务下各个服务节点进行选主,成为主节点的服务可以正常对外服务,其他节点处于Standby模式,一旦主节点故障后他们会再次尝试选主操作;
  (3) 实现了分布式锁的功能。

更新
  在看了阿里中间件的博客后,对ZooKeeper()有了更多的一些认识,这里总结下来,大家在使用的时候需可能要注意到这些问题,评估对自己业务的影响。
  (1) 服务健康侦测
  在ZooKeeper中是通过客户端和注册中心定时发送heart_beat维持一种session机制来保持的,这只是一种基于网络链路正常的探测,并没有代表业务上服务的正常与否。这点只能通过业务调用方在业务层感知服务提供者是否正常,并且依次做出本地服务选择的调整(比如本地增加或者减少某些服务的权重值)。
  (2) 通知事件可能丢失
  ZooKeeper的通知是非持久的,如果需要对某个节点持续关注的话,通常来说都是经历:注册Watch、收到通知读取最新配置、再次注册Watch……这样的循环来操作的,人称ZooKeeper之所以不提供持久事件侦听乃是处于性能方面的考虑。不过这种设计是容易丢失通知的,比如在收到消息后进行读取操作的时候(还没来得及Watch),而此节点刚好又有了事件,那么此时的事件将会丢失,而这种情况,对于数据变更比较频繁的情形更容易发生。所以对数据准确性和一致性要求较高的服务,通常都会定期去拉取最新配置,以便补偿这种可能的事件丢失带来的问题,不过这也会对整个注册中心带来额外的负载。
  (3) 性能问题
  根据分布式系统的协议,ZooKeeper的任何操作必须得到集群中绝大多数节点的响应才算成功,所以ZooKeeper是无法通过横向扩充来实现性能提升的,如果集群的规模过大、时间更新频繁的话,据说性能问题会比较严重。像不支持持久事件侦听、侦听父节点不能得到子节点的子节点事件信息,都是为了性能方面做出的妥协。
  (4) 脑裂问题
  ZooKeeper遵循的是一致性协议,但是假设某个数据中心和注册中心通信异常的话,那么整个数据中心的服务就处于不可用的状态了,这其实就是CAP中的CP导致了A可用性无法满足。在服务本机房内本身是连通的,但是因为注册中心的问题导致服务不可用,显然是不优雅的。

本文完!

参考