9.4 使用Zookeeper实现可扩展锁

在Zookeeper论文的结尾,讨论了如何使用Zookeeper解决非扩展锁的问题。有意思的是,因为Zookeeper的API足够灵活,可以用来设计另一个更复杂的锁,从而避免羊群效应。从而使得,即使有1000个客户端在等待锁释放,当锁释放时,另一个客户端获得锁的复杂度是O(1)O(1) 而不是O(n)O(n) 。这个设计有点复杂,下面是论文第6页中2.4部分的伪代码。在这个设计中,我们不再使用一个单独的锁文件,而是创建Sequential文件(详见9.1)。

CREATE("f", data, sequential=TRUE, ephemeral=TRUE)
WHILE TRUE:
    LIST("f*")
    IF NO LOWER #FILE: RETURN
    IF EXIST(NEXT LOWER #FILE, watch=TRUE):
        WAIT

在代码的第1行调用CREATE,并指定sequential=TRUE,我们创建了一个Sequential文件,如果这是以“f”开头的第27个Sequential文件,这里实际会创建类似以“f27”为名字的文件。这里有两点需要注意,第一是通过CREATE,我们获得了一个全局唯一序列号(比如27),第二Zookeeper生成的序号必然是递增的。

代码第3行,通过LIST列出了所有以“f”开头的文件,也就是所有的Sequential文件。

代码第4行,如果现存的Sequential文件的序列号都不小于我们在代码第1行得到的序列号,那么表明我们在并发竞争中赢了,我们获得了锁。所以当我们的Sequential文件对应的序列号在所有序列号中最小时,我们获得了锁,直接RETURN。序列号代表了不同客户端创建Sequential文件的顺序。在这种锁方案中,会使用这个顺序来向客户端分发锁。当存在更低序列号的Sequential文件时,我们要做的是等待拥有更低序列号的客户端释放锁。在这个方案中,释放锁的方式是删除文件。所以接下来,我们需要做的是等待序列号更低的锁文件删除,之后我们才能获得锁。

所以,在代码的第5行,我们调用EXIST,并设置WATCH,等待比自己序列号更小的下一个锁文件删除。如果等到了,我们回到循环的最开始。但是这次,我们不会再创建锁文件,代码从LIST开始执行。这是获得锁的过程,释放就是删除创建的锁文件。

学生提问:为什么重试的时候要在代码第3行再次LIST文件?

Robert教授:这是个好问题。问题是,我们在代码第3行得到了文件的列表,我们就知道了比自己序列号更小的下一个锁文件。Zookeeper可以确保,一旦一个序列号,比如说27,被使用了,那么之后创建的Sequential文件不会使用更小的序列号。所以,我们可以确定第一次LIST之后,不会有序列号低于27的锁文件被创建,那为什么在重试的时候要再次LIST文件?为什么不直接跳过?你们来猜猜答案。

答案是,持有更低序列号Sequential文件的客户端,可能在我们没有注意的时候就释放了锁,也可能已经挂了。比如说,我们是排在第27的客户端,但是排在第26的客户端在它获得锁之前就挂了。因为它挂了,Zookeeper会自动的删除它的锁文件(因为创建锁文件时,同时也指定了ephemeral=TRUE)。所以这时,我们要等待的是序列号25的锁文件释放。所以,尽管不可能再创建序列号更小的锁文件,但是排在前面的锁文件可能会有变化,所以我们需要在循环的最开始再次调用LIST,以防在等待锁的队列里排在我们前面的客户端挂了。

学生提问:如果不存在序列号更低的锁文件,那么当前客户端就获得了锁?

Robert教授:是的。

学生提问:为什么这种锁不会受羊群效应(Herd Effect)的影响?

Robert教授:假设我们有1000个客户端在等待获取锁,每个客户端都会在代码的第6行等待锁释放。但是每个客户端等待的锁文件都不一样,比如序列号为500的锁只会被序列号为501的客户端等待,而序列号500的客户端只会等待序列号499的锁文件。每个客户端只会等待一个锁文件,当一个锁文件被释放,只有下一个序列号对应的客户端才会收到通知,也只有这一个客户端会回到循环的开始,也就是代码的第3行,之后这个客户端会获得锁。所以,不管有多少个客户端在等待锁,每一次锁释放再被其他客户端获取的代价是一个常数。而在非扩展锁中,锁释放时,每个等待的客户端都会被通知到,之后,每个等待的客户端都会发送CREATE请求给Zookeeper,所以每一次锁释放再被其他客户端获取的代价与客户端数量成正比。

学生提问:那排在后面的客户端岂不是要等待很长的时间?

Robert教授:你可以去喝杯咖啡等一等。编程接口不是我们关心的内容,不过代码第6行的等待有两种可能,第一种是启动一个线程同步等待锁,在获得锁之前线程不会继续执行;第二种会更加复杂一些,你向Zookeeper发送请求,但是不等待其返回,同时有另外一个goroutine等待Zookeeper的返回,这跟前面介绍的AppCh(Apply Channel,详见6.6)一样,第二种方式更加常见。所以要么是多线程,要么是事件驱动,不管怎样,代码在等待的时候可以执行其他的动作。

学生提问:代码第5行EXIST返回TRUE意味着什么?

Robert教授:如果返回TRUE,意味着,要么对应的客户端还活着并持有着锁,要么还活着在等待其他的锁,我们不知道是哪种情况。如果EXIST返回FALSE,那么有两种可能:要么是序列号的前一个客户端释放了锁并删除了锁文件;要么是前一个客户端退出了,因为锁文件是ephemeral的,然后Zookeeper删除了锁文件。所以,不论EXIST返回什么,都有两种可能。所以我们重试的时候,要检查所有的信息,因为我们不知道EXIST完成之后是什么情况。

我第一次看到可扩展锁,是在一种完全不同的背景下,也就是在多线程代码中的可扩展锁。通常来说,这种锁称为可扩展锁(Scalable Lock)。我认为这是我见过的一种最有趣的结构,就像我很欣赏Zookeeper的API设计一样。

不得不说,我有点迷惑为什么Zookeeper论文要讨论锁。因为这里的锁并不像线程中的锁,在线程系统中,不存在线程随机的挂了然后下线。如果每个线程都正确使用了锁,你从线程锁中可以获得操作的原子性(Atomicity)。假如你获得了锁,并且执行了47个不同的读写操作,修改了一些变量,然后释放了锁。如果所有的线程都遵从这里的锁策略,没有人会看到一切奇怪的数据中间状态。这里的线程锁可以使得操作具备原子性。

而通过Zookeeper实现的锁就不太一样。如果持有锁的客户端挂了,它会释放锁,另一个客户端可以接着获得锁,所以它并不确保原子性。因为你在分布式系统中可能会有部分故障(Partial Failure),但是你在一个多线程代码中不会有部分故障。如果当前锁的持有者需要在锁释放前更新一系列被锁保护的数据,但是更新了一半就崩溃了,之后锁会被释放。然后你可以获得锁,然而当你查看数据的时候,只能看到垃圾数据,因为这些数据是只更新了一半的随机数据。所以,Zookeeper实现的锁,并没有提供类似于线程锁的原子性保证。

所以,读完了论文之后,我不禁陷入了沉思,为什么我们要用Zookeeper实现锁,为什么锁会是Zookeeper论文中的主要例子之一。

我认为,在一个分布式系统中,你可以这样使用Zookeeper实现的锁。每一个获得锁的客户端,需要做好准备清理之前锁持有者因为故障残留的数据。所以,当你获得锁时,你查看数据,你需要确认之前的客户端是否故障了,如果是的话,你该怎么修复数据。如果总是以确定的顺序来执行操作,假设前一个客户端崩溃了,你或许可以探测出前一个客户端是在操作序列中哪一步崩溃的。但是这里有点取巧,你需要好好设计一下。而对于线程锁,你就不需要考虑这些问题。

另外一个对于这些锁的合理的场景是:Soft Lock。Soft Lock用来保护一些不太重要的数据。举个例子,当你在运行MapReduce Job时,你可以用这样的锁来确保一个Task同时只被一个Work节点执行。例如,对于Task 37,执行它的Worker需要先获得相应的锁,再执行Task,并将Task标记成执行完成,之后释放锁。MapReduce本身可以容忍Worker节点崩溃,所以如果一个Worker节点获得了锁,然后执行了一半崩溃了,之后锁会被释放,下一个获得锁的Worker会发现任务并没有完成,并重新执行任务。这不会有问题,因为这就是MapReduce定义的工作方式。所以你可以将这里的锁用在Soft Lock的场景。

另一个值得考虑的问题是,我们可以用这里的代码来实现选举Master。

学生提问:有没有探测前一个锁持有者崩溃的方法?

Robert教授:还记录论文里说的吗?你可以先删除Ready file,之后做一些操作,最后再重建Ready file。这是一种非常好的探测并处理前一个Master或者锁持有者在半路崩溃的方法。因为可以通过Ready file是否存在来判断前一个锁持有者是否因为崩溃才退出。

学生提问:在Golang实现的多线程代码中,一个线程获得了锁,有没有可能在释放锁之前就崩溃了?

Robert教授:不幸的是,这个是可能的。对于单个线程来说有可能崩溃,或许在运算时除以0,或者一些其他的panic。我的建议是,现在程序已经故障了,最好把程序的进程杀掉。

在多线程的代码中,可以这么来看锁:当锁被持有时,数据是可变的,不稳定的。当锁的持有线程崩溃了,是没有安全的办法再继续执行代码的。因为不论锁保护的是什么数据,当锁没有释放时,数据都可以被认为是不稳定的。如果你足够聪明,你可以使用类似于Ready file的方法,但是在Golang里面实现这种方法超级难,因为内存模型决定了你不能依赖任何东西。如果你更新一些变量,之后设置一个类似于Ready file的Done标志位,这不意味任何事情,除非你释放了锁,其他人获得了锁。因为只有在那时线程的执行顺序是确定的,其他线程才能安全的读取Done标志位。所以在Golang里面,很难从一个持有了锁的线程的崩溃中恢复。但是在我们的锁里面,恢复或许会更加可能一些。

以上就是对于Zookeeper的一些介绍。有两点需要注意:第一是Zookeeper聪明的从多个副本读数据从而提升了性能,但同时又牺牲了一些一致性;另一个是Zookeeper的API设计,使得Zookeeper成为一个通用的协调服务,这是一个简单的put/get 服务所不能实现,这些API使你可以写出类似mini-transaction的代码,也可以帮你创建自己的锁。

最后更新于