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。
为什么这是一个错误的答案?是的,这里不是原子操作,这是问题的根源。
如果有两个客户端想要同时增加计数器的值,它们首先都会先通过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”。
现在,我们获得了一个数值X,和一个版本号V,可能不是最新的,也可能是新的。之后,我们对于SETDATA("f", X + 1, V)
加一个IF判断。如果返回true,表明它的确写入了数据,那么我们会从循环中跳出 break
,如果返回false,那我们会回到循环的最开始,重新执行。
在代码的第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。最终我们会看到最新的数据。
学生提问:Zookeeper的数据都存在内存吗?
Robert教授:是的。如果数据小于内存容量那就没问题,如果数据大于内存容量,那就是个灾难。所以当你在使用Zookeeper时,你必须时刻记住Zookeeper对于100MB的数据很友好,但是对于100GB的数据或许就很糟糕了。这就是为什么人们用Zookeeper来存储配置,而不是大型网站的真实数据。
学生提问:对于高负载的场景该如何处理呢?
Robert教授:我们可以在SETDATA失败之后等待一会。我会这么做,首先,等待(sleep)是必须的,其次,等待的时间每次需要加倍再加上一些随机。这里实际上跟Raft的Leader Election里的Exponential back-off是类似的。这是一种适应未知数量并发客户端请求的合理策略。
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。
通过计数器这个例子里的策略可以实现很多功能,比如VMware FT所需要的Test-and-Set服务就可以以非常相似的方式来实现。如果旧的数据是0,一个虚机尝试将其设置成1,设置的时候会带上旧数据的版本号,如果没有其他的虚机介入也想写这个数据,我们就可以成功的将数据设置成1,因为Zookeeper里数据的版本号没有改变。如果某个客户端在我们读取数据之后更改了数据,那么Leader会通知我们说数据写入失败了,所以我们可以用这种方式来实现Test-and-Set服务。你应该记住这里的策略。
最后更新于