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

在本文中,您将找到使用ZooKeeper实施高阶函数的准则。所有这些都是在客户端实施的约定,不需要ZooKeeper的特殊支持。

希望社区能够在客户端库中捕获这些约定,以简化它们的使用并鼓励标准化。

关于ZooKeeper的最有趣的事情之一是,即使ZooKeeper使用异步通知,您也可以使用它来构建同步一致性原语,例如队列和锁。

正如您将看到的,这是可能的,因为ZooKeeper对更新强加了总体顺序,并具有公开此顺序的机制。

请注意,以下食谱尝试采用最佳做法。

特别是,它们避免了轮询,计时器或其他任何会导致“群效应”的事情,从而导致流量爆发并限制了可伸缩性。

可以想象这里没有包含许多有用的功能-可撤销的读写优先级锁,仅作为示例。

尽管您可能会发现其他构造(例如事件句柄或队列),这是执行相同功能的更实用方法,但这里提到的某些构造(尤其是锁)说明了某些要点。

通常,本节中的示例旨在激发思想。

有关错误处理的重要说明

实施配方时,必须处理可恢复的异常。

特别地,一些配方采用顺序的临时节点。

创建顺序临时节点时,会出现错误情况,其中在服务器上create()成功,但服务器崩溃后才将节点名称返回给客户端。

客户端重新连接时,其会话仍然有效,因此不会删除该节点。这意味着客户端很难知道其节点是否已创建。

以下食谱包括解决此问题的措施。

开箱即用的应用程序:名称服务,配置,组成员身份

名称服务和配置是ZooKeeper的两个主要应用程序。

这两个功能由ZooKeeper API直接提供。

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

Barriers

分布式系统使用Barriers来阻止一组节点的处理,直到满足条件时才允许所有节点继续进行。

在ZooKeeper中通过指定屏障节点来实现屏障。如果Barriers节点存在,则Barriers就位。

这是伪代码:

  1. 客户端在屏障节点上调用ZooKeeper API的exist()函数,并将watch设置为true。

  2. 如果existing()返回false,则Barriers消失,客户端继续前进

  3. 否则,如果exist()返回true,则客户端将等待ZooKeeper的watch事件以了解屏障节点。

  4. 当监视事件被触发时,客户端重新发出exist()调用,再次等待直到屏障节点被移除。

双屏障

双屏障使客户端可以同步计算的开始和结束。

当足够多的进程加入Barriers后,进程将开始计算并在完成后离开Barriers。此食谱展示了如何使用ZooKeeper节点作为Barriers。

此配方中的伪代码将Barriers节点表示为b。每个客户端进程p在进入时向屏障节点注册,并在准备离开时注销。节点通过下面的Enter步骤在屏障节点中注册,它等待直到x客户端进程注册后,才能继续进行计算。 (这里的x由您决定,取决于您的系统。)

Enter	                                                    Leave
1. Create a name n = b+“/”+p	                            1. L = getChildren(b, false)
2. Set watch: exists(b + ‘‘/ready"", true)	                2. if no children, exit
3. Create child: create(n, EPHEMERAL)	                    3. if p is only process node in L, delete(n) and exit
4. L = getChildren(b, false)	                            4. if p is the lowest process node in L, wait on highest process node in L
5. if fewer children in L than_x_, wait for watch event	    5. else **delete(n)**if still exists and wait on lowest process node in L
6. else create(b + ‘‘/ready"", REGULAR)	                    6. goto 1

进入时,所有进程都会监视就绪节点,并创建一个临时节点作为屏障节点的子节点。

除最后一个进程外,每个进程都进入障碍并等待就绪节点出现在第5行。

创建第x个节点的进程(最后一个进程)将在子级列表中看到x个节点并创建就绪节点,从而唤醒该进程。

其他过程。请注意,等待进程仅在需要退出时才唤醒,因此等待非常有效。

在退出时,您不能使用诸如就绪的标志,因为您正在监视流程节点消失。通过使用临时节点,进入屏障后失败的过程不会阻止正确的过程完成。当流程准备好离开时,它们需要删除其流程节点并等待所有其他流程执行相同的操作。

当没有剩余的流程节点作为b的子代时,流程退出。但是,为了提高效率,可以将最低的进程节点用作就绪标志。准备好退出的所有其他进程都在监视最低的现有进程节点,而最低进程的所有者则在监视其他任何进程节点(为简单起见,选择最高的进程)。

这意味着在删除每个节点时,只有一个进程会被唤醒,最后一个节点除外,最后一个节点会在删除后唤醒所有人。

Queues

分布式队列是一种常见的数据结构。

要在ZooKeeper中实现分布式队列,请首先指定一个znode来保存该队列,即队列节点。

分布式客户端通过调用路径名以“queue-”结尾的create()将某些东西放入队列中,并且create()调用中的序列和短暂标志设置为true。

因为设置了序列标志,所以新路径名的格式将为 path-to-queue-node/queue-X,其中X是单调递增的数字。

想要从队列中删除的客户端调用ZooKeeper的getChildren()函数,在队列节点上将watch设置为true,然后开始处理编号最小的节点。在客户端用尽从第一个getChildren()调用获得的列表之前,客户端不需要发出另一个getChildren()。如果队列节点中没有子节点,则读取器等待监视通知以再次检查队列。

  • 笔记

现在,ZooKeeper配方目录中存在一个Queue实现。

这与发布工件一起发布- zookeeper-recipes/zookeeper-recipes-queue 目录。

优先级队列

要实现优先级队列,您只需对通用队列配方进行两项简单更改。

首先,要添加到队列中,路径名以“queue-YY”结尾,其中YY是元素的优先级,数字越小表示优先级越高(就像UNIX)。

其次,从队列中删除时,客户端使用最新的子列表,这意味着如果监视通知触发了队列节点,则客户端将使先前获取的子列表无效。

Locks

全局同步的完全分布式锁,这意味着在任何时间的快照中,没有两个客户端会认为它们拥有相同的锁。这些可以使用ZooKeeeper实现。

与优先级队列一样,首先定义一个锁定节点。

  • 笔记

现在,ZooKeeper配方目录中存在一个Lock实现。

这与发布工件一起发布-zookeeper-recipes/zookeeper-recipes-lock目录。

核心流程

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

  1. 用路径名“locknode/guid-lock-”调用create(),并设置序列和临时标志。如果错过了create()结果,则需要GUID。请参阅下面的注释。

  2. 在不设置监视标志的情况下,在锁定节点上调用getChildren()(这对于避免羊群效应很重要)。

  3. 如果在步骤1中创建的路径名具有最低的序列号后缀,则客户端具有锁定,并且客户端退出协议。

  4. 客户端使用在下一个最低序号的锁定目录中的路径上设置的监视标志来调用exist()。

  5. 如果existing()返回null,请转到步骤2。否则,请等待上一步的路径名通知,然后再转到步骤2。

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

注意事项

这里有一些注意事项:

  1. 删除一个节点只会导致一个客户端唤醒,因为每个节点正好由一个客户端监视。 这样,您可以避免羊群效应。

  2. 没有轮询或超时。

  3. 由于实现了锁定的方式,很容易看到锁定争用,中断锁定,调试锁定问题等的数量。

可恢复的错误和GUID

如果调用create()发生可恢复的错误,则客户端应调用getChildren()并检查包含路径名中使用的guid的节点。

这处理了在服务器上成功执行create()的情况(如上所述),但是服务器在返回新节点的名称之前崩溃了。

共享锁

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

获取读锁

  1. 调用create()创建一个路径名为”guid-/read-”的节点。 这是协议后面部分中使用的锁定节点。 确保同时设置顺序标志和临时标志。

  2. 在不设置监视标志的情况下在锁定节点上调用getChildren()-这很重要,因为它避免了成群效应。

  3. 如果没有子代的路径名以“write-”开头,并且序号比在步骤1中创建的节点低,则客户端具有锁定并可以退出协议。

  4. 否则,在路径目录中以路径名以下一个最低序号”write-”开头的,在监视目录中的节点上设置带有监视标志的存在()调用。

  5. 如果existing()返回false,请转到步骤2。

  6. 否则,请等待上一步的路径名通知,然后再执行步骤2

获取写锁

  1. 调用create()创建路径名为”guid-/write-”的节点。 这是协议后面提到的锁定节点。 确保同时设置顺序和临时标志。

  2. 在不设置监视标志的情况下在锁定节点上调用getChildren()-这很重要,因为它避免了成群效应。

  3. 如果没有子序号比在步骤1中创建的节点小的子代,则客户端具有锁,并且客户端退出协议。

  4. 在路径名次低的节点上调用设置了监视标志的存在()。

  5. 如果existent()返回false,请转到步骤2。否则,请等待上一步的路径名通知,然后再转到步骤2。

  • 笔记:

看来,此配方会产生成群的效果:当有一大批客户端在等待读锁时,当删除序号最低的”write-”节点时,所有客户端或多或少会同时收到通知。

实际上。这是正确的行为:由于所有等待的读取器客户端都具有锁定,因此应释放它们。放牧效果是指实际上只有一台或少量机器可以运行时释放“放牧”(herd)。

可撤销的共享锁

通过对共享锁协议进行较小的修改,可以通过修改共享锁协议使共享锁可撤销:

在获得读取器和写入器锁定协议的步骤1中,在调用create()之后立即调用带有watch set的getData()。

如果客户端随后收到其在步骤1中创建的节点的通知,它将在该节点上执行监视,并在该节点上执行另一个getData(),并查找字符串”unlock”,这向客户端发出信号,它必须释放锁定。这是因为,根据此共享锁协议,您可以通过在锁节点上调用setData()并向该节点写入“解锁”来请求具有该锁的客户端放弃该锁。

请注意,此协议要求锁持有人同意释放锁。这种同意很重要,尤其是在锁持有人需要在释放锁之前进行一些处理的情况下。

当然,您始终可以通过在协议中规定,如果在一段时间之后,锁持有者未删除该锁,则允许revoker删除锁节点,从而始终可以使用具有异常激光束的可撤消共享锁。

两阶段提交

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

在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的进程为leader。

但这还不是全部。重要的是要注意领导者的失败,以便在当前领导者发生故障的情况下,新客户会以新领导者的身份出现。

一个简单的解决方案是让所有应用程序进程监视当前最小的znode,并在最小的znode消失时检查它们是否是新的领导者(请注意,如果领导者失败,则最小的znode将消失,因为领导者是短暂的)。但是,这会导致成群的影响:在当前领导者失败后,所有其他进程都会收到通知,并在”/ election”上执行getChildren以获得”/ election”的子代列表。如果客户端数量很大,则会导致ZooKeeper服务器必须处理的操作数量激增。为了避免成群效应,足够注意znode序列上的下一个znode。如果客户端收到其正在监视的znode消失的通知,则在没有较小的znode的情况下它将成为新的领导者。请注意,通过避免让所有客户端都观看同一znode,这避免了羊群效应。

伪代码(pseudo code)

这是伪代码:

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

  1. 用SEQUENCE和EPHEMERAL标志创建路径为”ELECTION/guid-n_”的znode z;

  2. 令C为“选举”的子代,而i为z的序列号;

  3. 注意”ELECTION/guid-n_j”上的更改,其中j是最大的序列号,使得j < i,n_j是C中的znode;

收到znode删除通知后:

  1. 令C为选举的新子集;

  2. 如果z是C中最小的节点,则执行领导程序;

  3. 否则,请注意”ELECTION/guid-n_j”上的更改,其中j是最大的序列号,使得j < i,n_j是C中的znode;

  • 笔记

请注意,子节点列表中没有前面的znode的znode并不表示此znode的创建者知道它是当前的领导者。

应用程序可以考虑创建一个单独的znode来确认领导者已经执行了领导者过程。

参考资料

https://zookeeper.apache.org/doc/r3.6.2/zookeeperTutorial.html