ZooKeeper详解

学习HBase过程中,发现它与ZooKeeper的关系比较密切,于是专门学习了一下ZooKeeper,下面是ZooKeeper官方文档的半翻译版。

介绍

此文档是为了向那些希望使用ZooKeeper协调优势自己分布式应用的人提供编程向导,它包含了理论和实战两部分!前四节将对ZooKeeper进行理论上比较深入的探讨,这对我们理解ZooKeeper的工作原理和怎么使用ZooKeeper非常重要。此四节不包含编程的源代码,但是它会通过讲解一些分布式计算相关的问题来让你对ZooKeeper加深了解:

  • ZooKeeper数据模型
  • ZooKeeper会话(Sessions)
  • ZooKeeper监视(Watches)
  • 确保全局一致性

ZooKeeper数据模型

ZooKeeper有一个属性的命名空间,它更像一个分布式文件系统。不同之处在于此命名空间中的每个节点可以访问它自身的数据和子节点的数据。节点的路径总是以斜杠分开这样的形式(/node/childNode)表示。ZooKeeper的路径可以以任何的Unicode字符组成,除了下列这些情况:

  • null字符(\u0000)不能出现在路径名中(这样做会导致与C交互是出现问题?)。
  • 接下来这些不能正常显示或导致路径混乱的字符也不能出现在路径名中:\u0001 - \u0019, \u007F - \u009F
  • 这些字符也不允许使用:\ud800 -uF8FFF, \uFFF0-uFFFF, \uXFFFE - \uXFFFF (X为1-E的16进制数字), \uF0000 - \uFFFFF.
  • 字符“.”可以出现在路径名中,但是“.”,“..”绝对不能单独成为路径名。众所周知,Linux文件系统中这两个路径表示了什么!
  • zookeeper是保留字符,也不能用。

ZNode

ZooKeeper路径中的每个节点都叫做一个ZNode。Znode持有一个状态数据结构,此结构中包含数据更新的版本号、访问权限(ACL)更新的版本号、时间戳。这些版本号和时间戳使ZooKeeper可以验证缓存有效性和协调更新。每当ZNode中的数据更新时,版本号都会递增。例如,当客户端获取数据时,它(客户端)也会接收到此数据对应的版本号。当此客户端下次执行更新(或删除)数据操作时,它必须向ZNode提供这些数据的版本号(是客户端当前持有的版本号)。如果此版本号与ZNode本身的版本号不一致,即其他客户端已经更新了此数据,那么此更新就会执行失败。

提示:在分布式应用工程中,节点可以是一个正常的主机、一个服务器、一个集群、一个客户端程序等等。在ZooKeeper文档中,ZNode表示这些数据节点在ZooKeeper服务器中的引用。quorum peers表示ZooKeeper集群中的服务器。客户端表示任何使用ZooKeeper服务的主机或进程。

ZNode是我们编程访问的主题,上面的提示非常值得引起你的注意!

Watches

客户端可以在ZNode上面添加一个Watch。之后此ZNode的改变都会激活并清除此Watch,当Watch激活时,ZooKeeper会向此客户端发送一个通知,关于Watch的更多信息参见“ZooKeeper监视(Watches)”小节。

数据访问

命名空间中存储在每个ZNode中数据的读写操作都是原子性的,读操作会获取与此ZNode相关联的所有数据字节,写操作会替换所有数据。每个节点都会有一个权限限制列表(ACL),此列表会约束哪些客户端有权对此ZNode做哪些数据操作。ZooKeeper并没有被设计为常规的数据库或者大数据存储。相反的是,它用来管理调度数据,比如分布式应用中的配置文件信息、状态信息、汇集位置等等。这些数据的共同特性就是它们都是很小的数据,通常以KB为大小单位。ZooKeeper的服务器和客户端都被设计为严格检查并限制每个ZNode的数据大小至多1M,当时常规使用中应该远小于此值。关系型大数据的操作会导致一些其他操作耗费更多的时间,并且因为大数据的网络传输和写入存数介质速度过慢也会导致其他操作的延迟。如果你需要存储大数据库,通常的做法是将这些大数据存储在NFS或HDFS中,然后将这些大数据的存储位置指针存储在ZooKeeper的ZNode中。

瞬时ZNode

ZooKeeper中也有瞬时ZNode的概念,这些ZNode仅存在于创建此ZNode的会话的有效期内。当此会话结束后此ZNode也会被删除,由于瞬时ZNode的特性,它不能有子节点。

顺序节点 – 唯一命名

当创建一个ZNode时候,你也可以请求ZooKeeper将一个单调递增计数器添加在路径之后。此计数器对于父节点是唯一的。

ZooKeeper中的时间

ZooKeeper可以以不同的方式跟踪时间:

  • Zxid:每次对ZooKeeper的改变都会收到一个zxid(ZooKeeper事务ID)格式的戳。它表示ZooKeeper中所有改变的总顺序,每次改变都会有一个唯一的zxid,如果zxid1的值小于zxid2,那么zxid1会在zxid2之前发生。
  • 版本号:每个ZNode的数据更新都会导致此ZNode版本号的增加。三个版本号分别为:version(ZNode中数据更改的次数)、cversion(ZNode的子节点的改变次数)、aversion(ZNode的ACL改变次数)。
  • 心跳(Ticks):在使用多服务器集群的ZooKeeper时候,服务器之间会使用心跳来限定事件时间,比如状态传递、会话超时、连接超时等等。心跳时间通过最小会话失效时间(minimum session timeout)指定。如果客户端请求的会话超时时间小于最小会话失效时间,服务器会告诉客户端服务器端指定的会话失效时间作为真实会话失效时间。
  • 真正时间:ZooKeeper并不使用真正的时间或者时钟时间,当然它会在ZNode创建和修改时候使用真正时间作为时间戳。

ZooKeeper的状态结构

ZooKeeper中每个ZNode的状态数据结构都是由下列字段组成

  • czxid:导致此ZNode创建的zxid(ZooKeeper事务ID)
  • mzxid:最后一次修改此ZNode的zxid
  • ctime:从此ZNode创建到现在为止的时间毫秒数
  • mtime:从此ZNode上次修改到现在为止的时间毫秒数
  • version:此Znode的数据修改次数
  • cversion:此ZNode的子节点修改次数
  • aversion:此ZNode的ACL修改次数
  • ephemeralOwner:如果此ZNode是一个瞬时节点,此值表示此ZNode对应的会话ID。如果不是瞬时节点则为0
  • dataLength:此ZNode中数据的长度
  • mumChildren:此ZNode的子节点数量

#ZooKeeper会话(Sessions)

ZooKeeper客户端可以通过创建一个ZooKeeper的句柄,从而与ZooKeeper服务建立一个会话(session)。会话创建之后,句柄的初始状态为CONNECTING状态,此时客户端句柄会尝试与ZooKeeper服务器建立连接,此服务器会指明此句柄将状态设置为CONNECTED。在常规操作中只会有这两种状态,如果有任何不可恢复的错误发生,比如会话失效、权限验证失败、应用强制关闭句柄等等,此句柄状态会转变为CLOSED状态。下图展现了客户端句柄可能出现的状态转换:

alt zk-status-change

为了创建一个客户端会话,应用程序代码必须提供一个连接字符串,此字符串包括一系列以逗号分开的“host:port”对,每个“host:port”均对应一个ZooKeeper服务器(例如:127.0.0.1:3000,128.0.0.1:2001)。ZooKeeper的客户端句柄会随机挑选一个ZooKeeper服务器并尝试与之创建连接。如果连接建立失败,或因为任何原因客户端与此服务器断开连接,此客户端都会自动尝试列表中的下一个服务器,直到连接建立。

3.2版本新添加:一个可选后缀“chroot”也可以添加在连接字符串末尾,那么在所有与此根路径相关的路径解释时都会执行此指令(与unix的chmod指令相似)。例如若连接字符串为“127.0.0.1:3000,127.0.0.1:3002/app/a”,那么客户端会将根目录建立在/app/a路径中。所有路径的根目录都会被重定向到此目录。如/foo/bar路径的读写操作都会被重定向到/app/a/foo/bar路径(从服务器端看是这样的)。这项新添加的功能在多应用公用ZooKeeper时非常有用,此功能使每个应用都能更简便的处理路径指定,就好像每个应用都在使用“/”路径一样。

当客户端从ZooKeeper服务获取到一个句柄,那么ZooKeeper服务会创建一个ZooKeeper会话,此会话以64bit的数字表示。之后ZooKeeper服务将此会话分配给客户端。如果客户端下次连接到其他的ZooKeeper服务器,那么客户端会将先前获取到的会话ID作为连接握手的一部分发送。出于安全考虑,ZooKeeper服务器会为每个会话ID创建一个所有ZooKeeper服务器都可以检验的密码,此密码会在客户端建立会话时随着会话ID一同发送给客户端。那么客户端会在下次与新的ZooKeeper服务器建立会话时将此密码与会话ID一同发送。

客户端创建ZooKeeper会话时会发送一个会话超时时间毫秒值参数,客户端发送一个请求的超时时间,服务器会响应一个它可以给客户端的超时时间。当前的实现要求此值最小必须为心跳时间(心跳时间在服务器配置文件中指定)的二倍,最大不得超过为心跳时间的二十倍。ZooKeeper客户端API允许协商超时时间。

当客户端会话与ZK服务断开连接之后,客户端会搜索创建此会话的服务器列表。若最终此会话与至少一个ZK服务器重新建立连接,那么此会话会被重新转换为CONNECTED状态;若在超时时间之内不能重新建立连接则此会话会被转换为expired状态。我们不建议你为断开连接的会话重新建立新的会话,因为ZK客户端库会自动为你的会话重新建立连接。特别是我们已经启发式的将ZK客户端库建立为处理类似于“羊群效应”这种情况,你最好只在收到会话失效通知的情况下才重新建立会话。

会话失效是由ZK服务器管理的而不是客户端,当客户端创建一个会话时,它会按上文描述的那样提供一个会话超时时间,ZK服务器会根据此值决定当前会话是否超时。若在超时时间之内ZK服务器没有收到客户端的消息(除了心跳),则ZK服务器会判定此会话超时无效。在会话无效之后,ZK服务器会删除所有此会话创建的瞬时节点并且立即通知其他每个监视这些节点且未断开连接的客户端(他们监视的瞬时节点已不存在了)。此时这个失效会话与ZK集群仍然处于断开连接状态,此会话客户端不会收到上述通知除非此客户端又重新建立了到ZK集群的连接。此客户端会一直处于未连接状态直到与ZK集群的TCP连接重新被建立,此时这个失效会话的监视者将会收到一个会话失效(“session expired”)通知。

下述为一个超时会话在它的监视者眼中的状态转换实例:

  • “connected”:会话建立且客户端与ZK集群成功建立连接。
  • …… 客户端与集群分开。
  • “disconnected”:客户端已与ZK集群失去联系。
  • …… 时间消逝,ZK集群判定会话失效之后,由于客户端已失去联系因此客户端不知道发生的一切。
  • …… 时间消逝,客户端重新获取与ZK服务器之间网络层的联系。
  • “expired”:最终客户端与ZK服务器重新建立连接,然后客户端被通知此会话已失效(为什么客户端不能自己先判断一下会话是否超时,以减少向ZK服务器的多余请求呢?)。

ZK会话建立的另一个参数叫做默认监视者。在客户端有任何状态改变发生时监视者(在ZK服务器端)都会得到通知,例如,如果客户端与ZK服务器实现联系则客户端(?)会得到通知,或者如果客户端会话失效也会得到通知等等。监视者将客户端的初始状态当做未连接状态(disconnected:在任何状态改变事件被客户端库发送至监视者之前)。在新连接建立时,发送至监视者的第一个事件通常都是会话连接建立事件。

会话的保持活跃请求是由客户端发起的。如果会话空闲了一段时间并且将要超时失效,客户端会发送一个PING请求以保持连接存活。PING请求不仅仅会让ZK服务器知道客户端仍然还活着并且它也会让客户端验证它与ZK服务器之间的联系仍然也活着(ZK服务器也有可能宕机)。PING的时间间隔应该是足够保守以保证能够在合理的时间内察觉到一个死链(ZK服务器宕机)并且及时的重新与一个新ZK服务器建立连接。

一旦客户端与ZK服务器之间的连接建立(connected),从根本上来说当一个同步或异步的操作执行时客户端库导致连接失去(C里面是返回结束码,java里面是抛出异常 – 参加API文档)的情况有两种:

  • 应用程序在一个不再有效的会话上调用一个操作。
  • 当还有未完成的ZK服务器异步操作请求时,ZK客户端断开了与ZK服务器的连接。

3.2版本新添加 – SessionMovedException:有一个客户端不可见的内部异常叫做SessionMovedException,此异常会在ZK服务器收到了一个指定会话的连接请求,但此会话已经在其他服务器上重新建立了连接时发生。此异常发生通常是因为一个客户端向一个ZK服务器发送一个请求,但是网络包延迟导致客户端超时而与一个新的服务器建立连接。之后当这个延迟了的网络包最终慢吞吞到达最初的ZK服务器时,ZK服务器检测到此会话已经被移动到另外一个ZK服务器上面并且关闭连接。客户端通常不会发现此异常因为他们不会从旧的连接中读取数据(这些旧连接通常都是已关闭的)。此异常出现的一种情况是当两个客户端都尝试使用同一个会话ID和密码与ZK服务器建立相同的连接。这两个客户端中的一个会成功建立连接而另外一个则会被断开连接。

ZooKeeper监视(Watches)

ZooKeeper中的所有读操作(getDate(), getChildren(), exists())都有一个设置监视者的选项。ZooKeeper中监视者的定义为:一个监视事件是一次性的触发器,此触发器会在监视数据发生改变时被触发并发送给设置此监视器的客户端。以下为一个监视器定义的三个关键点:

  • 一次性触发器:当数据改变时一个监视事件会被发送给客户端。例如,如果一个客户端执行了getDate(“/znode1”, true)并且之后/znode1上的数据被改变会删除,此客户端会接受到一个 /node1的监视事件。如果此节点又改变了,则此时间不会再发生,除非此客户端又执行了一次getDate(“/znode1”, true)并且设置了一个新的监视器。
  • 发生往客户端:这说明一个监视事件是发送往客户端A的,但是当另一个客户端B执行数据更新时,在客户端B收到从服务器返回的更新操作成功代码时,客户端A的监视事件也许还没有到达客户端A呢。监视事件是被异步的发送往监视者的。ZooKeeper提供了一个顺序上的保证:如果客户端A没有看到这个监视事件,那么客户端A它永远也看不到这个改变(是这个新数据它看不到么,即使主动请求?)。网络延迟或其他因素或许会导致多个客户端在不同时间看到监视时间和更新的返回码。关键在于不同的客户端看到的所有数据都有一个一致的顺序。
  • 被设置了监视器的数据:这表示一个节点会有多种改变,ZooKeeper有两种监视:数据监视和子节点监视。getData()和exists()可以设置数据监视器,getChildren()可以设置子节点监视器。因此setData()会激活数据监视器,而create()会激活子节点监视器,一个成功的delete()操作会激活两种监视器,因为此操作会删除数据和所有子节点。

监视器被客户端所连接的ZK服务器所持有,这允许监视器可以被轻松的设置、维持和发送。当此客户端连接到一个新的ZK服务器,监视器会被以任何会话事件激活(?)。当客户端与ZK服务器断开连接则它不会再收到任何监视事件。当客户端重新建立连接,先前注册的监视器都会被重新注册并且根据需要被激活。通常这一系列操作都是透明进行的,有一种情况下,监视器也许会出现失误:监视的节点在先前客户端断开连接期间已经被创建和删除。

ZooKeeper对监视器的保证

对于监视器,ZooKeeper有以下保证:

  • 监视事件会与其他的事件、异步响应有序排列,ZooKeeper客户端库会保证任何事都会按需发送(即无论怎么延迟等等,ZK客户端库都会按需将这些事件交给应用)。
  • 客户端会在看到一个ZNode的新数据之前先接收到先前在此ZNode上注册的监视事件。
  • 监视事件的顺序会按照ZK服务器所看到的数据更新数据发送(即那个节点先更新,那个节点的监视事件先到达应用)。

ZK监视器中应该记住的事:

  • 监视器是一次性触发器,如果你想收到更多的监视通知,你必须一次又一次的注册监视器。
  • 监视器是一次性触发的,并且在获取到监视事件之前也许会存在一些延迟,因此你也许不会看到在ZK服务器中节点数据的全部改变。你要对这种情况做好准备:在你接收到监视事件并注册新事件之前,此节点的数据已经改变过多次。(你一般也不必在意这种情况 – 反正都是读取最新的数据,但是好歹你也得明白。)
  • 相同的触发事件仅仅会引起一次监视事件,也就是说如果你执行多次getData()并绑定了相同的监视器,之后如果此数据被更新,那么你先前绑定的多个监视器仅会被触发一次。
  • 当你与服务器断开连接时(如当服务器宕机),你不会获取到任何监视事件除非连接又重新建立。因为这个原因会话事件会被发送到所有未处理的监视句柄。使用会话事件进入安全模式:在断开连接期间你不会收到任何监视事件,因此你的程序应该在此期间小心从事。—— 这个搞不明白什么意思,好像我理解错了。。。

ZooKeeper使用ACL进行权限控制

ZK使用ACL来控制指定ZNode的访问,ACL的实现与Unix文件访问许可非常相似:它用许可bit位来表示一个ZNode上各种操作的允许或不允许。与标准Unix许可不同的是,一个ZK节点的访问权限并不为特定的三种用户(拥有者、用户组、全局)范围限制。ZK并没有一个节点拥有者的概念,ZK通过指定ID和这些ID相关联的许可来进行权限限制。要注意的是一个ACL只与指定的ZNode有关,尤其注意的是,这个ACL并不作用于子节点,如果/app仅对于ip:172.0.0.1可读但是/app/status却可以对所有IP都可见。因为ACL并不会覆盖子节点的ACL。

ZK支持可插拔的权限模块,ID使用scheme:id这种格式指定。例如:ip:127.0.0.1。ACL是以(scheme:expression, perms)对组成。例如 (ip:19.22.0.0/16, READ) 将读权限交给任何IP地址以19.22开始的客户端。

ACL权限

ZooKeeper支持下列权限:

  • CREATE:你可以创建一个子节点
  • READ:你可以读取数据,并且可以读取子节点列表
  • WRITE:你可以写入数据
  • DELETE:你可以删除一个子节点
  • ADMIN:你可以改变ACL

CREATE和DELETE权限已经从WRITE中提取出来了,这样做是为了更加细微的权限控制。CREATE和DELETE分割出来的原因是:你想让一个客户端只能修改一个ZNode中的数据但是不能创建或删除子节点。ADMIN权限的存在是因为ZK没有ZNode拥有者这个概念,因此ADMIN权限可以扮演拥有者这个角色。ZK不支持LOOKUP权限,ZK内在的设定所有用户都有LOOKUP权限,这允许你可以查看ZNode的状态,但是你只能看这么多。(问题是,如果你想要在一个并不存在的ZNode上调用zoo_exists(),那么并没有权限列表进行判断是否有权限,因此ZK才内含所有人都有LOOKUP权限)。

ACL的固定模式

ZooKeeper有下列的固定模式

  • world:有个单一的ID,anyone,表示任何人。
  • auth:不使用任何ID,表示任何通过验证的用户(是通过ZK验证的用户?连接到此ZK服务器的用户?)。
  • digest:使用 用户名:密码 字符串生成MD5哈希值作为ACL标识符ID。权限的验证通过直接发送用户名密码字符串的方式完成,
  • ip:使用客户端主机ip地址作为一个ACL标识符,ACL表达式是以 addr/bits 这种格式表示的。ZK服务器会将addr的前bits位与客户端地址的前bits位来进行匹配验证权限。

确保全局一致性

ZooKeeper是一个高性能的、可伸缩的服务。读写操作都被设计为尽可能快的。尽管读操作通常都比写操作要快很多,读写同样快的原因是对于读操作而言,ZK会返回更旧的数据,反过来而言,这正是由于ZooKeeper的一致性保证。

  • 顺序保证:客户端的更新操作会按照客户端发送这些更新请求的顺序完成。
  • 原子性:更新要么成功要么失败 – 不会出现部分成功或失败。
  • 单一系统映像:客户端无论连接在ZK集群的那个服务器上,永远只会看到同一副映像。
    可靠性:一旦一次更新完成,那么此更新将一直存在直到下一次更新覆盖此次更新。这个保证有两种必然结果:如果客户端得到一个成功返回码,那么更新操作就说明已被成功执行。如果出现失败(通信错误、超时等等)那么客户端将不会知道此次更新是否已经被应用到ZK服务器。我们采取一些办法来最小化失败造成的影响,但是我们只能保证只有在得到那个成功返回码时才说明更新成功。(—— 感觉有问题,总之就是这个意思了)。任何由客户端通过读请求或成功更新操作发送的更新请求将用于不会被回滚,即便是在服务器宕机恢复期间。
  • 及时性:ZK系统的客户端视图可以保证在一定时间范围内是实时的,在这几十秒内,系统的更新会对所有客户端都可见。否则客户端就应该检查一下它所连接的ZK服务是否已终止。

使用这些一致性保证,我们能很容易的在客户端构建更高层次的功能,比如领袖选举、队列、可回滚读写锁等等。

提示:有时候开发者会误认为ZooKeeper能够确保一些ZK本不能确保的事,那就是"客户端视图之间的同步"。ZooKeeper并不保证在任何时候任何情况下,两个不同的客户端都会获取到ZooKeeper数据的同一份视图。由于一些比如网络延迟等因素,一个客户端也许会在另一个客户端接受到更新通知之前完成更新(有问题)。例如,如果有两个客户端A和B,如果客户端A将节点/a的值从0变为1,然后告诉B去读取此值,此时客户端有可能还是读到0而不是1,这取决于B连接在那台服务器上和网络的延迟等等。如果此值的同步对A和B都非常重要,那么客户端B应该在执行读操作之前调用一次sync()方法。

Java Binding

ZK客户端库中有两个包组成了ZK binding功能:org.apache.zookeeper和org.apache.zookeeper.data。其余的包是被其内部使用的或者仅仅是服务器实现的一部分,开发者不必了解。org.apache.zookeeper.data包由一些被简单的用作容器的普通类组成。

ZooKeeper的java客户端库的主类是ZooKeeper类,此类的构造方法们之间仅仅在可选的会话ID和密码参数有无有差别。ZK支持程应用程序恢复会话,一个java程序可以将它的会话ID和密码持久化保存在硬盘介质中,重启之后可以使用会话ID和密码重新恢复此会话。

当一个ZooKeeper对象初始化时,它也会创建两个线程:一个IO线程和一个事件线程。所有的IO操作都通过IO线程进行,所有的事件回调都在事件线程中进行。会话的维护工作比如连接重建和心跳机制都在IO线程上完成,同步方法调用也是在IO线程上完成。所有的异步方法响应和监视事件都由事件线程处理。下面列出这种做法的结果和好处:

  • 所有的异步调用和监视事件回调都可以一次一个的很容易的排列。调用者可以做它想做的任何处理,此时不会有其他回调操作进行。
  • 回调操作不会阻塞IO线程的进行,也不会阻塞同步请求的进行。
  • 同步调用也许不会按正确的顺序返回。例如,假设一个客户端做下列请求:客户端发起了被监视的节点/a的一个异步读操作(此节点的更新导致监视事件发生),在这个异步读回调操作完成时客户端又对节点/a发起了一个同步读操作。如果在这个异步读和同步读两个时间段中间此数据发生了改变,客户端库会接收到表示/a节点更新的监视事件,也许此时异步读回调操作还没有完成,但是此时再发起同步读则会因为监视事件已经发送,因此此客户端能够读到最新的数据,而异步读操作读到的是旧数据(这个例子迷迷糊糊的。。。)

最后,ZK客户端的关闭过程也很简单:当ZooKeeper对象被主动关闭或者收到一个致命的事件(SESSION_EXPIRED和AUTO_FAILED),ZooKeeper对象就会变为无效状态。关闭时,两个线程都会被关闭并且之后任何在此ZooKeeper之上的操作都是未知结果的,这种操作应该被避免。

异常处理:ZK操作时有可能会出现异常KeeperException,如果在此异常上调用code()方法会获取到特定的错误码。你可以查看API文档查看具体的异常详情和不同错误码代表什么意思。