> For the complete documentation index, see [llms.txt](https://mit-public-courses-cn-translatio.gitbook.io/mit6-824/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://mit-public-courses-cn-translatio.gitbook.io/mit6-824/lecture-09-more-replication-craq/9.4-shi-yong-zookeeper-shi-xian-ke-kuo-zhan-suo.md).

# 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的返回，这跟前面介绍的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的代码，也可以帮你创建自己的锁。
