Lec 9:More Replication,CRAQ
这节课我想完成两件事情,第一个是结束关于Zookeeper的讨论,第二就是讨论CRAQ。
Last updated
这节课我想完成两件事情,第一个是结束关于Zookeeper的讨论,第二就是讨论CRAQ。
Last updated
借鉴链接:
Zookeeper里面我最感兴趣的事情是它的API设计。Zookeeper的API设计使得它可以成为一个通用的服务,从而分担一个分布式系统所需要的大量工作。那么为什么Zookeeper的API是一个好的设计?具体来看,因为它实现了一个值得去了解的概念:mini-transaction。
zookeeper的特点:
Zookeeper基于(类似于)Raft框架,所以我们可以认为它是,当然它的确是容错的,它在发生网络分区的时候,也能有正确的行为。
当我们在分析各种Zookeeper的应用时,我们也需要记住Zookeeper有一些性能增强,使得读请求可以在任何副本被处理,因此,可能会返回旧数据。
另一方面,Zookeeper可以确保一次只处理一个写请求,并且所有的副本都能看到一致的写请求顺序。这样,所有副本的状态才能保证是一致的(写请求会改变状态,一致的写请求顺序可以保证状态一致)。
由一个客户端发出的所有读写请求会按照客户端发出的顺序执行。
一个特定客户端的连续请求,后来的请求总是能看到相比较于前一个请求相同或者更晚的状态。
在我深入探讨Zookeeper的API长什么样和为什么它是有用的之前,我们可以考虑一下,Zookeeper的目标是解决什么问题,或者期望用来解决什么问题?
人们可以用它来发布其他服务器使用的配置信息
另一个Zookeeper的经典应用是选举Master
其他还有,对于一个类似于MapReduce的系统,Worker节点可以通过在Zookeeper中创建小文件来注册自己。
同样还是类似于MapReduce这样的系统,你可以设想Master节点通过向Zookeeper写入具体的工作,之后Worker节点从Zookeeper中一个一个的取出工作,执行,完成之后再删除工作。
zookeeper的文件和目录都被称为znodes。Zookeeper中包含了3种类型的znode:
第一种Regular znodes。这种znode一旦创建,就永久存在,除非你删除了它。
第二种Ephemeral znodes。如果Zookeeper认为创建它的客户端挂了,它会删除这种类型的znodes。这种类型的znodes与客户端会话绑定在一起,所以客户端需要时不时的发送心跳给Zookeeper,告诉Zookeeper自己还活着,这样Zookeeper才不会删除客户端对应的ephemeral znodes。
最后一种类型是Sequential znodes。它的意思是,当你想要以特定的名字创建一个文件,Zookeeper实际上创建的文件名是你指定的文件名再加上一个数字。当有多个客户端同时创建Sequential文件时,Zookeeper会确保这里的数字不重合,同时也会确保这里的数字总是递增的。
Zookeeper以RPC的方式暴露以下API:
CREATE(PATH,DATA,FLAG)
。入参分别是文件的全路径名PATH,数据DATA,和表明znode类型的FLAG。这里有意思的是,CREATE的语义是排他的。也就是说,如果我向Zookeeper请求创建一个文件,如果我得到了yes的返回,那么说明这个文件之前不存在,我是第一个创建这个文件的客户端;如果我得到了no或者一个错误的返回,那么说明这个文件之前已经存在了。所以,客户端知道文件的创建是排他的。在后面有关锁的例子中,我们会看到,如果有多个客户端同时创建同一个文件,实际成功创建文件(获得了锁)的那个客户端是可以通过CREATE的返回知道的。
DELETE(PATH,VERSION)
。入参分别是文件的全路径名PATH,和版本号VERSION。有一件事情我之前没有提到,每一个znode都有一个表示当前版本号的version,当znode有更新时,version也会随之增加。对于delete和一些其他的update操作,你可以增加一个version参数,表明当且仅当znode的当前版本号与传入的version相同,才执行操作。当存在多个客户端同时要做相同的操作时,这里的参数version会非常有帮助(并发操作不会被覆盖)。所以,对于delete,你可以传入一个version表明,只有当znode版本匹配时才删除。
EXIST(PATH,WATCH)
。入参分别是文件的全路径名PATH,和一个有趣的额外参数WATCH。通过指定watch,你可以监听对应文件的变化。不论文件是否存在,你都可以设置watch为true,这样Zookeeper可以确保如果文件有任何变更,例如创建,删除,修改,都会通知到客户端。此外,判断文件是否存在和watch文件的变化,在Zookeeper内是原子操作。所以,当调用exist并传入watch为true时,不可能在Zookeeper实际判断文件是否存在,和建立watch通道之间,插入任何的创建文件的操作,这对于正确性来说非常重要。
GETDATA(PATH,WATCH)
。入参分别是文件的全路径名PATH,和WATCH标志位。这里的watch监听的是文件的内容的变化。
SETDATA(PATH,DATA,VERSION)
。入参分别是文件的全路径名PATH,数据DATA,和版本号VERSION。如果你传入了version,那么Zookeeper当且仅当文件的版本号与传入的version一致时,才会更新文件。
LIST(PATH)
。入参是目录的路径名,返回的是路径下的所有文件。
我们来看看是如何使用这些Zookeeper API的。
第一个很简单的例子是计数器,假设我们在Zookeeper中有一个文件,我们想要在那个文件存储一个统计数字,例如,统计客户端的请求次数,当收到了一个来自客户端的请求时,我们需要增加存储的数字。
现在关键问题是,多个客户端会同时并发发送请求导致存储的数字增加。所以,第一个要解决的问题是,除了管理数据以外(类似于简单的SET和GET),我们是不是真的需要一个特殊的接口来支持多个客户端的并发。Zookeeper API看起来像是个文件系统,我们能不能只使用典型的存储系统的读写操作来解决并发的问题?
比如说,在Lab3中,你们会构建一个key-value数据库,它只支持两个操作,一个是PUT(K,V),另一个是GET(K)。对于所有我们想要通过Zookeeper来实现的操作,我们可以使用Lab3中的key-value数据库来完成吗?或许我们真的可以使用只有两个操作接口的Lab3来完成这里的计数器功能。你可以这样实现,首先通过GET读出当前的计数值,之后通过PUT写入X + 1。
x = Get(K)
PUT(k,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”)
(x是值,v是版本号),我们并不关心文件名是什么,所以这里直接传入一个“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会失败,并且我们会得到一个错误的回复,这样我们的代码不会跳出循环,我们会回到循环的最开始,重头开始再执行,并且期望这次能执行成功。
这个例子,其实就是大家常说的mini-transaction。这里之所以是事务的,是因为一旦我们操作成功了,我们对计数器达成了*读-更改-写*的原子操作。对于我们在Lab3中实现的数据库来说,它的读写操作不是原子的。而我们上面那段代码,一旦完成了,就是原子的。因为一旦完成了,我们的读,更改,写操作就不受其他任何客户端的干扰。
之所以称之为mini-transaction,是因为这里并不是一个完整的数据库事务(transaction)。一个真正的数据库可以使用完整的通用的事务,你可以指定事务的开始,然后执行任意的数据读写,之后结束事务。数据库可以聪明的将所有的操作作为一个原子事务提交。一个真实的事务可能会非常复杂,而Zookeeper支持这种非常简单的事务,使得我们可以对于一份数据实现原子操作。这对于计数器或者其他的一些简单功能足够了。所以,这里的事务并不通用,但是的确也提供了原子性,所以它被称为mini-transaction。
通过计数器这个例子里的策略可以实现很多功能,比如VMware FT所需要的Test-and-Set服务就可以以非常相似的方式来实现。如果旧的数据是0,一个虚机尝试将其设置成1,设置的时候会带上旧数据的版本号,如果没有其他的虚机介入也想写这个数据,我们就可以成功的将数据设置成1,因为Zookeeper里数据的版本号没有改变。如果某个客户端在我们读取数据之后更改了数据,那么Leader会通知我们说数据写入失败了,所以我们可以用这种方式来实现Test-and-Set服务。你应该记住这里的策略。
这一部分我想讨论的例子是非扩展锁。我讨论它的原因并不是因为我强烈的认为这种锁是有用的,而是因为它在Zookeeper论文中出现了。
对于锁来说,常见的操作是Aquire Lock,获得锁。获得锁可以用下面的伪代码实现:
在代码的第2行,是尝试创建锁文件。除了指定文件名,还指定了ephemeral为TRUE(ephemeral:临时文件,9.1)。如果锁文件创建成功了,表明我们获得了锁,直接RETURN。
如果锁文件创建失败了,我们需要等待锁释放。因为如果锁文件创建失败了,那表明锁已经被别人占住了,所以我们需要等待锁释放。最终锁会以删除文件的形式释放,所以我们这里通过EXIST函数加上watch=TRUE,来监测文件的删除。在代码的第3行,可以预期锁文件还存在,因为如果不存在的话,在代码的第2行就返回了。在代码的第4行,等待文件删除对应的watch通知。收到通知之后,再回到循环的最开始,从代码的第2行开始执行。
所以,总的来说,先是通过CREATE创建锁文件,或许可以直接成功。如果失败了,我们需要等待持有锁的客户端释放锁。通过Zookeeper的watch机制,我们会在锁文件删除的时候得到一个watch通知。收到通知之后,我们回到最开始,尝试重新创建锁文件,如果运气足够好,那么这次是能创建成功的。
在这里,我们要问自己一个问题:如果多个客户端并发的请求锁会发生什么?
有一件事情可以确定,如果有两个客户端同时要创建锁文件,Zookeeper Leader会以某种顺序一次只执行一个请求。所以,要么是我的客户端先创建了锁文件,要么是另一个客户端创建了锁文件。如果我的客户端先创建了锁文件,我们的CREATE调用会返回TRUE,这表示我们获得了锁,然后我们直接RETURN返回,而另一个客户端调用CREATE必然会收到了FALSE。如果另一个客户端先创建了文件,那么我的客户端调用CREATE必然会得到FALSE。不管哪种情况,锁文件都会被创建。当有多个客户端同时请求锁时,因为Zookeeper一次只执行一个请求,所以还好。
如果我的客户端调用CREATE返回了FALSE,那么我接下来需要调用EXIST,如果锁在代码的第2行和第3行之间释放了会怎样呢?这就是为什么在代码的第3行,EXIST前面要加一个IF,因为锁文件有可能在调用EXIST之前就释放了。如果在代码的第3行,锁文件不存在,那么EXIST返回FALSE,代码又回到循环的最开始,重新尝试获得锁。
类似的,并且同时也更有意思的是,如果正好在我调用EXIST的时候,或者在与我交互的副本还在处理EXIST的过程中,锁释放了会怎样?不管我与哪个副本进行交互,在它的Log中,可以确保写请求会以某种顺序执行。所以,与我交互的副本,它的Log以某种方式向前增加。因为我的EXIST请求是个只读请求,所以它必然会在两个写请求之间执行。现在某个客户端的DELETE请求要在某个位置被处理,所以,在副本Log中的某处是来自其他客户端的DELETE请求。
而我的EXIST请求有两种可能:要么完全的在DELETE请求之前处理,这样的话副本会认为,锁文件还存在,副本会在WATCH表单(详见8.4)中增加一条记录,之后才执行DELETE请求。而当执行DELETE请求的时候,可以确保我的WATCH请求在副本的WATCH表单中,所以副本会给我发送一个通知,说锁文件被删除了。
要么我的EXIST请求在DELETE请求之后处理。这时,文件并不存在,EXIST返回FALSE,又回到了循环的最开始。
因为Zookeeper的写请求是序列化的,而读请求必然在副本Log的两个写请求之间确定的位置执行,所以这种情况也还好。
这里的锁设计并不是一个好的设计,因为它和前一个计数器的例子都受羊群效应(Herd Effect)的影响。所谓的羊群效应,对于计数器的例子来说,就是当有1000个客户端同时需要增加计数器时,我们的复杂度是 O(n^2) ,这是处理完1000个客户端的请求所需要的总时间。对于这一节的锁来说,也存在羊群效应,如果有1000个客户端同时要获得锁文件,为1000个客户端分发锁所需要的时间也是 O(n^2) 。因为每一次锁文件的释放,所有剩下的客户端都会收到WATCH的通知,并且回到循环的开始,再次尝试创建锁文件。所以CREATE对应的RPC总数与1000的平方成正比。所以这一节的例子也受羊群效应的影响,像羊群一样的客户端都阻塞在Zookeeper这。这一节实现的锁有另一个名字:非扩展锁(Non-Scalable Lock)。它对应的问题是真实存在的,我们会在其他系统中再次看到。
在Zookeeper论文的结尾,讨论了如何使用Zookeeper解决非扩展锁的问题。有意思的是,因为Zookeeper的API足够灵活,可以用来设计另一个更复杂的锁,从而避免羊群效应。从而使得,即使有1000个客户端在等待锁释放,当锁释放时,另一个客户端获得锁的复杂度是O(1)O 而不是O(n) 。这个设计有点复杂,下面是论文第6页中2.4部分的伪代码。在这个设计中,我们不再使用一个单独的锁文件,而是创建Sequential文件(详见9.1)。
在代码的第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,以防在等待锁的队列里排在我们前面的客户端挂了。
学生提问:为什么这种锁不会受羊群效应(Herd Effect)的影响?
Robert教授:假设我们有1000个客户端在等待获取锁,每个客户端都会在代码的第6行等待锁释放。但是每个客户端等待的锁文件都不一样,比如序列号为500的锁只会被序列号为501的客户端等待,而序列号500的客户端只会等待序列号499的锁文件。每个客户端只会等待一个锁文件,当一个锁文件被释放,只有下一个序列号对应的客户端才会收到通知,也只有这一个客户端会回到循环的开始,也就是代码的第3行,之后这个客户端会获得锁(注:要是因为挂掉而回去应该就不一定会获得锁)。所以,不管有多少个客户端在等待锁,每一次锁释放再被其他客户端获取的代价是一个常数。而在非扩展锁中,锁释放时,每个等待的客户端都会被通知到,之后,每个等待的客户端都会发送CREATE请求给Zookeeper,所以每一次锁释放再被其他客户端获取的代价与客户端数量成正比。
我第一次看到可扩展锁,是在一种完全不同的背景下,也就是在多线程代码中的可扩展锁。通常来说,这种锁称为可扩展锁(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。
以上就是对于Zookeeper的一些介绍。有两点需要注意:第一是Zookeeper聪明的从多个副本读数据从而提升了性能,但同时又牺牲了一些一致性;另一个是Zookeeper的API设计,使得Zookeeper成为一个通用的协调服务,这是一个简单的put/get 服务所不能实现,这些API使你可以写出类似mini-transaction的代码,也可以帮你创建自己的锁。
开始CRAQ(Chain Replication with Apportioned Queries)。
CRAQ是对于一个叫链式复制(Chain Replication)的旧方案的改进。Chain Replication实际上用的还挺多的,有许多现实世界的系统使用了它,CRAQ是对它的改进。CRAQ采用的方式与Zookeeper非常相似,它通过将读请求分发到任意副本去执行,来提升读请求的吞吐量,所以副本的数量与读请求性能成正比。CRAQ有意思的地方在于,它在任意副本上执行读请求的前提下,还可以保证线性一致性(Linearizability)。这与Zookeeper不太一样,Zookeeper为了能够从任意副本执行读请求,不得不牺牲数据的实时性,因此也就不是线性一致的。CRAQ却可以从任意副本执行读请求,同时也保留线性一致性,这一点非常有趣。
首先,我想讨论旧的Chain Replication系统。Chain Replication是这样一种方案,你有多个副本,你想确保它们都看到相同顺序的写请求(这样副本的状态才能保持一致),这与Raft的思想是一致的,但是它却采用了与Raft不同的拓扑结构。首先,在Chain Replication中,有一些服务器按照链排列。第一个服务器称为HEAD,最后一个被称为TAIL。
当客户端想要发送一个写请求,写请求总是发送给HEAD。HEAD根据写请求更新本地数据,我们假设现在是一个支持PUT/GET的key-value数据库。所有的服务器本地数据都从A开始。当HEAD收到了写请求,将本地数据更新成了B,之后会再将写请求通过链向下一个服务器传递。下一个服务器执行完写请求之后,再将写请求向下一个服务器传递,以此类推,所有的服务器都可以看到写请求。当写请求到达TAIL时,TAIL将回复发送给客户端,表明写请求已经完成了。这是处理写请求的过程。
对于读请求,如果一个客户端想要读数据,它将读请求发往TAIL,TAIL直接根据自己的当前状态来回复读请求。所以,如果当前状态是B,那么TAIL直接返回B。读请求处理的非常的简单。
这里只是Chain Replication,并不是CRAQ。Chain Replication本身是线性一致的,在没有故障时,从一致性的角度来说,整个系统就像只有TAIL一台服务器一样,TAIL可以看到所有的写请求,也可以看到所有的读请求,它一次只处理一个请求,读请求可以看到最新写入的数据。如果没有出现故障的话,一致性是这么得到保证的,非常的简单。
从一个全局角度来看,除非写请求到达了TAIL,否则一个写请求是不会commit,也不会向客户端回复确认,也不能将数据通过读请求暴露出来。而为了让写请求到达TAIL,它需要经过并被链上的每一个服务器处理。所以我们知道,一旦我们commit一个写请求,一旦向客户端回复确认,一旦将写请求的数据通过读请求暴露出来,那意味着链上的每一个服务器都知道了这个写请求。
主要看论文中的图二图三 ,即 论文的 2.3部分
我有一点疑惑就是:
我感觉craq虽然是通过tcp传递的,但是应该不一定需要逐步到head才说回复给客户端吧?我觉得完全可以通过广播消息就实现了。不太懂。
在Chain Replication中,出现故障后,你可以看到的状态是相对有限的。因为写请求的传播模式非常有规律,我们不会陷入到类似于Raft论文中图7和图8描述的那种令人毛骨悚然的复杂场景中。并且在出现故障之后,也不会出现不同的副本之间各种各样不同步的场景。
在Chain Replication中,因为写请求总是依次在链中处理,写请求要么可以达到TAIL并commit,要么只到达了链中的某一个服务器,之后这个服务器出现故障,在链中排在这个服务器后面的所有其他服务器不再能看到写请求。所以,只可能有两种情况:committed的写请求会被所有服务器看到;而如果一个写请求没有commit,那就意味着在导致系统出现故障之前,写请求已经执行到链中的某个服务器,所有在链里面这个服务器之前的服务器都看到了写请求,所有在这个服务器之后的服务器都没看到写请求。
如果HEAD出现故障,作为最接近的服务器,下一个节点可以接手成为新的HEAD,并不需要做任何其他的操作。对于还在处理中的请求,可以分为两种情况:
对于任何已经发送到了第二个节点的写请求,不会因为HEAD故障而停止转发,它会持续转发直到commit。
如果写请求发送到HEAD,在HEAD转发这个写请求之前HEAD就故障了,那么这个写请求必然没有commit,也必然没有人知道这个写请求,我们也必然没有向发送这个写请求的客户端确认这个请求,因为写请求必然没能送到TAIL。所以,对于只送到了HEAD,并且在HEAD将其转发前HEAD就故障了的写请求,我们不必做任何事情。或许客户端会重发这个写请求,但是这并不是我们需要担心的问题。
如果TAIL出现故障,处理流程也非常相似,TAIL的前一个节点可以接手成为新的TAIL。所有TAIL知道的信息,TAIL的前一个节点必然都知道,因为TAIL的所有信息都是其前一个节点告知的。
中间节点出现故障会稍微复杂一点,但是基本上来说,需要做的就是将故障节点从链中移除。或许有一些写请求被故障节点接收了,但是还没有被故障节点之后的节点接收,所以,当我们将其从链中移除时,故障节点的前一个节点或许需要重发最近的一些写请求给它的新后继节点。这是恢复中间节点流程的简单版本。
Chain Replication与Raft进行对比,有以下差别:
从性能上看,对于Raft,如果我们有一个Leader和一些Follower。Leader需要直接将数据发送给所有的Follower。所以,当客户端发送了一个写请求给Leader,Leader需要自己将这个请求发送给所有的Follower。然而在Chain Replication中,HEAD只需要将写请求发送到一个其他节点。数据在网络中发送的代价较高,所以Raft Leader的负担会比Chain Replication中HEAD的负担更高。当客户端请求变多时,Raft Leader会到达一个瓶颈,而不能在单位时间内处理更多的请求。而同等条件以下,Chain Replication的HEAD可以在单位时间处理更多的请求,瓶颈会来的更晚一些。
另一个与Raft相比的有趣的差别是,Raft中读请求同样也需要在Raft Leader中处理,所以Raft Leader可以看到所有的请求。而在Chain Replication中,每一个节点都可以看到写请求,但是只有TAIL可以看到读请求。所以负载在一定程度上,在HEAD和TAIL之间分担了,而不是集中在单个Leader节点。
前面分析的故障恢复,Chain Replication也比Raft更加简单。这也是使用Chain Replication的一个主要动力。
学生提问:假设第二个节点不能与HEAD进行通信,第二个节点能不能直接接管成为新的HEAD,并通知客户端将请求发给自己,而不是之前的HEAD?
教授:假设HEAD和第二个节点之间的网络出问题了,HEAD还在正常运行,同时HEAD认为第二个节点挂了。然而第二个节点实际上还活着,它认为HEAD挂了。所以现在他们都会认为,另一个服务器挂了,我应该接管服务并处理写请求。因为从HEAD看来,其他服务器都失联了,HEAD会认为自己现在是唯一的副本,那么它接下来既会是HEAD,又会是TAIL。第二个节点会有类似的判断,会认为自己是新的HEAD。所以现在有了脑裂的两组数据,最终,这两组数据会变得完全不一样。
(下一节还会讲关于这个问题的答案)
所以,Chain Replication并不能抵御网络分区,也不能抵御脑裂。在实际场景中,这意味它不能单独使用。Chain Replication是一个有用的方案,但是它不是一个完整的复制方案。它在很多场景都有使用,但是会以一种特殊的方式来使用。总是会有一个外部的权威(External Authority)来决定谁是活的,谁挂了,并确保所有参与者都认可由哪些节点组成一条链,这样在链的组成上就不会有分歧。这个外部的权威通常称为Configuration Manager。
Configuration Manager的工作就是监测节点存活性,一旦Configuration Manager认为一个节点挂了,它会生成并送出一个新的配置,在这个新的配置中,描述了链的新的定义,包含了链中所有的节点,HEAD和TAIL。Configuration Manager认为挂了的节点,或许真的挂了也或许没有,但是我们并不关心。因为所有节点都会遵从新的配置内容,所以现在不存在分歧了。
现在只有一个角色(Configuration Manager)在做决定,它不可能否认自己,所以可以解决脑裂的问题(就像leader?)。
当然,你是如何使得一个服务是容错的,不否认自己,同时当有网络分区时不会出现脑裂呢?答案是,Configuration Manager通常会基于Raft或者Paxos。在CRAQ的场景下,它会基于Zookeeper。而Zookeeper本身又是基于类似Raft的方案。
所以,你的数据中心内的设置通常是,你有一个基于Raft或者Paxos的Configuration Manager,它是容错的,也不会受脑裂的影响。之后,通过一系列的配置更新通知,Configuration Manager将数据中心内的服务器分成多个链。比如说,Configuration Manager决定链A由服务器S1,S2,S3组成,链B由服务器S4,S5,S6组成。
Configuration Manager通告给所有参与者整个链的信息,所以所有的客户端都知道HEAD在哪,TAIL在哪,所有的服务器也知道自己在链中的前一个节点和后一个节点是什么。现在,单个服务器对于其他服务器状态的判断,完全不重要。假如第二个节点真的挂了,在收到新的配置之前,HEAD需要不停的尝试重发请求。节点自己不允许决定谁是活着的,谁挂了。
这种架构极其常见,这是正确使用Chain Replication和CRAQ的方式。在这种架构下,像Chain Replication一样的系统不用担心网络分区和脑裂,进而可以使用类似于Chain Replication的方案来构建非常高速且有效的复制系统。比如在上图中,我们可以对数据分片(Sharding),每一个分片都是一个链。其中的每一个链都可以构建成极其高效的结构来存储你的数据,进而可以同时处理大量的读写请求。同时,我们也不用太担心网络分区的问题,因为它被一个可靠的,非脑裂的Configuration Manager所管理。
题外话:
这种链式复制存储数据也可以用raft等来做,raft相比这个有个优点就是可以避免等待较慢的某些副本。
链式复制的优点是可以减低某个特定服务器的负载,在高写入的情况下仍然保持高性能,且可以在读方面性能更优。