Apache > ZooKeeper
 

ZooKeeper 食谱和解决方案

使用 ZooKeeper 创建更高级别构造的指南

在本文中,您将找到使用 ZooKeeper 实现高阶函数的指南。它们都是在客户端实现的约定,不需要 ZooKeeper 的特殊支持。希望社区将在客户端库中捕获这些约定,以简化它们的使用并鼓励标准化。

ZooKeeper 最有趣的事情之一是即使 ZooKeeper 使用异步通知,您也可以使用它来构建同步一致性原语,例如队列和锁。正如您将看到的,这是可能的,因为 ZooKeeper 对更新施加了整体顺序,并且具有公开此顺序的机制。

请注意,以下食谱尝试采用最佳实践。特别是,它们避免了轮询、计时器或任何其他会导致“羊群效应”的东西,从而导致流量激增并限制可扩展性。

有很多有用的功能可以想象,这里没有包含——可撤销的读写优先级锁,仅举一个例子。这里提到的一些结构——尤其是锁——说明了某些观点,即使您可能会发现其他结构,例如事件句柄或队列,是执行相同功能的更实用的方法。通常,本节中的示例旨在激发思考。

关于错误处理的重要说明

在实施配方时,您必须处理可恢复的异常(请参阅常见问题解答)。特别是,一些配方采用顺序临时节点。创建顺序临时节点时,会出现错误情况,即 create() 在服务器上成功但服务器在将节点名称返回给客户端之前崩溃。当客户端重新连接时,其会话仍然有效,因此不会删除该节点。这意味着客户端很难知道其节点是否已创建。下面的食谱包括处理这个问题的措施。

开箱即用的应用程序:名称服务、配置、组成员资格

名称服务和配置是 ZooKeeper 的两个主要应用程序。这两个函数由 ZooKeeper API 直接提供。

ZooKeeper 直接提供的另一个功能是group members 。该组由一个节点表示。组的成员在组节点下创建临时节点。当 ZooKeeper 检测到故障时,异常故障的成员的节点将被自动删除。

障碍

分布式系统使用障碍来阻止对一组节点的处理,直到满足允许所有节点继续进行的条件。在 ZooKeeper 中通过指定一个屏障节点来实现屏障。如果屏障节点存在,则屏障就位。这是伪代码:

  1. 客户端在屏障节点上调用 ZooKeeper API 的exists()函数,并将watch设置为 true。
  2. 如果exists()返回 false,则障碍消失,客户端继续
  3. 否则,如果exists()返回 true,则客户端等待 ZooKeeper 为屏障节点发送监视事件。
  4. 当 watch 事件被触发时,客户端重新发出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_少,等待watch事件 5. else **delete( n )**if 仍然存在并等待 L 中的最低进程节点
6. else create(b + ''/ready'', REGULAR) 6.转到1

进入时,所有进程都在一个就绪节点上观察,并创建一个临时节点作为屏障节点的子节点。除最后一个进程外,每个进程都进入屏障并等待就绪节点出现在第 5 行。创建第 x 个节点的进程,即最后一个进程,将在子列表中看到 x 个节点并创建就绪节点,唤醒其他过程。请注意,等待进程仅在退出时才会唤醒,因此等待是有效的。

在退出时,您不能使用诸如ready之类的标志,因为您正在等待进程节点消失。通过使用临时节点,在进入屏障后失败的进程不会阻止正确的进程完成。当进程准备离开时,它们需要删除它们的进程节点并等待所有其他进程也这样做。

当没有进程节点作为b的子节点时,进程退出。但是,为了提高效率,您可以使用最低的进程节点作为就绪标志。准备退出的所​​有其他进程监视最低的现有进程节点离开,最低进程的所有者监视任何其他进程节点(为简单起见选择最高的)离开。这意味着每次删除节点时只有一个进程会唤醒,除了最后一个节点,它会在删除时唤醒所有人。

队列

分布式队列是一种常见的数据结构。在 ZooKeeper 中实现分布式队列,首先指定一个 znode 来保存队列,即队列节点。分布式客户端通过调用路径名以“queue-”结尾的 create() 将某些内容放入队列,并将 create() 调用中的序列临时标志设置为 true。因为设置了序列标志,所以新路径名的格式为 path-to-queue-node /queue-X,其中 X 是一个单调递增的数字。想要从队列中移除的客户端调用 ZooKeeper 的getChildren()函数,使用watch在队列节点上设置为 true,并开始处理编号最小的节点。客户端不需要发出另一个getChildren(),直到它用完从第一个getChildren()调用获得的列表。如果队列节点中没有子节点,则阅读器等待监视通知再次检查队列。

笔记

ZooKeeper recipes 目录中现在存在一个 Queue 实现。这与发布工件的发布 - zookeeper-recipes/zookeeper-recipes-queue 目录一起分发。

优先队列

要实现优先级队列,您只需对通用队列配方进行两个简单的更改。首先,要添加到队列中,路径名以“queue-YY”结尾,其中 YY 是元素的优先级,数字越小表示优先级越高(就像 UNIX 一样)。其次,当从队列中移除时,客户端使用最新的子列表,这意味着如果针对队列节点触发了监视通知,客户端将使先前获得的子列表无效。

锁具

全局同步的完全分布式锁,这意味着在任何时间快照中,没有两个客户端认为他们持有相同的锁。这些可以使用 ZooKeeper 来实现。与优先级队列一样,首先定义一个锁节点。

笔记

ZooKeeper recipes 目录中现在存在一个 Lock 实现。这与发布工件的发布 - zookeeper-recipes/zookeeper-recipes-lock 目录一起分发。

希望获得锁的客户端执行以下操作:

  1. 使用路径名为“ locknode /guid-lock-”并设置序列临时标志调用create() 。如果缺少 create() 结果,则需要该guid 。请参阅下面的注释。
  2. 在不设置监视标志的情况下在锁定节点上调用getChildren()(这对于避免羊群效应很重要)。
  3. 如果在步骤1中创建的路径名具有最小的序列号后缀,则客户端具有锁定并且客户端退出协议。
  4. 客户端调用exists( )并在锁定目录中的路径上设置了监视标志,并具有下一个最低序列号。
  5. 如果exists( )返回null,则转到步骤2。否则,请等待上一步的路径名通知,然后再转到第2步。

解锁协议非常简单:希望释放锁的客户端只需删除他们在步骤 1 中创建的节点。

这里有几点需要注意:

可恢复的错误和 GUID

共享锁

您可以通过对锁协议进行一些更改来实现共享锁:

获取读锁: 获取写锁:
1. 调用create( )创建一个路径名为“ guid-/read- ”的节点。这是协议后面使用的锁节点。确保同时设置序列临时标志。1. 调用create( )创建一个路径名为“ guid-/write- ”的节点。这是协议后面提到的锁定节点。确保同时设置序列临时标志。
2.在锁定节点上调用getChildren()而不设置监视标志——这很重要,因为它避免了羊群效应。2.在锁定节点上调用getChildren()而不设置监视标志——这很重要,因为它避免了羊群效应。
3. 如果没有路径名以“ write- ”开头且序列号小于步骤1中创建的节点的子节点,则客户端拥有锁,可以退出协议。3.如果没有比步骤1创建的节点序号小的子节点,则客户端拥有锁,客户端退出协议。
4. 否则,调用exists( )并带有watch标志,在 lock 目录中的节点上设置,路径名以“ write- ”开头,具有下一个最小的序列号。4.在具有下一个最小序列号的路径名的节点上调用exists(),并设置监视标志。
5. 如果exists( )返回false,转到第2步。5. 如果exists( )返回false,转到第2步。否则,请等待上一步的路径名通知,然后再转到第2步。
6. 否则,请等待上一步的路径名通知,然后再进行第2步

笔记:

可撤销共享锁

通过对共享锁协议进行少量修改,您可以通过修改共享锁协议使共享锁可撤销:

在步骤1中,在获取读取器和写入器锁定协议中,在调用create()之后立即调用getData()并设置watch。如果客户端随后接收到它在步骤1中创建的节点的通知,它会在该节点上执行另一个getData(),并设置watch并查找字符串“unlock”,这会向客户端发出它必须释放锁的信号。这是因为,根据这个共享锁协议,您可以通过在锁节点上调用setData()请求持有锁的客户端放弃锁,将“解锁”写入该节点。

请注意,此协议要求锁持有者同意释放锁。这种同意很重要,特别是如果锁持有者需要在释放锁之前进行一些处理。当然,您始终可以通过在您的协议中规定,如果在一段时间后锁没有被锁持有者删除,则允许撤销者删除锁节点,从而使用怪异的激光束实现可撤销共享锁。

两阶段提交

两阶段提交协议是一种算法,它允许分布式系统中的所有客户端同意提交事务或中止。

在 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 消失时检查它们是否是新的领导者(请注意,如果领导者失败,最小的 znode 将消失,因为节点是短暂的)。但这会导致羊群效应:当当前leader失败时,所有其他进程都会收到通知,并在“/election”上执行getChildren,以获取“/election”的当前子节点列表。如果客户端数量很大,则会导致 ZooKeeper 服务器必须处理的操作数量激增。为了避免羊群效应,观察 znode 序列上的下一个 znode 就足够了。如果客户端收到它正在监视的 znode 已消失的通知,则在没有更小的 znode 的情况下它成为新的领导者。请注意,这通过不让所有客户端观看同一个 znode 来避免羊群效应。

这是伪代码:

让 ELECTION 成为应用程序的选择路径。自愿成为领导者:

  1. 使用 SEQUENCE 和 EPHEMERAL 标志的路径“ELECTION/guid-n_”创建 znode z;
  2. 设C为“ELECTION”的子代,I为z的序号;
  3. 注意“ELECTION/guid-n_j”的变化,其中 j 是最大的序列号,使得 j < i 并且 n_j 是 C 中的 znode;

收到 znode 删除通知后:

  1. 设 C 为 ELECTION 的新子集;
  2. 如果z是C中的最小节点,则执行leader procedure;
  3. 否则,请注意“ELECTION/guid-n_j”上的变化,其中 j 是最大的序列号,使得 j < i 并且 n_j 是 C 中的 znode;

笔记: