MIT6.824/lecture-09-more-replication-craq/9.4-shi-yong-zookeeper-shi-xian-ke-kuo-zhan-suo.md
2022-01-25 02:41:31 +00:00

11 KiB
Raw Permalink Blame History

9.4 使用Zookeeper实现可扩展锁

在Zookeeper论文的结尾讨论了如何使用Zookeeper解决非扩展锁的问题。有意思的是因为Zookeeper的API足够灵活可以用来设计另一个更复杂的锁从而避免羊群效应。从而使得即使有1000个客户端在等待锁释放当锁释放时另一个客户端获得锁的复杂度是$$O(1) 而不是$$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的返回这跟前面介绍的AppChApply 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的代码也可以帮你创建自己的锁。