ZooKeeper 入门
ZooKeeper 是由雅虎开源的一个分布式协调服务,它是 Google Chubby 的开源实现,它对外提供一组简单的原语,分布式应用可以通过使用这些原语来实现一些更高级的服务,比如:数据发布与订阅(配置中心)、负载均衡、命名服务、分布式协调与通知、集群管理、Master 选举、分布式锁和分布式队列等。
ZooKeeper 的基本概念
集群角色
ZooKeeper 集群有三种角色,Leader、Follower 和 Observer。一个 ZooKeeper 集群在同一时刻只有一个 Leader。Leader 会在一次选举中产生,为客户端提供读和写服务。其他的节点都是 Follower 或 Observer,它们只提供读服务,并负责把写请求转发给 Leader。
ZooKeeper 增加 Observer 角色是为了能在不影响集群写性能的情况下扩展集群,从而提高集群的读性能。由于写入操作需要集群中超过一半的 Follower 节点更新成功才会真正写入,为了提高集群读性能而增加 Follower 节点会因为网络消耗等原因导致投票的成本增加,从而造成集群的写入性能下降,而 Observer 与 Follower 不同的是它不参与 Leader 的选举过程,也不参与写操作的过半写成功策略。Observer 另外的一个优势就是,由于不参与选举和投票,所以即使它们从集群中断开也不会影响集群的可用性。
数据模型
ZooKeeper 的数据模型是一个 ZNode 节点树,与标准的文件系统非常类似,该数据模型的命名空间使用斜杠(/)进行分隔,每一个节点都由路径来标识。
会话
会话指的是客户端与服务器之间的一个 TCP 长连接,客户端与服务端的一切交互都是通过这个长连接进行的。会话会在客户端身份验证失败或与服务器断开连接后,在设置的 sessionTimeout 时间内没有重新建立连接后失效。
节点
节点在 ZooKeeper 中有两层含义,一种是集群中的一台机器,我们称为机器节点;一种是 ZooKeeper 数据模型中的数据单元,我们称为数据节点(ZNode)。
与文件系统不同的是,ZNode 既是一个文件也是一个文件夹,它可以有与之相关联的子节点,同时它自身还存储数据,并且它还维护着一个 Stat 结构的数据,里面存储着一些状态信息,如数据的版本号、ACL 的版本号等,Stat 结构自身也包含一个时间戳。每次 ZNode 节点数据更新,相应的版本号会递增,客户端读取数据时,也会获取到数据的版本号。每个 ZNode 节点数据的读写都是原子性的,读操作将读取整个节点的数据,写操作也将替换整个节点的数据,同时每个节点都有一个 ACL(访问控制列表),用来说明谁可以做什么。
ZNode 节点类型可以细分为四种,持久节点(Persistent Nodes)、持久连续节点(Persistent Sequential Nodes)、临时节点(Ephemeral Nodes)和临时连续节点(Ephemeral Sequential Nodes)。持久节点是指一旦这个 ZNode 创建成功,除非主动移除,否则这个节点会一直保存在 ZooKeeper 上。而临时节点会在创建该节点的会话处于活动状态时存在,在会话结束时删除,并且临时节点不能有子节点。至于持久连续节点和临时连续节点,除了保持上述特性外,还会在节点路径末尾附加一个单调递增的计数器,这个计数器由它的父节点维护,相对于父节点来说是唯一的。
操作
ZooKeeper 设计的目标之一就是将复杂的操作简化,对外提供简单的接口,因此它仅支持以下操作:
接口 | 作用 | 说明 |
---|---|---|
create | 创建一个 ZNode 节点 | 如果创建子节点,则父节点必须已经存在 |
delete | 删除一个 ZNode 节点 | 该节点必须没有子节点 |
exists | 检查节点是否存在,如果存在则返回节点信息 | |
getData/setData | 获取/设置节点的数据 | |
getACL/setACL | 获取/设置节点的 ACL | |
getChildren | 获取子节点列表 | |
sync | 强制将客户端所访问的 ZooKeeper 服务器上的数据同步到最新状态 | ZooKeeper 的写操作是原子性的,一个成功的写操作只保证数据被持久化到大多数 ZooKeeper 的服务器存储上,所以读操作可能会读取不到最新的数据,这时通过 sync 操作可以让客户端所访问的数据与 Leader 强制同步到最新状态 |
Watcher
ZooKeeper 允许客户端在指定的节点上注册一些 Watcher,在某些特定的事件触发时 ZooKeeper 会通知这些客户端。Watcher 设置一次只会触发一次,在收到通知后如果希望继续观察,则需要再次设置 Watcher。
我们可以在执行读操作 exists、getData 和 getChildren 时设置 Watcher,在执行写操作 create、delete 和 setData 时将会触发 Watcher 事件,当然也可以在执行写操作时选择不触发。
当观察的节点被创建、删除或其数据发生改变时,设置在 exists 上的 Watcher 会被触发;当观察的节点被删除或其数据发生改变时,设置在 getData 上的 Watcher 会被触发;当观察的节点的子节点被创建、删除或者节点自身被删除时,设置在 getChildren 上的 Watcher 会被触发,可以通过 Watcher 事件的类型来判断被删除的是子节点还是它自身。
由于事件包含了触发事件的 ZNode 的 path,所以我们可以通过 NodeCreated 和 NodeDeleted 事件知道是哪个节点被创建或删除,如果我们想知道 NodeChildrenChanged 事件发生后哪个子节点被改变了,就需要调用 getChildren 来获取一个新的子节点列表。类似的,在 NodeDataChanged 事件发生后,我们需要调用 getData 来获取新的数据。
关于 Watcher 需要注意的是,因为是它一次性触发,并且设置 Watcher 和事件通知之间存在延迟,所以可能会发生收到通知和再次设置 Watcher 时节点已经发生了多次变化的情况。同时,如果客户端与服务器断开,在重新建立连接之前客户端将不会获得任何通知。
ACL
ZooKeeper 使用 ACL 来控制对 ZNode 访问,ACL 与 UNIX 文件访问权限很像,但不同的是 ZooKeeper 的节点不受用户、组和其他这种范围的限制,ZooKeeper 没有 ZNode 所有者的概念,同时 ACL 仅适用于特定的节点,子节点不会继承父节点的 ACL。比如:设置 /app 只能通过 ip 地址 172.16.16.1 读取,而 /app/status 设置的是 world 可读的,那么任何人都可以读取 /app/status。
ACL 由鉴权方式、鉴权方式的 ID 和一个 permission 的集合组成。比如:我们想设置通过 ip 地址为 10.0.0.1 的客户端读取一个 ZNode,那么我们选择鉴权方式为 IP,鉴权方式的 ID 为 10.0.0.1,只允许读权限。在 Java 中类似下面的代码:
1 | new ACL(Perms.READ, new Id("ip", "10.0.0.1")); |
ZooKeeper 内置了四种鉴权方式,包括 world、auth、digest 和 ip。
ACL 鉴权方式 | ACL ID(授权对象) | 说明 |
---|---|---|
world | anyone | 表示任何人都有权限 |
auth | 不使用 ID | 任何通过身份验证的客户端都有权限 |
digest | MD5(username:password) | 以明文的形式发送用户名和密码进行身份验证,在 ACL 中使用时,表达式形式为 username:BASE64(SHA1(username:password)) |
ip | ip 地址 | 在 ACL 中表达式为 addr/bits 。可以设置具体的 IP,也可以是 IP/bits 的格式,即 IP 转换为二进制,匹配前 bits 位,如 192.168.0.0/16 匹配 192.168.*.* |
下面列出的是所有的 permission。需要注意的是,exists 不受 ACL 的控制,任何客户端都可以使用 exists 操作来获取 ZNode 的状态。
ACL 权限 | 操作 |
---|---|
CREATE | 创建节点 |
READ | getData、getChildren |
WRITE | setData |
DELETE | 删除节点,可以删除子节点(仅下一级节点) |
ADMIN | setACL |
ZooKeeper 运行模式
ZooKeeper 有两种运行模式,独立模式(standalone mode)和复制模式(replicated mode)。
独立模式下只有一个 ZooKeeper 服务器,比较适合测试环境,但是不能保证高可用和恢复性。在生产环境中通常使用复制模式,将 ZooKeeper 运行在一个集群上,这个集群被称为一个集合体(ensemble)。
ZooKeeper 通过复制来实现高可用性,只要集合体中半数以上的的机器处于可用状态,ZooKeeper 集群就能提供服务。比如:在一个有 5 个节点的集群中,有 2 个节点脱离集群,则服务还是可用的,因为剩下的 3 个节点仍然能够产生超过集群半数的投票来选举 Leader。而在 6 个节点的集群中最多也只能容忍 2 个节点宕机,因为超过 2 个节点离线后,集群就无法产生超过半数的投票了。所以 ZooKeeper 集群的节点数量一般都是奇数。
ZooKeeper 一致性原理
ZooKeeper 的核心是原子广播协议,通过该协议保证了各个服务器之间的同步,该协议有两种模式,恢复模式和广播模式。
在 ZooKeeper 服务刚启动、Leader 崩溃或者集群半数节点脱离集群时会进入恢复模式,当领导者被重新选举出来,且大多数服务器完成了和 Leader 的同步之后,恢复模式结束。
一旦 Leader 已经和大多数 Follower 完成了状态同步后,它就可以开始广播消息了。这时如果一个新的节点加入,它会在恢复模式下启动,发现 Leader 并和 Leader 进行状态同步,等到同步结束,它也会参与到消息广播中。ZooKeeper 服务一直维持在广播状态,直到 Leader 崩溃或者大多数 Follower 离线。
消息广播模式很像分布式事务中的 2pc。在该模式中,ZooKeeper 所有的事务请求都会交给 Leader 来处理,再由 Leader 广播给 Follower,当超过半数的 Follower 已经完成了数据的更新,Leader 才会将更新提交。
应用场景
ZooKeeper 是一个分布式数据管理和协调框架,采用 ZAB 一致性协议来保证分布式环境中数据的一致性,这样的特性使它成为解决分布式一致性问题的利器。
数据发布与订阅(配置中心)
发布与订阅模型,也就是所谓的配置中心。发布者将数据发布到 ZooKeeper 的数据节点上,供订阅者动态的获取数据,从而实现配置的集中式管理和动态更新。常见的比如全局的应用配置信息,服务框架的服务地址列表等都适用。
将应用中用到的一些配置信息放到 ZooKeeper 进行集中式的管理,这类场景通常是:应用启动时主动获取一次配置,同时在节点上注册 Watcher,在配置变更时会通知客户端,这样就可以再次获取最新的配置信息了。
负载均衡
这里所说的负载均衡指的是软负载均衡,其中比较典型的就是消息中间件的生产者和消费者的负载均衡。
命名服务
在分布式系统中,通过命名服务,客户端可以通过指定的名称来获取资源或者服务的地址,服务的提供者等信息,被命名的实体通常可以是集群中的机器,提供服务的地址、远程对象等,这些都可以称为名字(Name)。其中最常见的就是分布式服务框架中的服务地址列表,通过调用 ZooKeeper 的创建节点 API 就能够很容易地创建一个全局唯一的 path,这个 path 就可以作为一个名称。
在 Dubbo 中,服务提供者会在启动时,会向 ZooKeeper 上的指定节点 /dubbo/${serviceName}/providers 写下自己的 URL 地址,这就完成了服务的发布。
分布式通知与协调
利用 ZooKeeper 的 Watcher 注册和异步通知机制,可以很好的实现分布式环境中不同系统之间的通知与协调,实现对数据变更的实时处理。
集群管理与 Master 选举
集群管理通常存在一个集群监控系统,用来实时监测集群机器的状态。过去的做法通常是通过某种手段,比如通过 ping 来定时检测每台机器,这种做法可行但是不够灵活,且存在一定的时延。而通过 ZooKeeper 的 Watcher 和临时节点这两个特性,就可以实时的监控集群。例如:监控系统在 /clusterServers 节点上注册了 Watcher,以后每动态增加一台机器,就往 /clusterServers 节点下创建一个临时节点 /clusterServers/${hostname},这样就能够实时知道集群机器的增减情况了。
Master 选举则是 ZooKeeper 在分布式环境中最经典的应用场景了,很多时候相同的业务应用分布在不同的机器上,而有些业务逻辑往往只需要让集群中的某台机器执行,其他机器共享结果即可。此时可以利用在同一时刻,同时有多个客户端请求创建同一个节点时,ZooKeeper 保证最终只会有一个客户端的请求能够创建成功的特性来选出一台 Master。
在动态 Master 选举场景中,则可以利用 ZooKeeper 的临时顺序节点。比如,同样是多个客户端同时提交创建节点请求,但是由于创建的是顺序节点,所以所有的请求都能够成功,但是创建出来的节点会类似这种:/currentMaster/{sessionId}-1,/currentMaster/{sessionId}-2,/currentMaster/{sessionId}-3,这样每次选取序号最小的那台机器作为 Master,如果这台机器挂了,由于是临时节点,在会话结束就会自动删除,所以此时的 Master 就是之后的那台机器了。
分布式锁
通过 ZooKeeper 实现的分布式锁服务主要有两种,一种是独占锁,一种是时序锁。
独占锁就是所有试图获取这个锁的客户端,最终只有一个能够成功获得这把锁。通常的做法是将 ZooKeeper 上的一个 ZNode 看作一把锁,通过创建临时节点来实现获取锁的操作,而没有获取到锁的客户端需要在该临时节点上通过 exists 注册 Watcher,当获取锁的客户端因为宕机或者主动删除临时节点释放锁时,其他客户端就可以收到通知,从而重新发送创建临时节点的请求来继续竞争锁。
时序锁其实可以看作是一种改进的独占锁,它通过控制时序来避免惊群效应。具体的思路就是,所有试图获取锁的客户端会在一个已经存在的节点下创建临时顺序节点,这样所有的客户端都能够成功创建临时顺序节点,然后客户端通过调用 getChildren 来获取父节点下的子节点列表(此时不要设置任何 Watcher,避免惊群效应),如果发现自己创建的子节点的 path 中的序号是最小的,那么就认为该客户端成功获取到了锁;如果发现自己创建的节点的 path 中的序号不是最小的,说明还没有获取到锁,此时客户端需要找到比自己小的那个相邻节点,然后对其调用 exists,同时注册监听事件。之后,如果被监听的节点被删除,则客户端会收到通知,此时再次通过 getChildren 来判断序号大小,如果自己创建的节点的 path 中的序号是最小的,那么就获取到了锁,否则重复之前的步骤。
分布式队列
通过 ZooKeeper 实现的分布式队列也有两种,一种是常规的先进先出队列,另一种则要等待所有的队列成员都准备就绪时才会统一按顺序执行的队列。
FIFO 队列的实现与分布式锁服务中控制时序的实现原理一致。而第二种队列,其实是第一种的增强。通常可以在 /queue 这个节点下预先建立一个子节点 /queue/num,赋值为 n 表示队列的大小(也可以在 /queue 上直接赋值)。之后每次有队列成员加入,就在 /queue 下创建临时有序节点,然后判断一下当前队列是否已经装满来决定是否可以按时序执行处理。这种用法的典型场景是:在分布式环境中,一个大任务需要很多子任务完成(或条件就绪)的情况下才能进行。
ZooKeeper 与 CAP
ZooKeeper 是满足 CP 特性的,即任何时刻对 ZooKeeper 的访问请求都能够得到最新的数据,同时对于网络分割具备容错性,但是它不保证每次请求的可用性,在一些极端情况下,ZooKeeper 会丢弃一些请求,客户端需要重新请求才有可能获得结果。但是别忘了,ZooKeeper 是分布式协调服务,它的职责就是保证数据在其管辖下的所有服务之间保持同步、一致,所以也就不难理解它为什么被设计成 CP 而不是 AP 的了。
在一致性上,有人认为 ZooKeeper 提供的是强一致性(通过 sync 操作),有人认为它只满足单调一致性(更新时的过半写成功策略),还有人认为它是最终一致性的(顺序一致性)。
最好不要用作服务发现
在 2010 年的 11 月份,ZooKeeper 从 Apache Hadoop 的子项目发展成为 Apache 的顶级项目。到了 2011 年,阿里开源了 Dubbo,为了剥离与阿里内部系统的关系,更好的开源,Dubbo 选择了 ZooKeeper 作为其注册中心,但其实早在 2008 年的时候淘宝就已经有了自己的服务注册中心 ConfigServer。经过长时间的实践,现在普遍认为 ZooKeeper 用作服务发现并不是最佳的选择,因为注册中心其实应该是一个 AP 系统。关于这方面可以参考《Eureka! Why You Shouldn’t Use ZooKeeper for Service Discovery》这篇文章。