ZooKeeper 使用方案与解决方案
使用ZooKeeper创建高级构件的指南
在本文中,您将找到使用ZooKeeper实现高阶函数的指南。所有这些都是在客户端实现的约定,不需要ZooKeeper提供特殊支持。希望社区能在客户端库中采用这些约定,以简化使用并促进标准化。
关于ZooKeeper最有趣的一点是,尽管它使用异步通知机制,却可以用来构建同步一致性原语,比如队列和锁。正如你将看到的,这之所以可能实现,是因为ZooKeeper对更新操作施加了全局顺序,并通过机制暴露这种顺序性。
请注意,以下配方尝试采用最佳实践。特别是,它们避免了轮询、定时器或任何可能导致"羊群效应"的情况,从而避免流量突发并限制可扩展性。
这里没有包含许多可以想象的有用功能——可撤销的读写优先级锁就是一个例子。此外,这里提到的一些结构——特别是锁——虽然说明了某些要点,但您可能会发现其他结构(如事件句柄或队列)是实现相同功能的更实用方法。总的来说,本节中的示例旨在激发思考。
关于错误处理的重要说明
在实现这些方案时,必须处理可恢复的异常(参见FAQ)。特别是,其中几个方案使用了顺序临时节点。在创建顺序临时节点时存在一种错误情况:服务器上的create()操作虽然成功执行,但服务器在将节点名称返回给客户端之前崩溃了。当客户端重新连接时,其会话仍然有效,因此该节点不会被删除。这意味着客户端很难知道其节点是否已创建。以下方案包含了处理这种情况的措施。
开箱即用的应用:命名服务、配置管理、组成员管理
名称服务和配置管理是ZooKeeper的两大核心应用场景。这两个功能直接由ZooKeeper API提供支持。
ZooKeeper直接提供的另一个功能是组成员管理。组由一个节点表示。组成员在组节点下创建临时节点。当ZooKeeper检测到异常故障时,异常故障的成员节点将被自动移除。
屏障
分布式系统使用屏障来阻塞一组节点的处理,直到满足某个条件时才允许所有节点继续执行。在ZooKeeper中,屏障通过指定一个屏障节点来实现。如果屏障节点存在,则表示屏障已就位。以下是伪代码:
- 客户端在屏障节点上调用ZooKeeper API的exists()函数,并将watch设置为true。
- 如果exists()返回false,则表示屏障已消失,客户端可以继续执行
- 否则,如果exists()返回true,客户端会等待来自ZooKeeper关于屏障节点的监视事件。
- 当监视事件被触发时,客户端会重新发出exists( )调用,再次等待直到屏障节点被移除。
双重屏障
双重屏障使客户端能够同步计算的开始和结束。当足够多的进程加入屏障后,进程开始计算并在完成后离开屏障。本方案展示了如何使用ZooKeeper节点作为屏障。
本方案中的伪代码将屏障节点表示为b。每个客户端进程p在进入时向屏障节点注册,并在准备退出时注销。节点通过下面的Enter过程向屏障节点注册,它会等待直到x个客户端进程注册后才继续进行计算。(此处的x值需要您根据系统需求自行确定。)
| 进入 | 离开 |
|---|---|
| 1. 创建一个名称 n = b+“/”+p | 1. L = getChildren(b, false) |
| 2. 设置监视: exists(b + ‘‘/ready’’, true) | 2. 如果没有子节点,退出 |
| 3. 创建子节点: create(n, EPHEMERAL) | 3. 如果 p 是 L 中唯一的进程节点,则删除(n)并退出 |
| 4. L = getChildren(b, false) | 4. 如果p是L中最低的进程节点,则等待L中最高的进程节点 |
| 5. 如果L中的子节点数少于_x_,则等待监视事件 | 5. 否则**删除(n)**如果仍然存在,并等待L中最低进程节点 |
| 6. 否则 create(b + ‘‘/ready’’, REGULAR) | 6. 跳转到 1 |
进入时,所有进程都会监视一个就绪节点,并在屏障节点下创建一个临时子节点。除最后一个进程外,其他进程都会进入屏障并在第5行等待就绪节点出现。创建第x个节点(即最后一个进程)的进程会在子节点列表中看到x个节点,随后创建就绪节点以唤醒其他进程。请注意,等待中的进程仅在需要退出时才会被唤醒,因此等待过程是高效的。
退出时,不能使用ready这样的标志,因为您正在监视进程节点是否消失。通过使用临时节点,进入屏障后失败的进程不会阻止正确进程完成。当进程准备退出时,它们需要删除自己的进程节点并等待所有其他进程也这样做。
当b的子节点中不再存在进程节点时,进程会退出。不过出于效率考虑,您可以使用编号最小的进程节点作为就绪标志。所有其他准备退出的进程会监视编号最小的现有进程节点是否消失,而拥有最小编号进程的进程则会监视其他任意进程节点(为简化起见选择编号最大的)是否消失。这意味着每次节点删除时只会唤醒单个进程,最后一个节点除外——当它被移除时会唤醒所有进程。
队列
分布式队列是一种常见的数据结构。要在ZooKeeper中实现分布式队列,首先需要指定一个znode作为队列节点来存放队列。分布式客户端通过调用create()方法,使用以"queue-"结尾的路径名,并将create()调用中的sequence和ephemeral标志设置为true,从而将内容放入队列。由于设置了sequence标志,新的路径名将采用path-to-queue-node/queue-X的形式,其中X是一个单调递增的数字。想要从队列中移除的客户端会调用ZooKeeper的getChildren( )函数,在队列节点上设置watch为true,并开始处理编号最小的节点。客户端不需要再次调用getChildren( ),直到它处理完第一次getChildren( )调用获得的列表。如果队列节点中没有子节点,读取器会等待监视通知再次检查队列。
注意
ZooKeeper的recipes目录中现已实现了一个队列。该实现随发行版一同发布——位于发行包中的zookeeper-recipes/zookeeper-recipes-queue目录。
优先级队列
要实现一个优先级队列,你只需要对通用的队列方案做两个简单改动。首先,在添加元素到队列时,路径名以"queue-YY"结尾,其中YY表示元素的优先级,数字越小优先级越高(类似UNIX系统)。其次,当从队列移除元素时,客户端需要使用最新的子节点列表,这意味着如果队列节点触发了监视通知,客户端将使之前获取的子节点列表失效。
锁
完全分布式的全局同步锁,意味着在任何时间快照下,都不会有两个客户端认为自己持有同一把锁。这可以通过ZooKeeper来实现。与优先级队列类似,首先需要定义一个锁节点。
注意
ZooKeeper的recipes目录中现已存在一个Lock实现。该实现随发行版一同发布——位于发行包中的zookeeper-recipes/zookeeper-recipes-lock目录。
希望获取锁的客户端需要执行以下操作:
- 调用create( )方法,路径名设为"locknode/guid-lock-",并设置sequence和ephemeral标志。需要guid是为了防止create()结果丢失。请参阅下面的说明。
- 在锁节点上调用getChildren( )方法不要设置watch标志(这点很重要,可以避免羊群效应)。
- 如果在步骤1中创建的路径名具有最低的序列号后缀,则客户端获得锁并退出协议。
- 客户端调用exists( )方法,并在锁目录中下一个最小序列号的路径上设置watch标志。
- 如果exists( )返回null,则转到步骤2。否则,在转到步骤2之前,等待上一步路径名的通知。
解锁协议非常简单:希望释放锁的客户端只需删除它们在步骤1中创建的节点。
以下是需要注意的几点:
-
删除一个节点只会唤醒一个客户端,因为每个节点仅由一个客户端监控。这样可以避免羊群效应。
-
无需轮询或超时。
-
由于您实现锁定的方式,可以轻松查看锁争用情况、解除锁定、调试锁定问题等。
可恢复错误与GUID
- 如果在调用create()时发生可恢复错误,客户端应调用getChildren()并检查路径名中是否包含guid的节点。这处理了create()在服务器上成功执行但服务器在返回新节点名称前崩溃的情况(如上文所述)。
共享锁
你可以通过对锁协议进行一些修改来实现共享锁:
| 获取读锁: | 获取写锁: |
|---|---|
| 1. 调用create( )创建一个路径名为"guid-/read-"的节点。这是后续协议中要使用的锁节点。请确保同时设置sequence和ephemeral标志。 | 1. 调用create( )创建一个路径名为"guid-/write-"的节点。这是协议后续提到的锁节点。请确保同时设置sequence和ephemeral标志。 |
| 2. 在锁节点上调用getChildren( )方法,不要设置watch标志 - 这很重要,因为它避免了羊群效应。 | 2. Call getChildren( ) on the lock node without setting the watch flag - this is important, as it avoids the herd effect. |
| 3. 如果没有子节点的路径名以"write-"开头且序号低于步骤1中创建的节点,则客户端获得锁并可以退出协议。 | 3. 如果没有子节点的序号低于步骤1中创建的节点,则客户端获得锁并退出协议。 |
| 4. 否则,调用exists( ),并设置watch标志,监视锁目录中路径名以"write-"开头且具有次低序列号的节点。 | 4. 调用exists( ),并设置watch标志,监视具有次低序列号路径名的节点。 |
| 5. 如果 exists( ) 返回 false,转到步骤 2。 | 5. 如果 exists( ) 返回 false,转到步骤 2。否则,在返回步骤 2 之前等待上一步路径名的通知。 |
| 6. 否则,在进入步骤 2 之前,等待上一步中路径名的通知 |
注意事项:
-
看起来这个方案可能会引发羊群效应:当有大量客户端在等待读取锁时,当序号最小的"write-"节点被删除时,所有客户端都会或多或少同时收到通知。实际上,这是合理的行为:因为所有这些等待的读取客户端都应该被释放,因为它们已经获得了锁。羊群效应指的是实际上只有单个或少量机器可以继续时却释放了整个"羊群"。
-
查看锁的说明了解如何在节点中使用guid。
可撤销共享锁
通过对共享锁协议进行少量修改,您可以通过修改共享锁协议使共享锁变为可撤销:
在步骤1中,无论是获取读锁还是写锁协议,在调用create( )后立即调用设置了watch标志的getData( )。如果客户端随后收到关于它在步骤1中创建的节点的通知,它会再次对该节点执行设置了watch标志的getData( ),并查找字符串"unlock",该字符串向客户端发出必须释放锁的信号。这是因为根据这个共享锁协议,你可以通过调用锁节点上的setData()并向该节点写入"unlock"来请求持有锁的客户端放弃锁。
请注意,该协议要求锁持有者同意释放锁。这种同意非常重要,尤其是当锁持有者需要在释放锁之前进行某些处理时。当然,您始终可以通过在协议中规定,如果锁持有者在一定时间内未删除锁节点,则撤销者有权删除该锁节点,从而实现可撤销的带激光束共享锁。
两阶段提交
两阶段提交协议是一种算法,它使得分布式系统中的所有客户端能够一致决定是提交事务还是中止。
在ZooKeeper中,您可以通过让协调器创建一个事务节点(例如"/app/Tx")并为每个参与站点创建一个子节点(例如"/app/Tx/s_i")来实现两阶段提交。当协调器创建子节点时,它会保持内容未定义。一旦事务中的每个站点从协调器接收到事务,该站点会读取每个子节点并设置监视。然后每个站点处理查询并通过写入其相应节点来投票"提交"或"中止"。一旦写入完成,其他站点会收到通知,当所有站点都收集到所有投票后,它们可以决定"中止"或"提交"。需要注意的是,如果有站点投票"中止",节点可以提前决定"中止"。
这个实现的一个有趣之处在于,协调器的唯一作用是决定站点组、创建ZooKeeper节点,并将事务传播到相应的站点。实际上,甚至可以通过在事务节点中写入来通过ZooKeeper完成事务传播。
上述方法存在两个重要缺点。一是消息复杂度为O(n²)。二是无法通过临时节点检测站点故障。要使用临时节点检测站点故障,必须由该站点创建节点。
为了解决第一个问题,您可以只让协调者接收事务节点的变更通知,待协调者作出决定后再通知各站点。请注意,这种方法具有可扩展性,但速度较慢,因为所有通信都需要经过协调者。
为了解决第二个问题,可以让协调器将事务传播到各个站点,并让每个站点创建自己的临时节点。
领导者选举
使用ZooKeeper进行领导者选举的一种简单方法是在创建代表客户端"提案"的znode时使用SEQUENCE|EPHEMERAL标志。其思路是创建一个znode,例如"/election",让每个znode都创建一个带有SEQUENCE|EPHEMERAL标志的子znode"/election/guid-n_"。通过序列标志,ZooKeeper会自动附加一个比之前附加到"/election"子节点上的任何序列号都大的序列号。创建具有最小附加序列号的znode的进程将成为领导者。
但这还不是全部。重要的是要监控领导者的故障,以便在当前领导者失效时,新的客户端能够成为新的领导者。一个简单的解决方案是让所有应用进程监视当前最小的znode,并在最小znode消失时检查自己是否成为新领导者(注意,如果领导者失效,该节点会消失,因为它是临时节点)。但这会导致羊群效应:在当前领导者失效时,所有其他进程都会收到通知,并执行getChildren操作获取"/election"的当前子节点列表。如果客户端数量庞大,这将导致ZooKeeper服务器需要处理的操作数量激增。为了避免羊群效应,只需监视znode序列中的下一个znode即可。如果客户端收到通知,发现其监视的znode已消失,并且没有更小的znode存在,那么它将成为新的领导者。请注意,这种方法通过不让所有客户端监视同一个znode,从而避免了羊群效应。
以下是伪代码:
假设ELECTION是应用程序选择的路径。要申请成为领导者:
- 使用路径"ELECTION/guid-n_"创建znode z,并设置SEQUENCE和EPHEMERAL标志;
- 设C为"ELECTION"的子节点,我是z的序号;
- 监视"ELECTION/guid-n_j"上的变化,其中j是最大的序列号,满足j < i且n_j是C中的一个znode;
收到znode删除通知时:
- 让 C 成为 ELECTION 的新子节点集合;
- 如果z是C中最小的节点,则执行leader流程;
- 否则,监听"ELECTION/guid-n_j"上的变化,其中j是满足j < i且n_j是C中一个znode的最大序列号;
注意事项:
-
请注意,在子节点列表中没有任何前置znode并不意味着该znode的创建者知道它是当前领导者。应用程序可以考虑创建一个单独的znode来确认领导者已执行领导者流程。
-
查看锁的说明了解如何在节点中使用guid。
