# 9.2 使用Zookeeper实现计数器

我们来看看是如何使用这些Zookeeper API的。

第一个很简单的例子是计数器，假设我们在Zookeeper中有一个文件，我们想要在那个文件存储一个统计数字，例如，统计客户端的请求次数，当收到了一个来自客户端的请求时，我们需要增加存储的数字。

现在关键问题是，多个客户端会同时并发发送请求导致存储的数字增加。所以，第一个要解决的问题是，除了管理数据以外（类似于简单的SET和GET），我们是不是真的需要一个特殊的接口来支持多个客户端的并发。Zookeeper API看起来像是个文件系统，我们能不能只使用典型的存储系统的读写操作来解决并发的问题。

比如说，在Lab3中，你们会构建一个key-value数据库，它只支持两个操作，一个是PUT(K，V)，另一个是GET(K)。对于所有我们想要通过Zookeeper来实现的操作，我们可以使用Lab3中的key-value数据库来完成吗？或许我们真的可以使用只有两个操作接口的Lab3来完成这里的计数器功能。你可以这样实现，首先通过GET读出当前的计数值，之后通过PUT写入X + 1。

为什么这是一个错误的答案？是的，这里不是原子操作，这是问题的根源。

![](/files/-MEVC5EzPPjPMgfsmxM7)

如果有两个客户端想要同时增加计数器的值，它们首先都会先通过GET读出旧的计数器值，比如说10。之后，它们都会对10加1得到11，并调用PUT将11写入。所以现在我们只对计数器加了1，但是实际上有两个客户端执行了增加计数器的操作，而我们本应该对计数器增加2。所以，这就是什么Lab3甚至都不能用在这个最简单的例子中。

但是，Zookeeper自身也有问题，在Zookeeper的世界中，GET可能得到的是旧数据。而Lab3中，GET不允许返回旧的数据。因为Zookeeper读数据可能得到旧的数据，如果你得到了一个旧版本的计数器值，并对它加1，那么你实际会写入一个错误的数值。如果最新的数据是11，但是你通过Zookeeper的GET得到的是旧的数据10，然后你加了1，再将11写入到Zookeeper，这是一个错误的行为，因为我们实际上应该将12写入到Zookeeper中。所以，Zookeeper也有问题，我们必须要考虑GET得到的不是最新数据的情况。

所以，如何通过Zookeeper实现一个计数器呢？我会这样通过Zookeeper来实现计数器。你需要将这里的代码放在一个循环里面，因为代码不一定能在第一次执行的时候成功。我们对于循环加上`while true`，之后我们调用GETDATA来获取当前计数器的值，代码是`X，V = GETDATA(“f”)`，我们并不关心文件名是什么，所以这里直接传入一个“f”。

![](/files/-ME_OoKizvcHYvr6c2ne)

现在，我们获得了一个数值X，和一个版本号V，可能不是最新的，也可能是新的。之后，我们对于`SETDATA("f", X + 1, V)`加一个IF判断。如果返回true，表明它的确写入了数据，那么我们会从循环中跳出 `break`，如果返回false，那我们会回到循环的最开始，重新执行。

```
WHILE TRUE:
    X, V = GETDATA("F")
    IF SETDATA("f", X + 1, V):
        BREAK
```

在代码的第2行，我们从某个副本读到了一个数据X和一个版本号V，或许是旧的或许是最新的。而第3行的SETDATA会在Zookeeper Leader节点执行，因为所有的写操作都要在Leader执行。第3行的意思是，只有当实际真实的版本号等于V的时候，才更新数据。如果系统没有其他的客户端在更新“f”对应的数据，那么我们可以直接读出最新的数据和最新的版本号，之后调用SETDATA时，我们对最新的数据加1，并且指定了最新的版本号，SETDATA最终会被Leader所接受并得到回复说写入成功，之后就可以通过BREAK跳出循环，因为此时，我们已经成功写入了数据。但是，如果我们在第2行得到的是旧的数据，或者得到的就是最新的数据，但是当我们的SETDATA送到Zookeeper Leader时，数据已经被其他的客户端修改了，这样我们的版本号就不再是最新的版本号。这时，SETDATA会失败，并且我们会得到一个错误的回复，这样我们的代码不会跳出循环，我们会回到循环的最开始，重头开始再执行，并且期望这次能执行成功。

> 学生提问：这里能确保循环一定退出吗？
>
> Robert教授：不，我们这里并没有保证说循环一定会退出。例如在实际中，我们读取数据的副本与Leader失联了，并且永远返回给我们旧数据，那么这里永远都会陷在循环中。大部分情况下，Leader会使得所有的副本都趋向于拥有与Leader相同的数据。所以，如果我们第一次拿到的是旧的数据，在我们再次重试前，我们或许需要等待10ms。最终我们会看到最新的数据。
>
> 一种最坏的情况是，如果有1000个客户端并发请求要增加计数器，那么一次只有一个客户端可以成功。这1000个客户端中，第一个将SETDATA发到Leader的客户端可以成功增加计数，而其他的会失败，因为其他客户端持有的版本号已经过时了。之后，剩下的999个客户端会再次并发的发送请求，然后还是只有一个客户端能成功。所以，为了让所有的客户端都能成功计数，这里的复杂度是 $$O(n^2)$$ 。这不太好，但是最终所有的请求都能够完成。所以，如果你的场景中有大量的客户端，那么这里你或许要使用一个不同的策略。前面介绍的策略只适合低负载的场景。
>
> 学生提问：Zookeeper的数据都存在内存吗？
>
> Robert教授：是的。如果数据小于内存容量那就没问题，如果数据大于内存容量，那就是个灾难。所以当你在使用Zookeeper时，你必须时刻记住Zookeeper对于100MB的数据很友好，但是对于100GB的数据或许就很糟糕了。这就是为什么人们用Zookeeper来存储配置，而不是大型网站的真实数据。
>
> 学生提问：对于高负载的场景该如何处理呢？
>
> Robert教授：我们可以在SETDATA失败之后等待一会。我会这么做，首先，等待（sleep）是必须的，其次，等待的时间每次需要加倍再加上一些随机。这里实际上跟Raft的Leader Election里的Exponential back-off是类似的。这是一种适应未知数量并发客户端请求的合理策略。
>
> 学生提问：提问过程比较长，听不太清，大概意思就是想使用WATCH机制来解决上面的 $$O(n^2)$$ 的问题。
>
> Robert教授：首先，如果我们在GETDATA的时候，增加WATCH=true，那么在我们实际调用SETDATA时，如果有人修改了计数器的值，我们是可以收到通知的。
>
> 但是这里的时序并不是按照你设想的那样工作，上面代码的第2，3行之间的时间理论上是0。但是如果有一个其他客户端在我们GETDATA之后发送了增加计数的请求，我们收到通知的时间可能会比较长。首先那个客户端的请求要发送到Leader，之后Leader要将这个请求转发到Follower，Follower执行完之后Follower会查找自己的Watch表单，然后才能给我们发送一个通知。所以，就算我们在GETDATA的时候设置了WATCH，我们在SETDATA的时候，也不一定能收到其他客户端修改数据的通知。
>
> 在任何情况下，我认为WATCH不能帮助我们。因为1000个客户端都会做相同的事情，它们都会调用GETDATA，设置WATCH，它们都会同时获得通知，并作出相同的决定。又或许没有一个客户端可以得到WATCH结果，因为没有人成功的SETDATA了。所以，最坏的情况是，所有的客户端从一个位置开始执行，它们都调用GETDATA，得到了版本号为1，同时设置了WATCH。因为现在还没有变更，这一千个客户端都通过RPC发送了SETDATA给Leader。之后，第一个客户端更新了数据，然后其他的999个客户端才能得到通知，但是现在太晚了，因为它们已经发送了SETDATA。
>
> WATCH或许可以在这里帮到我们。接下来的Lock的例子解决了这里的问题。所以，我们可以采用论文中的第二个有关Lock的例子，在有大量客户端想要增加计数器时，使得计数器一次只处理一个客户端。

还有其他问题吗？

这个例子，其实就是大家常说的mini-transaction。这里之所以是事务的，是因为一旦我们操作成功了，我们对计数器达成了\_**读-更改-写**\_的原子操作。对于我们在Lab3中实现的数据库来说，它的读写操作不是原子的。而我们上面那段代码，一旦完成了，就是原子的。因为一旦完成了，我们的读，更改，写操作就不受其他任何客户端的干扰。

之所以称之为mini-transaction，是因为这里并不是一个完整的数据库事务（transaction）。一个真正的数据库可以使用完整的通用的事务，你可以指定事务的开始，然后执行任意的数据读写，之后结束事务。数据库可以聪明的将所有的操作作为一个原子事务提交。一个真实的事务可能会非常复杂，而Zookeeper支持这种非常简单的事务，使得我们可以对于一份数据实现原子操作。这对于计数器或者其他的一些简单功能足够了。所以，这里的事务并不通用，但是的确也提供了原子性，所以它被称为mini-transaction。

![](/files/-MEfintzRdnOWdGn9u2Z)

通过计数器这个例子里的策略可以实现很多功能，比如VMware FT所需要的Test-and-Set服务就可以以非常相似的方式来实现。如果旧的数据是0，一个虚机尝试将其设置成1，设置的时候会带上旧数据的版本号，如果没有其他的虚机介入也想写这个数据，我们就可以成功的将数据设置成1，因为Zookeeper里数据的版本号没有改变。如果某个客户端在我们读取数据之后更改了数据，那么Leader会通知我们说数据写入失败了，所以我们可以用这种方式来实现Test-and-Set服务。你应该记住这里的策略。


---

# 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.2-zookeeper-shi-yong-chang-jing.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.
