# 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。

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

![](https://2933519158-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MAkokVMtbC7djI1pgSw%2F-MEV1AoKEoKVT-RsKex2%2F-MEVC5EzPPjPMgfsmxM7%2Fimage.png?alt=media\&token=7f026208-83c0-4438-9e01-1c8d0fce8507)

如果有两个客户端想要同时增加计数器的值，它们首先都会先通过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”。

![](https://2933519158-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MAkokVMtbC7djI1pgSw%2F-MEXvtJRg6USVF32eOv5%2F-ME_OoKizvcHYvr6c2ne%2Fimage.png?alt=media\&token=18eb06dc-b6b4-4c22-9475-bd045858b807)

现在，我们获得了一个数值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。

![](https://2933519158-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MAkokVMtbC7djI1pgSw%2F-MEerW7hWA_KDvc26m8k%2F-MEfintzRdnOWdGn9u2Z%2Fimage.png?alt=media\&token=83e488f0-6151-4b29-a2ac-41fae3c4022e)

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