ZooKeeper食谱(八)
使用ZooKeeper构造高级别应用的指南
在这个文章中,你将会发现使用ZooKeeper来实现高级别功能的指南。所有的它们在客户端上被实现而不需要ZooKeeper特别的支持.希望社区将注意到这些约定在客户端库里来方便他们的使用并且促进标准化。
其中一个关于ZooKeeper最有趣的事是尽管ZooKeeper使用异步的通知,你可以使用它构造同步的一致性原语,例如队列和锁。正如你将要看到的一样,这是可能的因为ZooKeeper对更新强加了一个整体的顺序,和暴躁这个顺序的机制。
注意下面的食谱试图实用最挂实践。特别地,他们避免投票,定时器或者任何其它导致产生“羊群效应”的造成突发的事故和限制扩展性。
有很多可以被想到的有用的功能没有包含在这 - 可撤销的读-写优先锁,只有一个例子。并且这里提到的一些构件 - 特别是锁 - 阐明了特定的观点,尽管你可能找到其它构件,例如事件处理或队列,执行相同功能的一个更实用的方法。通常,这部分的例子被设计用来刺激思想。
关于错误处理的重要注意事项
当实现这些食谱你必须处理可恢复的异常(参考 FAQ)。特别地,一些食谱利用了顺序的短暂节点。当创建一个顺序的短暂节点时,有一个错误的案例,当create()在服务端成功了但是在返回给客户端这个节点的名字之前服务端挂掉了。当客户端重新连接它的会话仍然是有效的并且,因此,这个节点没有被删除。这个意思是对客户端来说它很难知道这个节点有没有被创建。下面的食谱包含了处理这种情况的方法。
取出即可用的应用:命名服务,配置,集群管理
命名服务和配置管理是ZooKeeper的两个主要应用。这两上功能被ZooKeeper API直接提供。
另一个被ZooKeeper直接提供的是集群管理。组被表示为了一个节点。组中的成员在组节点下创建短暂的节点。不正常地失败的组中的节点将被自动地删除当ZooKeeper检测到失败。
屏障
分布式的系统使用屏障来一组节点的进程,直到遇到满足的条件的时候所有节点才被允许继续执行。在ZooKeeper中屏障通过指定一个屏障节点来实现。障碍在路上如果屏障节点存在。下面的它的伪代码:
- 客户端在屏障节点上调用ZooKeeper API的exists()方法,并且设置 watch 为true.
- 如果exists()返回false,屏障消失并且客户端继续。
- 否则,如果exists()返回true,客户端等待屏障节点的事件。
- 当监听事件被触发,客户端再次调用exists()方法,再次等待直到屏障节点被删除。
两重屏障
两重屏障使客户端同步一个计算的开始和结束。当足够的进程进入到屏障,进程开始他们的计算,并且一旦完成就离开屏障。这个食谱展示了怎么作为屏障使用ZooKeeper节点。
这个食谱中的伪代码用b来表示一个屏障节点。每一个客户端进程 p 在进入的时候注册到屏障节点上并且在离开的时候取消注册。一个节点通过下面的Enter过程来注册到屏障节点上,在继续计算之前它等待直到 x 客户端进程完成注册(这里的x取决于你的系统)。
Enter | Leave |
1. Create a name n = b+“/”+p 2. Set watch: exists(b + ‘‘/ready’’, true) 3. Create child: create( n, EPHEMERAL) 4. L = getChildren(b, false) 5. if fewer children in L than x, wait for watch event 6. else create(b + ‘‘/ready’’, REGULAR) |
1. L = getChildren(b, false) 2. if no children, exit 3. if p is only process node in L, delete(n) and exit 4. if p is the lowest process node in L, wait on highest process node in L 5. else delete(n) if still exists and wait on lowest process node in L 6. goto 1 |
当进入的时候,所有进程监视一个准备好的节点,并且创建一个短暂节点作为屏障节点的子节点,每一个进程但是最后进入的屏障,并且等待准备的节点出现在第5行。创建第x个节点的进程,也就是最后一个节点。将看到x个节点在孩子列表中。并且创建准备结点。然后唤醒其它进程。注意等待进程只有在退出的时候才唤醒。所以等待是高效的。
在退出的时候。你不能使用一个标示,例如ready,因为你正在等待处理的节点离开。通过使用短暂节点。在进入屏障之后失败的进程不能阻止正确的进程结束。当进程准备离开的时候。它们必须删除进程节点并且等待其它进程做同样的事情。
进程退出当没有进程节点作为b的孩子节点的时候。然而,为了效率,你可以使用最底的进程节点作为准备标示。准备退出的所有其它进程监视最底的退出节点离开。并且最底节点的拥有者监视任一其它节点(为了简单选择最高的)的离开。这意为只一个节点唤醒当一个节点删除的时候除了最后一个节点删除。最后节点删除的时候所有节点被唤醒。
队列
分布式队列是一个普遍的数据结构。为了在ZooKeeper中实现一个分布式队列。首先指定一个znode持有队列。队列节点。分布式的客户端放一些东西进入队列通过用以queue-结尾的路径,序号和为短暂的标示true调用create()。因为序号的标示被设置。新的路径名字的形式为_path-to-queue-node_/queue-X,这里的X是自动增加的数字。一个想要从队列中被删除的节点调用ZooKeeper 的getChildren( ) 函数。在队列中的节点都设置了监视器。并且开始处理最小数值的节点。客户端不需要发起另一个getChildren( )直到消耗到第一次调用getChildren( )返回的列表。如果没有孩子在队列节点中。读进程等待一个监视通知来再次检查队列。
注意
现在有一个队列实现在ZooKeeper食谱目录下。它在发行版本的src/recipes/queue 目录下。
优先队列
为了实现优先队列,你只需要在通用的队列上做两个小的修改。首先,为了增加一个队列,路径名字以"queue-YY"结尾,这里的YY是元素的优先级,而且最小的数有最高的优先级(就像UNIX)。第二,当从队列中删除的时候。客户端使用最新的孩子列表意为着客户端将使先前获取的孩子列表无效如果触发了这个队列节点的监听通知。
锁
完全分布式锁是全局同步的。意为着在任何时间的快照中没有两个客户认为它们持有相同的锁。这些可以实现使用ZooKeeper。就像优先队列,先定义一个锁节点。
注意现在有一个锁实现在ZooKeeper食谱目录下,它在发行版本的src/recipes/queue 目录下。
客户端想获取锁需要做下面的事情:
- 以参数"_locknode_/guid-lock-"同时设置sequence和ephemeral标识来调用create()。需要一个guid以防create()丢失。参考下面的注意事项。
- 在锁节点上调用getChildren()而不设置监视标识(这对避免羊群效应很重要)
- 如果在步骤1中创建的路径名有最小的顺序数字后缀,客户端拥有这个锁并且客户端退出协议。
- 客户端调用exists()并在锁目录中的下一个最小序列数字的路径设置监视标识。
- 如果exists()返回false,跳到第2步。否则等待从前一个步骤中得到路径名的通知在进入步骤2之前。
解锁协议是非常简单的:将要释放锁的客户端简单地删除他们在步骤1中创建的节点。
这里需要几件事需要注意:
- 一个节点的删除将只引起一个客户端唤醒因为每一个节点恰恰被一个客户端监视着。用这种方式你避免了羊群效应。
- 这里没有投票或超时。
- 因为你实现锁的方式,很容易看到锁竞争的数量,打断锁,调试锁问题,等等。
可重新覆盖的错误和GUID
- 如果在调用create()的时候出现可重新覆盖的错误,客户端应该调用getChildren()并且检查包含用来在路径名的guid的节点。这处理在服务端create()成功但是在返回新节点的名字之前服务端崩溃的情况。
共享锁
你可以实现共享锁通过对锁协议做一些小的改变:
获取读锁 | 获取写锁 |
1. 调用create()来创建一个路径名 "guid-/read-"的为节点。这个在后来的协议中使用锁节点。确保设置了sequence 和 ephemeral标记。 2. 在节锁节点上调用getChildren(),而不设置监视标识 - 它很重要,它避免羊群效应。 3. 如果没有一个路径名以"write-"开头的孩子并且有比步骤1创建的节点小的序列号,那么客户端就拥有了锁并且可能退出协议。 4. 否则,调用监视标识的exists(),在有最小的序列号并且路径名是以"write-"开头的节点上设置。 5. 如果exists()返回 false,跳到步骤2。 6. 否则,在跳到步骤2之前等待来自上一个步骤中的路径名的通知。 |
1. 调用create()来创建一个路径名为"guid-/write-"的节点。这是锁节点。确保设置了sequence 和 ephemeral标记。 2. 在锁节点上调用getChildren( )方法而不设置监视标记 - 它很重要,它避免羊群效应。 3. 如果没有孩子节点的序列号比步骤1中创建的节点低,那么客户端就获取锁并且客户端退出这个协议。 4. 调用exists(),在下一个最小的序列号的节点上设置监视标识。 5. 如果exists() 返回false,跳到步骤2。否则,在跳到步骤2之前等待来自上一个步骤中的路径名的通知。 |
注意:
- 这个食谱它可能出现羊群效应:当很多组的客户端正在等待读锁,在最小序列号的"write-"节点被删除的时候,这些客户端几乎同时获取到了通知。实际上,这是合法的行为:因为所有这些等待读的客户端应该被释放因为它们拥有锁。羊群效应是指释放一个"羊群"当实际上只有一个或一小部分机器可能处理的时候。
- 参考note for Locks关于怎么使用guid
可撤消的共享锁
对共享锁做一些小的修改,通过修改共享锁你可以使用锁可撤销。
在步骤1中,同时获取了读和写锁,在调用create()后立刻调用getData()并设置监视器,如果客户端随后收到在步骤1中创建的节点的通知,它做另一个getData()在这个节点上,同时设置监视器并且搜寻字符串“unlock”,这表示客户端必须释放锁。这是因为根据共享锁的协议,你可以要求拥有锁的客户端放弃锁通过在被锁的节点上调用setData(),并写“unlock”到这个节点上。
注意这个协议要求锁持有者同意释放锁。这样的同意是很重要的,特别是如果锁持有者需要在释放锁之前做一些处理。当然你可以一直实现带着该死的激光束可撤消的锁通过在你的协议中规定撤消者被允许删除锁节点如果在一段时间过后锁没有被锁持有者删除。
两段式提交
一个两段式提交协议是一种逻辑,它让所有在分布式系统中的的客户端同意要么提交事务要么取消事务。
在ZooKeeper中,你可以实现一个两段式提交通过用一个协调者创建一个事务节点,比如"/app/Tx",每一个参与的站点的孩子节点,比如,"/app/Tx/s_i"。当协调者创建孩子节点,它没有设置内容。一旦每一个事务中的一方从协调者收到事务,站点读每一个孩子节点,并且设置一个监视器。每一个站点然后处理查询 和投票“提交”和“取消”通过写它各自的节点。一旦写完成,其它站点被通知,并且一旦所有的站点者投票,他们可以决定是提交 还是取消。注意一个节点可早点决定取消如果一些站点投票取消。
这个实现的一个有趣的地点是只有协调的角色来根据站点组来决做决定,为了创建ZooKeeper节点,为了传播事务到相应的站点。实际上,尽管传播事务事务可以被完成融通ZooKeeper通过写它在事务节点上。
上面论讨的方法有两个缺点。一个是消息的复杂度,它是O(n²)。第二个是通过临时节点检测站点失效的不可能性。为了使用临时节点来检测站点失效,它必须站点创建这个节点。
为了解决第一个问题,只可以只能让协调者被通知事务节点的改变,并且通知节点一旦协调者收到一个决定。注意这个方法是可扩展的,但是它也更慢,因为它要求所有的通信通过协调者。
为了解决第二个问题,你可以使协调者传播事务到站点,并且使每一个站点创建它的临时节点。
领导者选举
ZooKeeper做领导者选择的一个简单的方法是当创建代表揭底客户端的节点时使用SEQUENCE|EPHEMERAL标记。这个思路是有一个znode,比如 “/election",每一个znode创建一个带着SEQUENCE|EPHEMERAL孩子节点"/election/guid-n_",有了顺序标记,ZooKeeper自动地附加一个比选择增加的都大的数字到一个"/election"的孩子上面。创建的最小顺序号的节点就是领导者。
这还是全部,监听领导者的失效是很重要的,所以在当前领导者失效的情况下一个新的客户端被选举出来。一个复杂的解决办法是使所有的应用进程监听当前最小的节点,并且检查他们是不是新的领导者当最小的znode消失的时候(注意最小的znode将消失,如果领导者失败,因为这个节点是临时节点)。但是这导致一个羊群效应:一旦当前领导者失败,所有其它里程收到 一个通知,然后执行"/election" 的getChildren的方法 来获取孩子列表。如果客户端的数字是大的,它导致ZooKeeper服务端不得不处理很多次数字操作。为了避免羊群效应,只监听在znodes序列的下一个节点就足够了。如果一个客户端收到它监听的节点消失了,那么它变为新的领导,一旦没有更小的节点的情况下。注意到这避免了羊群效应通过不是所有的客户端监听同一个节点。
这里是它的伪代码:
假设ELECTION是应用的选择的路径。志愿成为一个领导者:
- 创建znode z,它的路径是"ELECTION/guid-n_",并且设置SEQUENCE 和 EPHEMERAL 标识;
- 让C成为"ELECTION"的孩子,并且i是z的序号;
- 监听"ELECTION/guid-n_j"的改变,这里的j是最小(官网上说是最大(largest)的应该是错的)的序序号,这样j<i并且 n_j是C中的节点;
一旦收到 节点删除的通知:
- 假如C是ELECTION的孩子节点
- 如果z是C中最小的节点,那么执行选举过程;
- 否则,监听"ELECTION/guid-n_j"的改变,这里的j是最小(官网上说是最大(largest)的应该是错的)的序序号,这样j<i并且 n_j是C中的节点;
注意:
- 注意一个节点前端没有节点不意为着这个节点的创建者意识到它是当前的领导者。应用可以考虑创建一个单独的znode来通知领导者已经执行了领导选举。
- 参考 note for Locks关于怎么使用节点中的guid