Zookeeper常见问题和解决方案


Zookeeper高级架构指南

在本文中,你会看到使用Zookeeper实现高级功能的指导方案。他们全部约定在客户端实现且不需要特殊的Zookeeper支持。希望社会各界在客户端类库里遵守这些约定提高易用性和鼓励标准化。

Zookeeper最有趣的事情之一是虽然Zookeeper使用异步通知,你可以使用它构建同步一致性原件,如queues和locks。如你将看到的那样,这是可行的因为Zookeeper在updates上强加了整体的顺序,并且有暴漏这个顺序的机制。

注意在下面的示例尝试采用最佳实践。特别的是,他们避免轮训,定时和任何导致"羊群效应"的东西,导致传输破裂和限制伸缩性。

有很多可以想象的有用的功能但不包括在这里 - 可撤销的读写优先级锁,只是一个例子。这里提到的一些结构 - locks,特别的是 - 说明某些问题,虽然你可以找到其他设计,如事件处理或queues,执行相同方法更实际的方法。总之,这个章节的例子是为了激发想法。

开箱即用的应用程序:Name Service,Configuration,Group Membership

Name Service和configuration是Zookeeper的两个主要应用。这两个功能由Zookeeper API直接提供。

另一个由Zookeeper直接提供的功能是group membership。group由一个节点代表。group的成员在group节点下创建临时节点。Zookeeper检测到故障的时候自动删除失败的成员节点。

阻塞

分布式系统使用barriers阻塞一组节点的处理直到遇见某个条件的时候才允许所有节点继续下去。Barriers通过在Zookeeper指定一个barrier节点实现。如果barriers节点存在就在原位。这是假象的代码:

  • 客户端在阻塞节点上调用Zookeeper API的exists()方法,设置watch为true。
  • 如果exists()返回false,阻塞消失并且客户端继续。
  • 另外,如果exists()返回true,客户端等待Zookeeper阻塞节点的watch事件。
  • 当触发watch事件时,客户端重新调用exists(),再次等待直到阻塞节点移除。

双重阻塞

双重阻塞可让客户端同步一个计算的开始和结束。当足够的进程加入了阻塞,进程开启他们的计算并一旦完成就离开阻塞。这个方法展示了怎么使用Zookeeper节点作为一个阻塞。

这个方法的代码的阻塞节点为b。每个客户端进程 p 进入的时候注册到阻塞节点并在准备离开的时候注销。节点通过下面的Enter程序注册阻塞节点,它在开始运算之前等待直到x个客户端进程注册。(这里的x的值由你自己决定。)

Enter

  1. 生成节点名字 n = b+“/”+p
  2. 设置watch: exists(b + "/ready", true)
  3. 创建子节点: create( n, EPHEMERAL)
  4. L = getChildren(b, false)
  5. 如果L小于x,等待watch事件
  6. 否则create(b + "/ready", REGULAR)

Leave

  1. L = getChildren(b, false)
  2. 如果没有子节点,退出
  3. 如果p只是L的进程节点,删除并退出
  4. 如果p是L的最小进程节点,等待L里最高的进程节点。
  5. 否则删除如果仍然存在等待最小的进程节点
  6. goto 1

进入的时候,所有的进程在ready节点上设置watch并创建一个临时节点作为阻塞节点的子节点。除了最后一个之外的每个进程进入阻塞并等待ready节点(在第5行)。进程创建第x个节点,最后的进程,将看到子节点列表里的x节点并创建ready节点,唤醒其他进程。注意等待的进程只在退出的时候唤醒,所以等待是高效的。

退出时,你不能使用ready这样的标记因为你正在监测进程节点离开。通过使用临时节点,阻塞之后的失败进程已经进入不阻止正确进程的完成。当进程准备好离开时,他们需要删除他们的进程节点并等待其他的进程做同样的事情。

当没有进程子节点存在时进程退出。然而,作为一个效率,你可以使用最低的进程节点作为ready标记。所有其他准备退出的进程监测最低的存在的进程节点离开,并且最低进程的拥有者监测任何其他进程节点(简单起见选择最高的)离开。这意味着在每个节点删除只有单独的进程唤醒除了最后一个节点。当他删除时唤醒每一个。

队列

分布式队列是一个常见的数据结构。在Zookeeper里实现分布式队列,首先指定一个znode持有队列,也就是队列节点。分布式客户端通过调用create()放进队列一些东西,包括路径名以"queue-"结尾、序列,并且在调用create()的时候设置临时节点标记为true。因为设置了序列标记,新路径的形式将是_path-to-queue-node_/queue-X,这里的X是单调递增的数字。一个客户端想要从队列删除调用Zookeeper的getChildren()方法,在queue节点上设置watch为true,并且使用最小的数值开始处理节点。客户端不需要发出另外的getChildren()直到它耗尽从第一次调用getChildren()获取的列表。如果在队列节点没有子节点,读取者等待watch通知再次检查队列。

注意

现在在Zookeeper秘诀目录里存在队列实现。它在发布包里--src/recipes/queue目录

优先队列

实现一个优先队列,你只需要对一般的queue recipe做两个简单的修改。首先,添加进一个队列,路径结尾是"queue-YY"这里的YY是元素的优先级,数值越低,优先级越大。第二,当从队列移除时,如果触发了队列节点的watch通知,客户端使用最新的子列表,意味着客户端将原来获得的子列表置为无效。

完整的分布式锁是全局同步的,意思是在任何时间点没有两个客户端持有相同的锁。他们可以使用Zookeeper实现。就像优先级队列,首先定义一个lock节点。

注意

现在在Zookeeper秘诀目录里有锁的实现。这是发布包里--src/recipes/lock

客户端想要获得一个锁要做以下事情:

  1. 调用create()方法,参数是"_locknode_/lock_"的路径名、序列和临时节点标记
  2. 在lock节点上调用getChildren()而不设置watch标记(这对避免羊群效应非常重要)
  3. 如果在第一步创建的路径名有最小序列值的后缀,客户端获得lock并退出协议
  4. 客户端在lock目录的下一个最小序列值的路径上使用watch标记调用exists()
  5. 如果exists()返回false,进入第二步。否则,进入第二步之前等待上一步路径的通知。

解锁协议非常简单:客户端想要释放锁只需要简单的删除第一步创建的节点即可。

这里有一些要注意的事情:

  • 一个节点的删除将会只引起一个客户端唤醒因为每个节点精确到一个客户端监测。用这种方法,你避免了羊群效应。
  • 没有轮训和超时。
  • 因为你实现锁的方式,非常容易看到竞争锁的成员、打破锁、调试锁问题等等。

共享锁

你在锁定协议上做一些改变就可以实现共享锁:

获得一个读取锁:

  1. 调用create()方法创建一个"_locknode_/read-"的节点。这是稍后在协议里使用的lock节点。确保同时设置sequence和ephemeral标记。
  2. 在lock节点上调用getChildren()方法而不设置watch标记 - 这非常重要,因为它避免羊群效应。
  3. 如果没有"write-"开头的子节点并且有一个比第一步创建的节点更小的序列号,客户端获得lock并可以离开协议。
  4. 否则,调用exists(),使用watch标记,设置在lock目录的"write-"开头下一个更小序列号的子节点上。
  5. 如果exists()返回false,进入第二步。
  6. 否则,在进入第二步之前等待在上一步pathname的通知。

获得写入锁:

  1. 调用create()创建一个"_locknode_/write-"的节点。这是协议里稍后说的lock节点。确保同时设置sequence和ephemeral标记。
  2. 不设置watch标记调用getChildren()方法 - 这非常重要,因为它避免羊群效应。
  3. 如果没有序列号小于第一步设置节点序列号的子节点,客户端得到锁并退出协议。
  4. 调用exists(),使用watch标记,设置在下一个更小序列号的节点上。
  5. 如果exists()返回false,进入第二步。否则,在进入第二步之前等待上一步pathname的通知。

注意

这个秘诀很可能创建一个羊群效应:当有一大批客户端等待一个读取锁,并且当最小序列号的"write-"节点被删除的同时或多或少的获得通知实际上。这是有效的行为:因为所有的等待的读者客户端应该释放因为他们有锁。羊群效应是指发布一个"herd"事实上只有一个或少量机器可以继续

可恢复的共享锁

稍微的修改一下共享锁协议,就可以实现可恢复的共享锁:

在reader和wirter锁协议的第一步,使用watch设置调用getData,然后立刻调用create()。如果客户端随后接收到在第一步创建的节点的通知,在那个节点上再次调用getData(),设置watch并查找字符串"unlock",它发送信号给客户端必须释放锁。这是因为,按照这个共享锁协议,你可以通过在lock节点调用setData()要求客户端放弃锁,往那个节点写入"unlock"。

注意这个协议要求锁的持有者同意释放锁。这样的同意非常重要,特别是如果锁的持有者需要在释放锁之前做一些处理的时候。当然你可以一直实现可恢复的共享锁通过在协议里规定允许撤销者删除lock节点如果lock持有者在一定的时间之后还没有删除lock。

两阶段提交

两阶段提交协议是一个让分布式系统里所有客户端同意提交或回滚事务的算法。

在Zookeeper里,你可以通过一个事务调节员节点实现两阶段提交,叫做"/app/Tx",并且每个参与者叫做"/app/Tx/s_i"。协调者创建子节点时,它让内容定义。一旦事务里涉及到的每个参与者从协调者接收到事务,参与者读取每个子节点并设置watch。然后每个参与者处理查询并通过写入他们各自节点投票"commit"或"abort"。一旦写入完成,就通知其他参与者,并且如果所有参与者投完票,他们可以决定"abort"或"commit"。注意一个节点提早可以决定"abort"。

这个实现有趣的一面是协调者的唯一角色是决定参与者,创建Zookeeper节点,并传到事务到相应的参与者。事实上,甚至传播事务可以通过Zookeeper写入事务节点实现。

在上面的描述中有两个重要的缺陷。一个是消息复杂性,它是O(n²)。第二个是通过临时节点不能检测失败。使用临时节点发现参与者的失败,参与者创建节点是非常必要的。

解决第一个问题,你可以只得到事务节点变化的通知,然后通知参与者一旦协调者达到一个决定。注意这个方法是可以扩展的,但他是很慢的,因为它要求所有通讯经过协调者。

解决第二个问题,你可以让协调者传播事务到参与者,并让每个参与者创建他们自己的临时节点。

领导者选举

使用Zookeeper做领导人选举的简单方法是创建代表客户端的"proposals"的节点的时候使用SEQUENCE|EPHEMERAL标记。方法是有一个节点,叫做"/election",这样每个节点创建一个子节点"/election/n_"同时加上SEQUENCE|EPHEMERAL标记。sequence标记,Zookeeper自动的追加比之前子节点更高的序列号。最小序列号的是领导者。

这还不是全部。重要的是检测领导者的故障,以便于在当前领导者故障的时候选举新领导者。一般的解决方案是在所有的应用进程检测当前最小的节点,并当最小节点走开时检测它是不是新领导者(注意因为节点是临时的,如果领导者故障最小的节点会消失)。但是这会引起一个羊群效应:当前领导者故障后,其他所有进程接收到一个通知,并在"/election"上执行getChildren()获得当前"/election"的子节点列表。如果客户端的数量非常大,它会引起Zookeeper服务处理操作的高峰。避免羊群效应,检测节点序列的下一个节点就够了。如果客户端接收一个节点通知,然后在这个没有更小节点的案例中,它变成新的领导者。注意这通过所有客户端检测相同节点避免羊群效应。

这是假想的代码:

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

  1. 创建节点z,路径是"ELECTION/n_"同时指定SEQUENCE和EPHEMERAL标记;
  2. 让C成为"ELECTION"的子节点,并且i是z节点的序列号;
  3. 检测"ELECTION/n_j"的变化,这里的j是最大序列号,这里j小于i并且n_j是C里的节点。

接收节点删除的通知:

  1. 让C成为ELECTION的新子节点;
  2. 如果z是C里的最小节点,然后执行领导者过程;
  3. 否则,检测"ELECTION/n_j"的变化,这里的j是最大序列号,这里j小于i并且n_j是C里的节点。

注意子节点列表里没有之前的节点并不意味着节点的创建者知道他是当前的领导者。应用可能考虑创建一个单独的节点去通知领导者已经执行了领导者选举过程。

马军伟
关于作者 马军伟
写的不错,支持一下

先给自己定个小目标,日更一新。