Files
MIT6.824/lecture-09-more-replication-craq/9.2-zookeeper-shi-yong-chang-jing.md
carlclone 51f7d91c39 fix typo
2022-12-16 09:43:56 +08:00

10 KiB
Raw Blame History

9.2 使用Zookeeper实现计数器

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

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

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

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

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

如果有两个客户端想要同时增加计数器的值它们首先都会先通过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来获取当前计数器的值代码是XV = GETDATA(“f”)我们并不关心文件名是什么所以这里直接传入一个“f”。

现在我们获得了一个数值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时如果有人修改了计数器的值我们是可以收到通知的。

但是这里的时序并不是按照你设想的那样工作上面代码的第23行之间的时间理论上是0。但是如果有一个其他客户端在我们GETDATA之后发送了增加计数的请求我们收到通知的时间可能会比较长。首先那个客户端的请求要发送到Leader之后Leader要将这个请求转发到FollowerFollower执行完之后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。

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