ZooKeeper 深入浅出之六:Zookeeper 应用程序三(锁服务和其他)
锁服务
分布式锁用来为一组程序提供互斥机制。任意一个时刻仅有一个进程能够获得锁。分布式锁可以用来实现大型分布式系统的 leader 选举算法,即leader就是获取到锁的那个进程。
注意: 不要把 ZooKeeper 的原生 leader 选举算法和我们这里所说的通用 leader 选举服务搞混淆了。ZooKeeper 的原生 leader 选举算法并不是公开的算法,并不能向我们这里所说的通用 leader 选举服务那样,为一个分布式系统提供主进程选举服务。
为了使用 ZooKeeper 实现分布式锁,我们使用可排序的 znode 来实现进程对锁的竞争。
思路其实很简单:首先,我们需要一个表示锁的 znode,获得锁的进程就表示被这把锁给锁定了(命名为 /leader
)。然后,client 为了获得锁,就需要在锁的 znode 下创建 ephemeral 类型的子 znode。在任何时间点上,只有排序序号最小的 znode 的 client 获得锁,即被锁定。
例如,如果两个 client 同时创建 znode /leader/lock-1
和 /leader/lock-2
,所以创建 /leader/lock-1
的 client 获得锁,因为他的排序序号最小。ZooKeeper 服务被看作是排序的权威管理者,因为是由他来安排排序的序号的。
锁可能因为删除了 /leader/lock-1
znode 而被简单的释放。另外,如果相应的客户端死掉,使用 ephemeral znode 的价值就在这里,znode 可以被自动删除掉。创建 /leader/lock-2
的 client 就获得了锁,因为他的序号现在最小。
当然客户端需要启动观察模式,在 znode 被删除时才能获得通知:此时他已经获得了锁。
获得锁的伪代码如下:
- 在 lock 的 znode 下创建名字为
lock-
的 ephemeral 类型 znode,并记录下创建的 znode 的 path(会在创建函数中返回)。 - 获取 lock znode 的子节点列表,并开启对 lock 的子节点的 watch 模式。
- 如果创建的子节点的序号最小,则再执行一次第2步,那么就表示已经获得锁了。退出。
- 等待第2步的观察模式的通知,如果获得通知,则再执行第2步。
羊群效应
虽然这个算法是正确的,但是还是有一些问题。第一个问题是羊群效应。试想一下,当有成千成百的 client 正在试图获得锁。每一个 client 都对 lock 节点开启了观察模式,等待 lock 的子节点的变化通知。每次锁的释放和获取,观察模式将被触发,每个 client 都会得到消息。
那么羊群效应就是指像这样,大量的 client 都会获得相同的事件通知,而只有很小的一部分 client 会对事件通知有响应。我们这里,只有一个 client 将获得锁,但是所有的 client 都得到了通知。那么这就像在网络公路上撒了把钉子,增加了 ZooKeeper 服务器的压力。
为了避免羊群效应,通知的范围需要更精准。我们通过观察发现,只有当序号排在当前 znode 之前一个 znode 离开时,才有必要通知创建当前 znode 的 client,而不必在任意一个 znode 删除或者创建时都通知 client。
在我们的例子中,如果 client1、client2 和 client3 创建了 znode /leader/lock-1
、/leader/lock-2
和 leader/lock-3
,client3 仅在 /leader/lock-2
消失时,才获得通知。而不需要在 /leader/lock-1
消失时,或者新建 /leader/lock-4
时,获得通知。
Recoverable exceptions
这个锁算法的另一个问题是没有处理当连接中断造成的创建失败。在这种情况下,我们根本就不知道之前的创建是否成功了。创建一个可排序的 znode 是一个非等幂操作,所以我们不能简单重试,因为如果第一次我们创建成功了,那么第一次创建的 znode 就成了一个孤立的 znode 了,将永远不会被删除直到会话结束。
那么问题的关键在于,在重新连接以后,client 不能确定是否之前创建过 lock 节点的子节点。我们在 znode 的名字中间嵌入一个 client 的 ID,那么在重新连接后,就可以通过检查 lock znode 的子节点 znode 中是否有名字包含 client ID 的节点。如果有这样的节点,说明之前创建节点操作成功了,就不需要再创建了。如果没有这样的节点,那就重新创建一个。
Client 的会话 ID 是一个长整型数据,并且在 ZooKeeper 中是唯一的。我们可以使用会话的 ID 在处理连接丢失事件过程中作为 client 的 id。在 ZooKeeper 的 JAVA API 中,我们可以调用 getSessionId()
方法来获得会话的 ID。
那么 Ephemeral 类型的可排序 znode 不要命名为 lock-<sessionId>-
,所以当加上序号后就变成了 lock-<sessionId>-<sequenceNumber>
。那么序号虽然针对上一级名字是唯一的,但是上一级名字本身就是唯一的,所以这个方法既可以标记 znode 的创建者,也可以实现创建的顺序排序。
Unrecoverable Exception
如果 client 的会话过期,那么他创建的 ephemeral znode 将被删除,client 将立即失去锁(或者至少放弃获得锁的机会)。应用需要意识到他不再拥有锁,然后清理一切状态,重新创建一个锁对象,并尝试再次获得锁。
注意,应用必须在得到通知的第一时间进行处理,因为应用不知道如何在 znode 被删除事后判断是否需要清理他的状态。
Implementation
考虑到所有的失败模式的处理的繁琐,所以实现一个正确的分布式锁是需要做很多细微的设计工作。好在 ZooKeeper 为我们提供了一个 产品级质量保证的锁的实现,我们叫做 WriteLock
。我们可以轻松的在 client 中应用。
更多的分布式数据结构和协议
我们可以用 ZooKeeper 来构建很多分布式数据结构和协议,例如,barriers,queues 和 two-phase commit。有趣的是我们注意到这些都是同步协议,而我们却使用 ZooKeeper 的原生异步特征(比如通知机制)来构建他们。
在 ZooKeeper 官网上提供了一些数据结构和协议的伪代码。并且提供了实现这些的数据结构和协议的标准教程(包括 locks、leader 选举和队列);你可以在 recipes 目录中找到。
Apache Curator project 也提供了一些简单客户端的教程。