ZooKeeper 深入浅出之三:Zookeeper 服务
ZooKeeper 是一个高可用的高性能调度服务。这一节我们将讲述他的模型、操作和接口。
数据模型 Data Model
ZooKeeper 包含一个树形的数据模型,我们叫做 znode。一个 znode 中包含了存储的数据和 ACL(Access Control List)。ZooKeeper 的设计适合存储少量的数据,并不适合存储大量数据,所以 znode 的存储限制最大不超过1M。
数据的访问被定义成原子性的。什么是原子性呢?一个客户端访问一个 znode 时,不会只得到一部分数据;客户端访问数据要么获得全部数据,要么读取失败,什么也得不到。相似的,写操作时,要么写入全部数据,要么写入失败,什么也写不进去。ZooKeeper 能够保证写操作只有两个结果,成功和失败。绝对不会出现只写入了一部分数据的情况。与 HDFS 不同,ZooKeeper 不支持字符的 append(连接)操作。原因是 HDFS 是被设计成支持数据流访问(streaming data access)的大数据存储,而 ZooKeeper 则不是。
我们可以通过 path 来定位 znode,就像 Unix 系统定位文件一样,使用斜杠来表示路径。但是,znode 的路径只能使用绝对路径,而不能想 Unix 系统一样使用相对路径,即 Zookeeper 不能识别 ../
和 ./
这样的路径。
节点的名称是由 Unicode 字符组成的,除了 zookeeper 这个字符串,我们可以任意命名节点。为什么不能使用 zookeeper 命名节点呢?因为 ZooKeeper 已经默认使用 zookeeper 来命名了一个根节点,用来存储一些管理数据。
请注意,这里的 path
并不是 URIs,在 Java API 中是一个 String
类型的变量。
Ephemeral znodes
我们已经知道,znode 有两种类型:ephemeral 和 persistent。在创建 znode 时,我们指定 znode 的类型,并且在之后不会再被修改。当创建 znode 的客户端的 session 结束后,ephemeral 类型的 znode 将被删除。persistent 类型的 znode 在创建以后,就与客户端没什么联系了,除非主动去删除它,否则他会一直存在。Ephemeral znode 没有任何子节点。
虽然 Ephemeral znode 绑定了客户端 session,但是对任何其他客户端都是可见的,当然是在他们的 ACL 策略下允许访问的情况下。
当我们在创建分布式系统时,需要知道分布式资源是否可用。Ephemeral znode 就是为这种场景应运而生的。正如我们之前讲述的例子中,使用 Ephemeral znode 来实现一个成员关系管理,任何一个客户端进程任何时候都可以知道其他成员是否可用。
Znode 的序号
如果在创建 znode 时,我们使用排序标志的话,ZooKeeper 会在我们指定的 znode 名字后面增加一个数字。我们继续加入相同名字的 znode 时,这个数字会不断增加。这个序号的计数器是由这些排序 znode 的父节点来维护的。
如果我们请求创建一个 znode,指定命名为 /a/b-
,那么 ZooKeeper 会为我们创建一个名字为 /a/b-3
的 znode。我们再请求创建一个名字为 /a/b-
的 znode,ZooKeeper 会为我们创建一个名字 /a/b-5
的 znode。ZooKeeper 给我们指定的序号是不断增长的。Java API 中的 create()
的返回结果就是 znode 的实际名字。
那么序号用来干什么呢?当然是用来排序用的!后面 《A Lock Service》 中我们将讲述如何使用 znode 的序号来构建一个 share lock。
观察模式 Watches
观察模式可以使客户端在某一个 znode 发生变化时得到通知。观察模式有 ZooKeeper 服务的某些操作启动,并由其他的一些操作来触发。例如,一个客户端对一个 znode 进行了 exists
操作,来判断目标 znode 是否存在,同时在 znode 上开启了观察模式。如果 znode 不存在,这 exists 将返回 false
。 如果稍后,另外一个客户端创建了这个 znode,观察模式将被触发,将 znode 的创建事件通知之前开启观察模式的客户端。我们将在以后详细介绍其他的操作和触发。
观察模式只能被触发一次。如果要一直获得 znode 的创建和删除的通知,那么就需要不断的在znode上开启观察模式。在上面的例子中,如果客户端还继续需要获得 znode 被删除的通知,那么在获得创建通知后,客户端还需要继续对这个 znode 进行 exists
操作,再开启一次观察模式。
在《A Configuration Service》中,有一个例子将讲述如何使用观察模式在集群中更新配置。
操作 Operations
下面的表格中列出了9种 ZooKeeper 的操作。
调用 delete
和 setData
操作时,我们必须指定一个 znode 版本号(version number),即我们必须指定我们要删除或者更新 znode 数据的哪个版本。如果版本号不匹配,操作将会失败。失败的原因可能是在我们提交之前,该 znode 已经被修改过了,版本号发生了增量变化。那么我们该怎么办呢? 我可以考虑重试,或者调用其他的操作。例如,我们提交更新失败后,可以重新获取 znode 当前的数据,看看当前的版本号是什么,再做更新操作。
ZooKeeper 虽然可以被看作是一个文件系统,但是由于 ZooKeeper 文件很小,所以没有提供像一般文件系统所提供的 open
、close
或者 seek
操作。
注意: 这里的 sync
操作与 POSIX 文件系统的 fsync()
操作是不同的。就像我们早前讲过的,ZooKeeper 的写操作是原子性的,一个成功的写操作只保证数据被持久化到大多数 ZooKeeper 的服务器存储上。所以读操作可能会读取不到最新状态的数据,sync
操作用来让 client 强制所访问的 ZooKeeper 服务器上的数据状态更新到最新状态。我们会在《一致性 Consistentcy》一节中详细介绍。
批量更新 Multiupdate
ZooKeeper 支持将一些原始的操作组合成一个操作单元,然后执行这些操作。那么这种批量操作也是具有原子性的,只可能有两种执行结果,成功和失败。批量操作单元中的操作,不会出现一些操作执行成功,一些操作执行失败的情况,即要么都成功,要么都失败。
Multiupdate 对于绑定一些结构化的全局变量很有用处。例如绑定一个无向图(undirected graph)。无向图的顶点(vertex)由 znode 来表示。添加和删除边(edge)的操作,由修改边的两个关联 znode 来实现。如果我们使用 ZooKeeper 的原始的操作来实现对边(edge)的操作,那么就有可能产生两个 znode 修改不一致的情况(一个修改成功,一个修改失败)。那么我们将修改两个 znode 的操作放入到一个 Multi 修改单元中,就能够保证两个 znode,要么都修改成功,要么都修改失败。这样就能够避免修改无向图的边时产生修改不一致的现象。
APIs
ZooKeeper 客户端使用的核心编程语言有 JAVA 和 C;同时也支持 Perl、Python 和 REST。执行操作的方式呢,分为同步执行和异步执行。我们之前已经见识过了同步的 Java API 中的 exists
。
public Stat exists(String path, Watcher watcher) throws KeeperException, InterruptedException
下面代码则是异步方式的 exists
:
public void exists(String path, Watcher watcher, StatCallback cb, Object ctx)
Java API 中,异步的方法的返回类型都是 void
,而操作的返回的结果将传递到回调对象的回调函数中。回调对象将实现 StatCallback
接口中的一个回调函数,来接收操作返回的结果。函数接口如下:
public void processResult(int rc, String path, Object ctx, Stat stat);
参数 rc
表示返回码,请参考 KeeperException
中的定义。在 stat
参数为 null 的情况下,非 0 的值表示一种异常。参数 path
和 ctx
与客户端调用的 exists
方法中的参数相等,这两个参数通常用来确定回调中获得 的响应是来至于哪个请求的。参数 ctx
可以是任意对象,只有当 path
参数不能消灭请求的歧义时才会用到。如果不需要参数 ctx
,可以设置为 null。
应该使用同步 API 还是异步 API 呢? 两种 API 提供了相同的功能,需要使用哪种 API 取决于你程序的模式。例如,你设计的程序模式是一个事件驱动模式的程序,那么你最好使用异步 API。异步 API 也可以被用在追求一个比较好的数据吞吐量的场景。想象一下,如果你需要得去大量的 znode 数据,并且依靠独立的进程来处理他们。如果使用同步 API,每次读取操作都会被阻塞住,直到返回结果。不如使用异步 API,读取操作可以不必等待返回结果,继续执行。而使用另外的线程来处理返回结果。
观察模式触发器 Watch triggers
读操作,例如:exists
、getChildren
、getData
会在 znode 上开启观察模式,并且写操作会触发观察模式事件,例如:create
、delete
和 setData
。ACL(Access Control List) 操作不会启动观察模式。观察模式被触发时,会生成一个事件,这个事件的类型取决于触发他的操作:
exists
启动的观察模式,由创建 znode,删除 znode 和更新 znode 操作来触发。getData
启动的观察模式,由删除 znode 和更新 znode 操作触发。创建 znode 不会触发,是因为 getData 操作成功的前提是 znode 必须已经存在。getChildren
启动的观察模式,由子节点创建和删除,或者本节点被删除时才会被触发。我们可以通过事件的类型来判断是本节点被删除还是子节点被删除:NodeChildrenChanged
表示子节点被删除,而NodeDeleted
表示本节点删除。
事件包含了触发事件的 znode 的 path,所以我们通过 NodeCreated
和 NodeDeleted
事件就可以知道哪个 znode 被创建了或者删除了。如果我们需要在 NodeChildrenChanged
事件发生后知道哪个子节点被改变了,我们就需要再调用一次 getChildren
来获得一个新的子节点列表。与之类似,在 NodeDataChanged
事件发生后,我们需要调用 getData
来获得新的数据。我们在编写程序时,会在接收到事件通知后改变 znode 的状态,所以我们一定要清楚的记住 znode 的状态变化。
ACLs 访问控制操作
znode 的创建时,我们会给他一个 ACL(Access Control List),来决定谁可以对 znode 做哪些操作。
ZooKeeper 通过鉴权来获得客户端的身份,然后通过 ACL 来控制客户端的访问。鉴权方式有如下几种:
- digest: 使用用户名和密码方式
- sasl: 使用 Kerberos 鉴权
- ip: 使用客户端的 IP 来鉴权
客户端可以在与 ZooKeeper 建立会话连接后,自己给自己授权。授权是并不是必须的,虽然 znode 的 ACL 要求客户端必须是身份合法的,在这种情况下,客户端可以自己授权来访问 znode。下面的例子,客户端使用用户名和密码为自己授权:
zk.addAuthInfo("digest", "tom:secret".getBytes());
ACL 是由鉴权方式、鉴权方式的 ID 和一个许可 (permession) 的集合组成。例如,我们想通过一个 ip 地址为 10.0.0.1 的客户端访问一个 znode。那么,我们需要为 znode 设置一个 ACL,鉴权方式使用IP鉴权方式,鉴权方式的 ID 为 10.0.0.1,只允许读权限。使用JAVA我们将像如下方式创建一个 ACL 对象:
new ACL(Perms.READ,new Id("ip", "10.0.0.1"));
所有的许可权限将在下表中列出。请注意,exists
操作不受 ACL 的控制,所以任何一个客户端都可以通过 exists
操作来获得任何 znode 的状态,从而得知 znode 是否真的存在。
在 ZooDefs.Ids
类中,有一些 ACL 的预定义变量,包括 OPEN_ACL_UNSAFE
,这个设置表示将赋予所有的许可给客户端(除了 ADMIN 的许可)。
另外,我们可以使用 ZooKeeper 鉴权的插件机制,来整合第三方的鉴权系统。
实现 Implementation
ZooKeeper 服务可以在两种模式下运行。在 standalone 模式下,我们可以运行一个单独的 ZooKeeper 服务器,我们可以在这种模式下进行基本功能的简单测试,但是这种模式没有办法体现 ZooKeeper 的高可用特性和快速恢复特性。在生产环境中,我们一般采用 replicated(复制)模式安装在多台服务器上,组建一个叫做 ensemble 的集群。ZooKeeper 在他的副本之间实现高可用性,并且只要 ensemble 集群中能够推举出主服务器,ZooKeeper 的服务就可以一直不终断。例如,在一个5个节点的 ensemble 中,容忍有2个节点脱离集群,服务还是可用的。因为剩下的3个节点投票,可以产生超过集群半数的投票,来推选一台主服务器。而6个节点的 ensemble 中,也只能容忍2个节点的服务器死机。因为如果3个节点脱离集群,那么剩下的3个节点无论如何不能产生超过集群半数的投票来推选一个主服务器。所以,一般情况下 ensemble 中的服务器数量都是奇数。
从概念上来看,ZooKeeper 其实是很简单的。他所做的一切就是保证每一次对 znode 树的修改,都能够复制到 ensemble 的大多数服务器上。如果非主服务器脱离集群,那么至少有一台服务器上的副本保存了最新状态。剩下的其他的服务器上的副本,会很快更新这个最新的状态。
为了实现这个简单而不平凡的设计思路,ZooKeeper 使用了一个叫做 Zab
的协议。这个协议分为两阶段,并且不断的运行在 ZooKeeper 上:
- 阶段 1:领导选举(Leader election)
Ensemble 中的成员通过一个程序来选举出一个首领成员,我们叫做 leader。其他的成员就叫做 follower。在大多数(quorum)follower 完成与 leader 状态同步时,这个阶段才结束。
- 阶段 2: 原子广播(Atomic broadcast)
所有的写入请求都会发送给 leader,leader 在广播给 follower。当大多数的 follower 已经完成了数据改变,leader 才会将更新提交,客户端就会随之得到 leader 更新成功的消息。协议中的设计也是具有原子性的,所以写入操作只有成功和失败两个结果。
如果 leader 脱离了集群,剩下的节点将选举一个新的 leader。如果之前的 leader 回到了集群中,那么将被视作一个 follower。leader 的选举很快,大概 200ms 就能够产生结果,所以不会影响执行效率。
Ensemble 中的所有节点都会在更新内存中的 znode 树的副本之前,先将更新数据写入到硬盘上。读操作可以请求任何一台 ZooKeeper 服务器,而且读取速度很快,因为读取是内存中的数据副本。
数据一致性 Consistency
理解了 ZooKeeper 的实现原理,有助于理解 ZooKeeper 如何保证数据的一致性。就像字面上理解的 leader
和 follower
的意思一样,在 ensemble 中 follower 的 update 操作会滞后于 leader 的 update完成。事实的结果使我们在提交更新数据之前,不必在每一台 ZooKeeper 服务器上执行持久化变更数据,而是仅需在主服务器上执行持久化变更数据。ZooKeeper客户端的最佳实践是全部链接到 follower 上。然而客户端是有可能连接到 leader 上的,并且客户端控制不了这个选择,甚至客户端并不知道连接到了 follower还是 leader。下图所示,读操作向 follower 请求即可,而写操作由 leader 来提交。
每一个对 znode 树的更新操作,都会被赋予一个全局唯一的 ID,我们称之为 zxid(ZooKeeper Transaction ID)。更新操作的ID按照发生的时间顺序升序排序。例如,z1 小于 z2,那么 z1 的操作就早于 z2 操作。
ZooKeeper 在数据一致性上实现了如下几个方面:
- 顺序一致性
从客户端提交的更新操作是按照先后循序排序的。例如,如果一个客户端将一个 znode z 赋值为 a,然后又将 z 的值改变成 b,那么在这个过程中不会有客户端在 z 的值变为 b 后,取到的值是 a。
- 原子性
更新操作的结果不是失败就是成功。即,如果更新操作失败,其他的客户端是不会知道的。
- 系统视图唯一性
无论客户端连接到哪个服务器,都将看见唯一的系统视图。如果客户端在同一个会话中去连接一个新的服务器,那么他所看见的视图的状态不会比之前服务器上看见的更旧。当 ensemble 中的一个服务器宕机,客户端去尝试连接另外一台服务器时,如果这台服务器的状态旧于之前宕机的服务器,那么服务器将不会接受客户端的连接请求,直到服务器的状态赶上之前宕机的服务器为止。
- 持久性
一旦更新操作成功,数据将被持久化到服务器上,并且不能撤销。所以服务器宕机重启,也不会影响数据。
- 时效性
系统视图的状态更新的延迟时间是有一个上限的,最多不过几十秒。如果服务器的状态落后于其他服务器太多,ZooKeeper 会宁可关闭这个服务器上的服务,强制客户端去连接一个状态更新的服务器。
从执行效率上考虑,读操作的目标是内存中的缓存数据,并且读操作不会参与到写操作的全局排序中。这就会引起客户端在读取 ZooKeeper 的状态时产生不一致。例如,A 客户端将 znode z 的值由 a 改变成 a′,然后通知客户端 B 去读取 z 的值,但是 B 读取到的值是 a,而不是修改后的 a′。为了阻止这种情况出现,B 在读取 z 的值之前,需要调用 sync
方法。sync
方法会强制 B 连接的服务器状态与 leader 的状态同步,这样 B 在读取 z 的值就是 A 重新更改过的值了。
注意: sync
操作只在异步调用时才可用,原因是你不需要等待操作结束再去执行其他的操作。因此,ZooKeeper 保证所有的子操作都会在 sync
结束后再执行,甚至在 sync
操作之前发出的操作请求也不例外。
会话 Sessions
ZooKeeper 的客户端中,配置了一个 ensemble 服务器列表。当启动时,首先去尝试连接其中一个服务器。如果尝试连接失败,那么会继续尝试连接下一个服务器,直到连接成功或者全部尝试连接失败。
一旦连接成功,服务器就会为客户端创建一个会话(session)。session 的过期时间由创建会话的客户端应用来设定,如果在这个时间期间,服务器没有收到客户端的任何请求,那么 session 将被视为过期,并且这个 session 不能被重新创建,而创建的 ephemeral znode 将随着 session 过期被删除掉。在会话长期存在的情况下,session 的过期事件是比较少见的,但是应用程序如何处理好这个事件是很重要的。(我们将在《The Resilient ZooKeeper Application》中详细介绍)
在长时间的空闲情况下,客户端会不断的发送 ping 请求来保持 session。(ZooKeeper的客户端开发工具的 liberay 实现了自动发送 ping 请求,所以我们不必去考虑如何维持 session)ping 请求的间隔被设置成足够短,以便能够及时发现服务器失败(由读操作的超时时长来设置),并且能够及时的在 session 过期前连接到其他服务器上。
容错连接到其他服务器上,是由 ZooKeeper 客户端自动完成的。重要的是在连接到其他服务器上后,之前的 session 以及 epemeral 节点还保持可用状态。
在容错的过程中,应用将收到与服务断开连接和连接的通知。Watch 模式的通知在断开链接时,是不会发送断开连接事件给客户端的,断开连接事件是在重新连接成功后发送给客户端的。如果在重新连接到其他节点时,应用尝试一个操作,这个操作是一定会失败的。对于这一点的处理,是一个 ZooKeeper 应用的重点。(我们将在《The Resilient ZooKeeper Application》中讲述)
时间 Time
在 ZooKeeper 中有一些时间的参数。tick
是 ZooKeeper 的基础时间单位,用来定义 ensemble 中服务器上运行的程序的时间表。其他时间相关的配置都是以 tick
为单位的,或者以 tick
的值为最大值或者最小值。例如,session 的过期时间在 2 ticks 到 20 ticks 之间,那么你再设置时选择的 session 过期时间必须在2和20之间的一个数。
通常情况 1 tick 等于2秒。那么就是说 session 的过期时间的设置范围在4秒到40秒之间。在 session 过期时间的设置上有一些考虑。过期时间太短会造成加快物理失败的监测频率。在组成员关系的例子中,session 的过期时间与从组中移除失败的成员花费的时间相等。如果设置过低的 session 过期时间,那么网络延迟就有可能造成非预期的 session 过期。这种情况下,就会出现在短时间内一台机器不断的离开组,然后又从新加入组中。
如果应用需要创建比较复杂的临时状态,那么就需要较长的 session 过期时间,因为重构花费的时间比较长。有一些情况下,需要在 session 的生命周期内重启,而且要保证重启完后 session 不过期(例如,应用维护和升级的情况)。服务器会给每一个 session 一个 ID 和密码,如果在连接创建时,ZooKeeper 验证通过,那么 session 将被恢复使用(只要 session 没过期就行)。所以应用程序可以实现一个优雅的关机动作,在重启之前,将 session 的 ID 和密码存储在一个稳定的地方。重启之后,通过 ID 和密码恢复 session。
这仅仅是在一些特殊的情况下,我们需要使用这个特性来使用比较长的 session 过期时间。大多数情况下,我们还是要考虑当出现非预期的异常失败时,如何处理 session 过期,或者仅需要优雅的关闭应用,在 session 过期前不用重启应用。
通常情况也越大规模的 ensemble,就需要越长的 session 过期时间。Connetction Timeout、Read Timeout 和 Ping Periods 都由一个以服务器数量为参数的函数计算得到,当 ensemble 的规模扩大,这些值需要逐渐减小。如果为了解决经常失去连接而需要增加 timeout 的时长,建议你先监控一下 ZooKeeper 的 metrics,再去调整。
状态 States
ZooKeeper 对象在他的生命周期内会有不同的状态,我们通过 getState()
来获得当前的状态。
public States getState()
状态是一个枚举类型的数据。新构建的 ZooKeeper 对象在尝试连接 ZooKeeper 服务时的状态是 CONNECTING
,一旦与服务建立了连接那么状态就变成了 CONNECTED
。
客户端可以通过注册一个观察者对象来接收 ZooKeeper 对象状态的迁移。当通过 CONNECTED
状态后,观察者将接收到一个 WatchedEvent 事件,他的属性 KeeperState 的值是 SyncConnected
。
注意: 观察者有两个职能:一是接收 ZooKeeper 的状态改变通知;二是接收 znode 的改变通知。ZooKeeper 对象构造时传递进去的 watcher 对象,默认是用来接收状态改变通知的,但是 znode 的改变通知也可能会共享使用默认的 watcher 对象,或者使用一个专用的 watcher。我们可以通过一个 Boolean 变量来指定是否使用共享默认 watcher。
ZooKeeper 实例会与服务连接断开或者重新连接,状态会在 CONNECTING
和 CONNECTED
之间转换。如果连接断开,watcher 会收到一个断开连接事件。请注意,这两个状态都是 ZooKeeper 实例自己初始化的,并且在断开连接后会自动进行重连接。
如果调用了 close()
或者 session 过期,ZooKeeper 实例会转换为第三个状态 CLOSED
,此时在接受事件的 KeeperState 属性值为 Expired
。一旦 ZooKeeper 的状态变为 CLOSED
,说明实例已经不可用(可以通过 isAlive()
来判断),并且不能再被使用。如果要重新建立连接,就需要重新构建一个 ZooKeeper 实例。