# 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的代码，也可以帮你创建自己的锁。


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET 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?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
