假设有一组服务器用户为客户端提供某种服务。我们希望每个客户端能找到其中一台服务器,这样他们就可以使用这项服务。在这个例子中,一个挑战是如何维护这组服务器觉得成员列表。
这组服务器的成员列表显然不能存储在网络中的单个节点上,否则该节点的故障将意味着整个系统的故障(我们希望这个成员列表时高度可用的)。我们先假设已经有了一种可靠的方法来解决成员列表的存储问题。接下来,如果其中有一台服务器出现故障,我们需要解决如何从服务器成员列表将它删除的问题。某个进程需要来负责删除故障服务器,但注意不能由故障服务器自己来完成,因为故障服务器已经不再运行!
我们描述的不是一个被动的分布式数据结构,而是一个主动的,能够在某个外部事件发生时发生修改数据项状态的数据结构,ZooKeeper 提供给了这种服务。
理解ZooKeeper 的一种方式就是将其看成一个具有高可用性特征的文件系统。这个文件系统中没有文件和目录。而是统一使用“节点”(node) 的概念,称为 znode 。znode 既可以作为保存数据的容器(如同文件),也可以作为保存其他 znode 的容器(目录)。所有的 znode 构成了一个层次化的命名空间,一种自然的建立组成员列表的方式就是利用这种层次结构, 创建一个以组名为节点名的 znode 作为父节点,然后以组成员(服务器名)为节点名来创建作为子节点的 znode。如下图所示:
在这个实例中,我们没有在任何 znode 中存储数据,但在一个真实的应用中,你可以想象将成员相关的数据存储在它们的 znode 中,例如主机名。
ZooKeeper 是一个具有高可用性的高性能协调服务。下面将从三个方面来了解这个服务:模型、操作和实现 。
1. ZooKeeper 维护这一个树形层次结构
树中的节点被称为 znode。znode 可以用于存储数据,并且有一个与之相关联的 ACL。ZooKeeper 被设计来实现协服务(这类服务通常使用小数据文件),而不是用于大容量数据存储,因此一个 znode 能存储的数据被限制在 1 MB 以内。
2. ZooKeeper 的数据访问具有原子性
客户端在读取一个 znode 的数据时,要么读到所有的数据,要么读操作失败,不会只读到部分数据。同样,一个写操作将替换 znode 存储的所有数据。ZooKeeper 会保证写操作不成功就失败,不会出现部分写之类的情况,也就是说不会存在只保存客户端所写部分数据的情况。ZooKeeper 不支持添加操作。这些特征都输与 HDFS 所不同的。HDFS 被设计用于大容量数据存储,支持流式数据访问和添加操作。
3. znode 通过路径被引用
想 Unix 中的文件系统路径一样,在 ZooKeeper 中路径被表示成用斜杠分割的 Unicode 字符串。与 Unix 中的文件系统路径不同的是,ZooKeeper 中的路径必须是绝对路径,也就是说每条路径必须从一个斜杠字符开始。此外,所有的路径表示必须是规范的,即每条路径只有唯一的一种表示方法,不支持路径解析,例如在 Unix 中,一个具有路径 /a/b 的文件也可以通过路径 /a/./b 来表示。原因在于 “.” 在 Unix 的路径中表示当前目录。在 ZooKeeper 中 “.” 不具备这样的特殊含义,这样表示的路径名是不合法的。
在 ZooKeeper 中,路径由 Unicode 字符串构成,并且有一些限制,字符串 “zookeeper ” 是一个保留词,不能将它作为路径表示中的一部分。需要注意的是,Zookeeper 使用 /zookeeper 子树来保存管理信息,例如关于配额的信息。
znode 有两种类型:短暂的和持久的。znode 的类型在创建时被确定并且之后不能再修改。在创建短暂 znode 的客户端会话结束时,Zookeeper 会将将短暂的 znode 删除。相比之下,持久 znode 不依赖与客户端会话,只有当客户端(不一定是创建它的那个客户端)明确要删除该持久 znode 时才会被删除。短暂 znode 不可以有子节点,即使是短暂子节点。
虽然每个短暂 znode 都会被绑定到一个客户端会话,但它们对所有的 客户端还是可见的(当然,还是要符合其 ACL 的定义)。
对于那些需要知道特定时刻有哪些分布式资源可用的应用来说,使用短暂 znode 是一种理想的选择。之前提到的例子就是用了短暂 znode 来实现一个组成员管理服务,让任何进程都知道在特定的时刻有哪些组成员可用。
顺序 (sequential) znode 是指名称中包含 Zookeeper 指定顺序号的 znode 。如果在创建 znode 时设置了顺序标识,那么该 znode 名称之后便会附加一个值,这个值是由一个单调递增的计数器(由父节点维护)所添加的。计数器会给出一个更大的值来保证 znode 名称的唯一性。在 Java 的 API 中,顺序 znode 的实际路径会作为 create() 调用的返回值被传回到客户端。
在一个分布式系统中,顺序号可以被用于为所有的时间进行全局排序,这样客户端就可以通过顺序号来推断事件的顺序。今后的共享锁就是利用该原理。
znode 以某种方式发生改变时,“观察”机制可以让客户端得到通知,可以针对 Zookeeper 服务的操作来设置观察,该服务的其他才哟可以触发观察。例如客户端可以对一个 znode 调用 exists 操作,同时设定一个观察。如果这个 znode 不存在,则客户端所调用的 exists 操作将返回 false。如果一段时间之后,另外一个客户端创建创建这个 znode,则这个观察就会被触发,通知前一个客户端这个 znode 被创建。
观察只能被触发一次。为了能够多次收到通知,客户端需要重新注册所需要的观察。如果客户端希望收到更多 znode 是否存在的通知(例如在这个 znode 被删除时也能收到通知),则需要再次调用 exists 操作来设定一个新的观察。
Zookeeper 中有9中基本操作
操作 | 描述 |
---|---|
create | 创建一个 znode (必须要有父节点) |
delete | 删除一个 znode (该 znode不能有任何子节点) |
exists | 测试一个 znode 是否存在并且查询它的元数据 |
getACL,setACL | 获取/设置一个 znode 的 ACL |
getChildren | 获取一个 znode 的子节点列表 |
getData,setData | 获取/设置一个 znode 所保存的数据 |
sync | 将客户端的 znode 视图与 Zookeeper 同步 |
Zookeeper 中的更新操作时有条件的,在使用 delete 或 setData 操作时必须提供被更新 znode 的版本号(可以通过 exists 操作获得)。如果版本号不匹配,则更新操作会失败。更新操作时非阻塞操作,因此一个更新失败的客户端(由于其他进程同时在更新同一个 znode)可以决定是否重试,或执行其他操作,并不会因此而阻塞其他进程的执行。
虽然 Zookeeper 可以被看作是一个文件系统,但出于简单性的需求,有一些文件系统的基本操作被它摒弃了。由于 Zookeeper 中的文件较小并且总是被整体读写,因此没有必要提供打开、关闭或查找操作。
Zookeeper 中有一个被称为 multi 的操作,用于将多个基本操作集合成一个操作单元,并确保这些基本操作同时被成功执行,或者同时失败,不会发生其中部分基本操作被成功执行而其他基本操作失败的情况。
对于 Zookeeper 客户端来说,主要由两种语言绑定 (binging) 可以使用:Java 和 C;当然也可以使用 Perl、Python 和 REST 的 contrib 绑定。对于每一种绑定语言来说,在执行操作时都可以选择同步执行或异步执行。
在 exists、getChildren 和 getData 这些读操作上可以设置观察,这些观察可以被写操作 create、delete 和 setData 触发。ACL 相关的操作不参与触发任何观察。当一个观察被触发时会产生一个观察时间,这个观察和触发它的操作共同决定着观察事件的类型。
每个 znode 被创建时都会有一个 ACL 列表,用于决定谁可以对它执行何种操作。
ACL 依赖于 Zookeeper 的客户端身份验证机制。Zookeeper 提供了一下几种身份验证方式:
在建议一个 Zookeeper 会话之后,客户端可以对自己进行身份验证。虽然 znode 的 ACL 列表会要求所有的客户端是经过验证的,但 Zookeeper 的身份验证过程却是可选的,客户端必须自己进行身份验证来支持对 znode 的访问。
Zookeeper 服务有两种不同的运行模式。一种是“独立模式 ”(standalone mode),即只有一个 Zookeeper 服务器。这种模式比较简单,比较适合用于测试环境(甚至可以在单元测试中采用),但是不能保证搞可用性和可恢复性。
在生产环境中的 Zookeeper 通常是以 “复制模式” (replicated mode)运行与一个计算机集群上,这个计算机集群被称为一个“集合体” (ensemble)。Zookeeper 通过复制来实现高可用性只要集合体中半数以上的机器处于可用的状态,它就能提供服务。
从概念上来说,Zookeeper 是非常简单的:它所做的就是确保对 znode 树的每一个修改都会被复制到集合体中超过半数的机器上。如果少于半数的机器出现故障,则最少有一台机器会保存最新的状态,其余的副本会最终更新到这个状态。
然而这个简单的想法的实现却不简单。Zookeeper 使用了 Zab 协议,该协议包括了两个可以无限重复的阶段:
阶段1:领导者选举
集合体中的所有的机器通过一个选择过程来选择出一台被称为 “领导者” (leader) 的机器,其他的机器被称为“跟随者”(follower)。一旦半数以上(或者指定数量)的跟随者已经将其状态与领导者同步,则表明这个阶段已经完成。
阶段2:原子广播
所有的写操作都会被转发给领导者,再由领导者将更新广播给 跟随者。当半数以上的跟随者已经将修改持久化之后,领导者才会提交这个更新,然后客户端才会收到一个更新成功的响应。这个用来达成共识的写译被设计成具有原子性,修改那么成功要么失败。
如果领导者出现故障,其余的机器会选举出另外一个领导者,并和新的领导者一起继续提供服务。随后,如果之前的领导者恢复正常,会成为一个跟随者。领导者选举的过程是非常快的,根据一个已公布的结果来看,只需要大约 200 毫秒,因此在领导者选举的过程中不会出现系统性能的明显降低。
在更新内存中的 znode 树之前,集合体中的所有机器都会先将更新写入磁盘。任何一台机器都可以为度请求提供服务,并且由于读请求只涉及内存检索,因此非常快。
理解 Zookeeper 的实现有主语理解其服务所提供的一致性保证。在集合体中所使用的术语“领导者”和 “跟随者”是恰当的,他们表名一个跟随者可能之后与领导者几个更新。这也表名在一个修改被提交之前,只需要集合体中半数以上机器已经将该修改持久化即可。对 Zookeeper 来说,理想的情况就是将客户端都连接到与领导者状态一致的服务器上,每个客户端都有困难被连接到领导者,但客户端对此无法控制,甚至它自己都无法知道是否连接到领导者。
每一个对 znode 树的更新都被赋予一个全局唯一的 ID,称为 zxid (代表 “Zookeeper Transaction ID”)。Zookeeper 要求对所有的更新进行编号并排序,它决定了分布式系统的执行顺序,如果 zxid z1 小于 z2,则 z1 一定发生在 z2 之前。
在 Zookeeper 的设计中,以下几点考虑保证了数据的一致性。
1. 顺序一致性
来自任意特定客户端的更新都会按其发送顺序被提交。也就是说,如果一个客户端将 znode z 的值更新为 a,在之后的操作中,它又将 z 的值更新为 b ,则没有客户端能够在看到 z 的值是 b 之后再看到值 a(如果没有其他对于 z 的更新)。
2. 原子性
更改要么成功,要么失败,不会存在部分成功或失败的结果。如果失败了,则不会有客户端看到这个更新的结果。
3. 单一系统映像
客户端会看到 Zookeeper 服务的相同的视图,而无论它们连到具体哪一个服务器上。这意味着,如果一个客户端在用一个会话中连接到一台新的服务器,它所看到的系统状态不会比在之前服务器上所看到的更老。当一台服务器出故障,导致它的一个客户端需要尝试连接集合体中其他的服务器时,所有状态滞后于故障服务器的服务器都不会接受该连接请求,除非这些服务器将状态更新至故障服务器的水平。
4. 持久性(可靠性)
一旦一次更改请求被应用,更改的结果就会被持久化,直到被下一次更改覆盖。这表明更新不会受到服务器故障的影响。
5. 及时性
任何客户端所看到的滞后系统视图都是有限的,不会超过几十秒,即客户端看到的系统视图在一定的时间范围内总是最新的。这意味着与其允许一个客户端看到非常陈旧的数据,还不如将服务器关闭,强迫该客户端连接到到一个状态该新的服务器。
处于性能的原因,所有的读操作都是从 Zookeeper 服务器的内存获得数据,它们不参与写操作的全局排序。如果客户端之间通过 Zookeeper 之外的机制进行通信,则客户端可能会发现它们所看到的 Zookeeper 状态是不一致的。
每个 Zookeeper 客户端的配置中都包括集合体中服务器的列表。在启动时,街护短会尝试连接到列表中的一台服务器。如果连接失败,它会尝试连接另一台服务器,以此类推,知道成功与一台服务器建立连接或因为所有 Zookeeper 服务器都不可用而失败。
一旦客户端与一台 Zookeeper 服务器建立连接,这台服务器就会为该客户端创建一个新的会话。每个会话都会有一个超时的时间设置,这个设置有创建会话的应用来设定,如果服务器在超过时间段内没有收到任何请求,则相应的会话会过期。一旦一个会话已经过期,就无法重新被打开,并且任何与该会话相关联的短暂 znode 都会丢失。会话通常会长期存在,而会话过期则是一种比较罕见的事件,但对于应用来说,如何处理会话过期仍是非常重要的。
只要一个会话空闲超过一定时间,都可以通过客户端发送 ping 请求(也称为心跳)来保持会话不过期。(ping 请求是由 Zookeeper 的客户端库自动发送,因此在你的代码中不需要考虑如何维护会话)。这个时间长度的设置应当足够低,以便能够检测出服务器故障(由读超时体现),并且能够在会话超时的时间段内重新连接到另外一台服务器。
Zookeeper 客户端可以自动地进行故障切换,切换至另一台 Zookeeper 服务器,并且关键的是,在另一台服务器接替故障服务器之后,所有的会话(和相关的短暂 znode)仍然是有效的。
在故障切换过程中,应用程序将收到断开连接和连接至服务的通知。当客户端断开连接时,观察通知将无法发送;但是当客户端成功恢复连接后,这些延迟的通知还会被发送。当然,在客户端重新连接至另一台服务器的过程中,如果应用程序试图执行一个操作,这个操作将会失败。这充分说明在真实的 Zookeeper 应用中处理连接丢失异常的重要性。
在 Zookeeper 中有几个时间参数。“滴答 (tick time)” 参数定义了 ZooKeeper 中的基本时间周期,并被集合体中的服务器用来定义相互交互的时间表。其他设置都是根据 “滴答 (tick time)” 参数来定义的,或至少受它的限制。例如,会话超时 (session timeout) 参数的值不可以小于 2 个 “滴答 (tick time)” 并且不可以大于 20 个 “滴答 (tick time)”。如果你试图将会话超时参数设置在这个范围之外,它将会被自动修改到这个范围之内。
通常将 “滴答 (tick time)” 参数设置为 2 秒 (2000毫秒),对应于允许的会话超时范围是 4 到 40 秒。在选择会话超时设置时有几点需要考虑。
较短的会话超时设置会较快的检测到机器故障。在组成员管理的例子中,会话超时的时间就是用来将故障机器从组中删除的时间。但要避免将会话超时时间设得太低,因为繁忙的网络会导致数据包传输延迟,从而可能会无意中导致会话过期。在这种情况下,机器可能会出现 ”振动“ (flap) 现象:在较短的时间内反复出现理考后又重新加入组的情况。
对于那些创建较复杂暂时状态的应用程序来说,由于重建的代价较大,因此比较合适设置较长的会话超时。在某些情况下,可以对应用程序进行设计,使它能够在会话超时之前重启,从而避免出现会话过期的情况(这适合于对应用进行维护或升级)。服务器会为每个会话分配一个唯一的 ID 和密码。如果在建立连接的过程中将它们传递给 Zookeeper ,可以用于恢复一个会话(只要该会话没有过期)。将会话 ID 和密码保存在稳定存储器中之后,可以将一个应用程序正常关闭,然后再重启应用之前凭借所保存的会话 ID 和密码来恢复会话环境。
你可以将这个特征看成是一种用来帮助避免会话过期的优化技术,但不能因此忽略对会话过期异常的处理,因为机器的意外故障也会导致会话过期,或者,即使应用程序是正常关闭的,也有可能因任何原因导致它没有在会话未过期之前完成重启。
一般的规则是,Zookeeper 集合体中的服务器越多,会话超时的设置应越大。连接超时、读超时和 ping 周期都被定义为集合体中服务器数量的函数,因此集合体中服务器数量越多,这些参数的值反而越小。如果频繁遇到连接丢失的情况,应考虑增大超时的设置。可以使用 JMX 来监控 Zookeeper 的度量指标,例如请求延迟的统计信息。
ZooKeeper 对象在其生命周期中会经历几种不同的状态,如下图。你可以在任何时刻通过 getState() 方法来查询对象的状态。
在生产环境中,应当以复制模式运行 ZooKeeper。在这里,我们将讨论使用 ZooKeeper 服务器的集合体所需要考虑的一些问题。
在安放 ZooKeeper 所用的机器时,应当考虑尽量减少机器和网络故障可能带来的影响。在实践过程中,一般是跨机架、电源和交换机来安放服务器,这样,这些设备中的任何一个出现故障都不会使集合体损失半数以上的服务器。
对于那些需要低延迟服务(毫秒级别)的应用来说,最好将所有的服务器都放在同一个数据中心的用一个集合体中。也有一些应用不需要低延迟服务,它们可以通过跨数据中(每个数据中至少两台服务器)安放服务器来获得更好的可恢复性,领导者选举和分布式粗粒度锁是这类应用的代表。这两个应用中的状态改变都相对较少,因此相对于整个服务来说,数据中心之间传递状态改变消息所需的几十毫秒开销是可以承受的。
ZooKeeper 中有一个 ”观察节点“的概念,是指没有投票权的跟随者,由于观察节点不参与写请求过程中达成共识的投票,因此使用观察节点可以让 ZooKeeper 集群在不影响写性能的情况下提高读操作的性能。使用观察节点可以让 ZooKeeper 集群跨越多个数据中心,同时不会增加正常投票节点的延迟。可以通过将投票节点安放在一个数据中心,将观察节点安放在另一个数据中心来实现这一点。
ZooKeeper 是具有高可用性的系统,对它来说,最关键的是能够及时履行其职责。因此,ZooKeeper应当运行在专用的机器上,如果有其他的应用程序竞争资源,可能会导致 ZooKeeper 的性能明显下降。
通过对 ZooKeeper 进行配置,可以使它的事物日志和数据快照分别保存在不同的磁盘驱动器上。在默认情况下,两者都保存在 dataDir 属性所指定的目录中,但是通过为 dataLogDir 属性设置一个值,便可以将事物日志写在指定的位置。通过指定一个专用的设备(不只是一个分区),一个 ZooKeeper 服务器可以以最大速率将日志记录写到磁盘,因为写日志是顺序写,并且没有寻址操作。由于所有的写操作都是通过领导者来完成的,增加服务器并不能提高写操作的吞吐量。所以提高性能的关键是写操作的速度。
如果写操作的进程被交换到磁盘上,则性能会受到不利的影响。这是可以避免的,将 Java 堆的大小设置为小于机器上空闲的物理内存即可。ZooKeeper 脚本可以从它的配置目录中获取一个名为 java.env 的文件,这个文件被用来设置 JVMFLAGS 环境变量,包括设置 Java 堆的大小(和任何其他所需的 JVM 参数)。
配置请参考笔者的另一篇博客