lab3在lab2实现的raft算法系统基础上,实现一个具备容错能力的kv存储服务。具体来说,在lab3中,有多个server为client提供服务,每个server对应一个raft中的peers。多个server通过raft作为底层算法,实现分布式一致性,对外提供put
,get
,append
服务,服务如下:
The service maintains a simple database of key/value pairs. Keys and values are strings.
Put(key, value)
replaces the value for a particular key in the database,Append(key, arg)
appends arg to key’s value, andGet(key)
fetches the current value for the key. AGet
for a non-existent key should return an empty string. AnAppend
to a non-existent key should act likePut
. Each client talks to the service through aClerk
with Put/Append/Get methods. AClerk
manages RPC interactions with the servers.
lab3A
任务
Your first task is to implement a solution that works when there are no dropped messages, and no failed servers.
You’ll need to add RPC-sending code to the Clerk Put/Append/Get methods in
client.go
, and implementPutAppend()
andGet()
RPC handlers inserver.go
. These handlers should enter anOp
in the Raft log usingStart()
; you should fill in theOp
struct definition inserver.go
so that it describes a Put/Append/Get operation. Each server should executeOp
commands as Raft commits them, i.e. as they appear on theapplyCh
. An RPC handler should notice when Raft commits itsOp
, and then reply to the RPC.You have completed this task when you reliably pass the first test in the test suite: “One client”.
Add code to handle failures, and to cope with duplicate
Clerk
requests, including situations where theClerk
sends a request to a kvserver leader in one term, times out waiting for a reply, and re-sends the request to a new leader in another term. The request should execute just once. These notes include guidance on duplicate detection. Your code should pass thego test -run 3A
tests.
要求实现不包含snapshot的kv service。按照实验手册指导,任务分两阶段实现,第一阶段是实现基础的kv service服务,不用考虑网络故障、机器故障。第二阶段再考虑网络分区和机器故障。
分析
做6.824的时候,我觉得最重要的事情就是要弄清楚我们要做什么,然后再对照实现指导手册和student guide
看看实现方式和要注意的问题。如果不清楚要干什么,那么会越看越迷糊,越看越劝退。
知道了lab3A要完成的任务,需要分析如何实现,或者更为具体的说:如何在lab2的基础上,完成lab3A。lab2实现的raft算法,可以保证分布式一致性,也就是只要某条command被apply,就一定能保证所有的peers在该command对应位置上一定都是这个command。
lab3需要完成的kv service需要我们对用户提供一个线性化的服务,也就是client提交命令后,只要收到了确认,那么这条命令就一定被执行了,而且所有被执行的命令会保证其执行的顺序在所有的server中都相同。
一个巧妙的想法出现了,如果我们将client提交的命令op作为一个command提交给raft的中leader,然后监听applych,只要这条命令对应的command被apply,那么我们就可以肯定这条命令一定被保存到了所有的peers上,并且位置相同。
一开始容易陷入的一个想法是:由于写命令会改变数据,而读命令不会,所以只需要将写命令保存到raft的log中,而读命令不用。这种想法在单机上确实是一种合理且高效的方式,但是在分布式集群里,不是所有的命令都是会通过某一server的,集群里一定存在少部分,这些server没有最新的log数据,如果在这些server上进行读操作则会得到过时数据,最重要的一点是,无法确定某台server是不是少部分。这就需要按照实验手册中提到的,将读操作也写入log中。
实现
实现部分将直接说明lab3A的最终实现,不再分别按照基础功能和应对网络和机器故障两部分展开。文件夹为src/kvraft
,需要添加代码的文件有client.go
、server.go
、common.go
。功能上,client.go
实现的是提供给用户的功能,server.go
实现的是在用户和raft系统间的中间层,也是核心部分,一个peer对应一个server,common.go
则包含了过程中需要使用的数据结构(类)。
首先是对lab2代码的修改,lab3A中有个测试会判断你的命令是否执行的够快,由于之前的实现中raft中start()
函数插入的log entry会在发送心跳时打包发送,因此速度会很慢,无法通过测试。
然后分析一下处理过程,对于client.go
,流程上,用户首先调用MakeClerk
函数得到一个客户端clerk
,之后可以分别调用clerk
提供的 Get
、 Put
、 Append
方法,完成对应功能。这三个方法的下一层都是调用server.go
中方法对应的方法。clerk发起Get
、 Put
、 Append
调用时,无论出现任何问题,都会循环直到该命令被执行(收到来自server的apply Err
字段为ok),这点可以用一个循环结构保证。
对于server.go
,流程上,首先通过StartKVServer
创建KVServer
对象,每个server会绑定一个peer。实现Get
、 Put
、 Append
方法时,会调用raft中的start()方法(即插入logEntry)。如果对应的peer不是leader,则返回ErrWrongLeader
,否则,则等待该命令被raft提交(从peer的applych收到该命令),并返回处理结果给clerk。
**第一个问题是clerk
如何向leader对应的server
发起调用?**答案是一个个尝试。clerk
初始化时,会随机初始化leaderId
,clerk
会根据记录的leaderId
向server.go
发起调用,如果该server
对应的peers不是leader,那么其返回值中的Err
字段会填充为ErrWrongLeader
,这时clerk
会按照遍历顺序向下一个server
发起调用,直到找到leader。因此Clerk
结构体需要添加的第一个字段是leaderId
,记录clerk
认为的leader。
这里按照论文中的说法,如果
server
关联的不是leader,那么其会返回最近一次联系的leaderId,然后clerk根据这个值去寻找leader,但是,倘若server
提供的leader值也是错误的,那么clerk
又会根据新的server提供的leaderId去寻找(这里可能会陷入循环?)。更为重要的一点是,目前raft实现中其实不包含返回leaderId的方法,只会返回该peers是不是leader。所以这里直接一个个的去调用。
第二个问题是如何在server
知道某条命令被raft提交后立刻返回结果给clerk
?这个问题是核心问题。做了这么多lab后,自然而然地会想到使用管道通信的方式处理这种情况。分析一下过程,clerk
通过rpc方式调用server
中的 Get
、 Put
、 Append
方法,直到收到回应,应该一直都是被阻塞的。而server
收到clerk
的调用后,会调用对应peer的start
方法,向log中插入一条op,然后等待这条op被提交到applych,那么,server
就需要持续监听applych,我这里单独开了一个循环线程,不断从applych读取数据,然后将读取到的结果通过管道返回给自己。
最简单的想法是在server
中为每一个来自clerk
的op创建一个管道,当server
从applych收到op时,如果这条op有对应的管道,则返回数据,否则不用往管道发送命令。需要注意的是对管道资源的释放。也就是在server
中添加一个map字段,op作为key,对应的chan作为value。
但直接使用op作为key,总感觉不太合适,于是换个思路,使用调用start()
函数返回的index作为key,这么做有两个好处,第一,假如applych收到commitIndex的值为index1,那么index1之前的log就已经全部被提交;第二,当某个server
在调用start()
函数时还是leader,但这条命令还没被提交就失去leader身份时,那么index处的命令可能会变,从而能反映出leader身份的变化。
可以通过term的变化判断是否发生了leader的改变。
第三个问题是clerk
的调用失效如何处理?这个问题也是核心问题。首先要明白什么情况是调用失效:如果clerk
发起调用时,server
还是leader,但还没commit时不再是leader,那么这条命令可能会被丢弃,再也收不到响应,那么就发生了调用失效。
另外,我们还需要知道,在存在网络故障和机器故障的情况下,有哪些需要考虑的情况
-
第一种,
clerk
向server
发起的调用还没通过start()
函数送入log,就因故障丢失。这种情况下,clerk
会再一次发起调用,server
这里没有任何影响。 -
第二种,
clerk
向server
发起调用后,server
已经调用了start()
函数,正在等待结果-
server
等待结果的过程中失去了leader身份,因为网络分区clerk
发起的这条命令已经发送到了新leader那里,那么随着系统正常运行,这条命令会被正确保存在log中。
这是最复杂的一种情况,假设原来的
server
是A,新leader对应的server
是B。A这里会有一个该命令的通道,等待这条命令的处理结果。假如A在RPC响应超时之前收到了这条命令的处理结果,那么A会返回结果给
clerk
,系统表现的好像无事发生;(但是在我的实现中,这种情况不会返回命令)假如A在RPC响应超时后收到了这条命令的处理结果,那么A中的这条通道应该已经被关闭(超时后应该就会关闭),此时,A不应该再往通道中写入任何内容。否则会出现panic。这里的解决办法是,对opChan设置缓冲区,查询与写入操作用一把锁锁死,也就是
server
收到applych的数据后,直接根据index查询opChan,查到了就写,没查到就不写。这样写入的时候不会阻塞,也不会出现往空管道中写数据的情况。另外一个问题是使用index作为查询管道的key,可能出现同一个index上log数据发生改变的情况,那么就需要判断是否发生了leader替换。这里我修改了raft2中的ApplyMsg,添加了一个字段CurrentTerm,通过比较调用
start()
函数时命令的term和收到ApplyCh时命令的term来判断,是否发生了轮替。就可以决定是否需要往通道中写内容。A超时后,
clerk
会向B发起调用,B会向clerk
返回结果(duplicate detection处理)。clerk
发起的这条命令没有发送到新leader那里,这条命令被丢弃了。那么会出现超时问题,clerk
会寻找新的leader,重复这个动作
-
server
等待结果的过程中崩掉了,clerk
会收到RPC调用不成功的结果,向下一个server
发起调用。
-
-
第三种,
clerk
向server
发起调用成功,并且server
也返回了结果,但是RPC返回因为网络故障丢失。那么clerk
会向server
发起重复调用,server
只需要返回duplicate detection处理后的结果。
我的实现是在server.go
从applych收取响应时会判断是否超时,超时则说明出现了上述情况(也可能出现了其他情况),结果不仅在unreliable net, many clients (3A)
中出现获取到了错误结果,而且卡在了partitions, one client (3A)
测试。
由于这里的超时时间随便设置的(1s),所以可能会存在某条命令可能还在处理中(网络延迟),clerk就向leader发起了这条命令的重复调用。如果这些重复调用都被执行,那么就会得到错误的结果。因此,我们需要保证实验手册中提到的**duplicate detection**,所以先讨论这个问题,出错原因也与这个有关。
第四个问题是duplicate detection的处理。duplicate detection即客户端重复发起调用时,需要保证处理结果的幂等性,例如在多clerk
的场景下,某个clerk
多次提交了修改某个key的value的命令,这些修改key的value的命令会穿插在其他clerk
提交的命令中,那么,我们就不能多次执行这个修改key的value的命令,这样会影响到其他clerk
命令的执行。
那怎么判断命令是否被重复执行?按照实验文档和raft论文,我们需要给每个命令一个标记,作为其唯一性证明,这样一来,就需要向Clerk
结构体添加两个字段commandId
和clientId
,前者为client提交命令的序号,单调递增,后者为每个client(clerk)对应的身份码,随机生成。两者同时使用,就保证了在多个clerk
的场景下,每条命令的唯一性。
关于命令重复性的判断,这里需要注意,
clerk
重复发起的命令只要没有被丢弃,都会保存在raft的log中,也就是说,无法避免同一条command重复出现在log中。我们对重复性的判断是在log数据被apply后判断的,每次server.go
从applych收到命令数据时,都会通过唯一性标记,来判断这条命令是否已被执行。所以不要陷入误区,认为我们需要保证log中的命令是不重复的,我们保证的应该是“事后不重复性”,当然,如果在添加log前就能检查出重复,那么自然不能将重复命令添加到log中。
这里,还需要存储每个clerk
最新一条命令的结果(其实只需要get命令的结果),为什么?设想一个场景,现在有多个clerk
,t1时刻,clerk1发起get A
命令,想获取A的value值,发送给了此时的leader,leader将命令发送出去后崩了, 此时get A
已经作为一条log entry并且已被状态机提交。一段时间后,clerk1会选择下一个leader再次发送get A
命令,但是在此期间,clerk2提交了一条命令:put A 3
,并且通过状态机提交了。那么新leader收到client1的get A
命令时,可以得知此条命令已被状态机提交。
其有两种处理方式,一种是不处理这条命令,并直接返回ok,对于put和append命令虽然成立,但get命令则是错误的(这也是我unreliable net, many clients (3A)
中获取到错误结果的原因)。第二种是返回这条命令当时的处理结果,现在的3显然不是当时的结果,因此我们需要保存client1的get A
的执行结果。
只需要保存每个client最新一条命令结果的原因,是因为在目前的设置下,一个client一次只能执行一条命令。
执行结果自然是存到server
中,server
还要记录每个clerk
最新提交命令的index,server
还需要一个保存一个kv系统,因此KVServer
结构体中需要添加三个字段,分别是clientCommitIndex map[int64]int
,clientLastCommitOpRes map[int64]string
,KVDataBase map[string]string
问题记录
1、碰到的第一个问题是go test -race
运行时会疯狂报data race
的错误,由于lab2中已经积累了一些经验,所以很快找到了有问题的代码。
2、碰到的第二个问题是卡在测试函数spawn_clients_and_wait()
中出不去,由于这段代码被标黄(warning),我刚开始以为是测试代码的问题,后来发现是卡在自己写的PutAppend()
函数中了,然后进一步发现是从管道读取消息和往管道送入消息时没释放锁。然后还发现一些小问题,比如args没有添加value,以及opType填错了。
3、通过了one client
测试后,卡在了ops complete fast enough
上。之前实现的raft算法中,每次start()
放入的内容,都会等待下一次心跳时打包发送,因此卡在了这个测试上,于是对raft部分的代码进行了修改,只要收到start,就会发送一次心跳,这也让整个系统中的rpc数量增加了很多。此外,按照网上的问题反馈,不再立即persist,而是等待函数执行完毕再统一persist,这种方式就需要注意实验指导手册中提到的,persist一定要在状态可见之前进行,也就是说,除了return之前统一persist,在相应rpc apply的时候,都需要先persist。(但是后来想了一下,我的设计中每个函数都加了一把大锁,似乎只需要在return前persist就行)
4、修改后,通过了ops complete fast enough
,但又出现了两个错误:unreliable net, many clients (3A)
中获取到了错误结果,而且卡在了partitions, one client (3A)
测试,第一个问题的解决已经分析过了,但是第二个问题通过看日志并不能找到问题,最后总会卡在clerk
重复提交某条命令给所有的server
,然后server
要么告知他不为leader,要么就是server
的调用超时。这个问题困扰了我两天,最后发现是一个很简单的错误,原因是我的循环读取applych线程写了个return,导致出现重复提交命令时,这个线程会自动退出。
5、修改后,通过了partitions, one client (3A)
,但又出现了读取到了错误的数据的情况,检查日志发现,我在保存命令通道的时候,用的是clientId作为key,当时想着是因为每次client只能调用一个命令,所以每个client只需要保存一个opChan。但是由于可能存在的网络延迟,来自同一个client的数据可能会覆盖掉之前的数据,所以这种方式并不妥。后面我按照网上的思路,使用调用start()
函数时返回的index作为key。
6、使用index作为key后,出现了history is not linearizable
的情况,查看结果后得知,get方法直接返回了错误结果。这是使用index作为key的一个需要注意的地方,就是index的内容可能发生了变化(由于leader替换,原来index处的内容发生了变化)。需要通过term的变化,分辨是否出现了这种情况。
7、修改后通过所有测试。
lab3B
任务
As things stand now, your key/value server doesn’t call your Raft library’s
Snapshot()
method, so a rebooting server has to replay the complete persisted Raft log in order to restore its state. Now you’ll modify kvserver to cooperate with Raft to save log space, and reduce restart time, using Raft’sSnapshot()
from Lab 2D.The tester passes
maxraftstate
to yourStartKVServer()
.maxraftstate
indicates the maximum allowed size of your persistent Raft state in bytes (including the log, but not including snapshots). You should comparemaxraftstate
topersister.RaftStateSize()
. Whenever your key/value server detects that the Raft state size is approaching this threshold, it should save a snapshot by calling Raft’sSnapshot
. Ifmaxraftstate
is -1, you do not have to snapshot.maxraftstate
applies to the GOB-encoded bytes your Raft passes as the first argument to topersister.Save()
.Modify your kvserver so that it detects when the persisted Raft state grows too large, and then hands a snapshot to Raft. When a kvserver server restarts, it should read the snapshot from
persister
and restore its state from the snapshot.
这里呼应上了lab2D中的snapshot(index int, snapshot []byte)
函数,当时我们提出了两个问题,一是snapshot数据和index从哪来的,二是什么时候会调用这个函数。lab3B中给出了答案,这样看来整个6.824的设计采用了自底向上的方式。
那么lab3B的任务就相对简单,一是在rf.persister.RaftStateSize()
大于maxraftstate
时,调用snapshot()
函数,并传入需要保存的持久化数据。二是在启动时需要通过ReadSnapshot()
读取可能存在的snapshot数据。
分析
任务还是比较明确的,但是现有三个关键的问题,一是哪些数据需要保存?二是snapshot
函数参数中的index如何确定?三是何时调用snapshot()
函数?
哪些数据需要保存?首先kvserver
中的数据库需要保存,其用于进行duplicate detection的数据需要进行保存。lab3A中我们引入了四个字段分别如下:
clientLastCommitOpIndex map[int64]int // 最后一个提交op的commandIndex
clientLastCommitOpRes map[int64]string // 最后一个提交op的处理结果(主要用于get)
clientLastRunOPChan map[int]chan OpMsg // 最后一个正在执行op的返回消息管道, 使用index作为key
clientLastRunOPTerm map[int]int // 最后一个正在执行op调用start时的term,使用index作为key
前两个一定是需要保存的,因为进行duplicate detection的正确处理,离不开这两个数据。但后两个数据对应的命令正在处理中,还未被应用到kvserver
的数据库,因此无需保存。
index如何确定?何时调用snapshot()
函数?这两个问题可以视为一个问题
这里的index关联到的log一定是已经被保存到kvserver
数据库中的命令。
调用snapshot()
函数?这里很自然有个思路,就是设置一个后台Goroutine,不断循环判断maxraftstate
与rf.persister.RaftStateSize()
大小关系,根据情况调用snapshot()函数。
一种思路是:
因为每次只有调用了Get
、Put
、Append
方法后才会出现log数据增长,那么不如在每次这些方法调用完成后进行数据保存。这样还能避免可能存在的问题。
这里可能存在的问题是个人的感觉,因为lab3A的锁的使用和lab2有区别,lab2基本上就是一把大锁报平安,但lab3A每个函数基本都涉及管道读取与发送,不能简单的一把大锁从头锁到尾,所以总感觉有点不安全。
那么只需要使用kvserver
中已经存在的clientLastCommitOpIndex
字段,取最大值就可以。
另一种思路更直接:
因为我们已经开启了一个后台Goroutine不断从applyMsg中读取数据,也只有这里会修改kvserver
数据库中的内容,因此不如直接在这个线程中进行检查
实现
实现起来还是比较简单的,但是要注意,除了我们主动调用拍快照,还可能收到来自状态机的快照数据,我们也需要对这些数据进行处理。
该如何处理这些收到的快照数据呢?我们需要判断这些数据是否过时,也就是其中的数据是否是过时的数据,因此需要一个字段记录上次拍快照的索引。
问题记录
1、第一个问题是出现了log超长,以及获取到错误数据的问题,检查发现是判断是否需要进行拍快照操在readandHanderApplyMsg()
中放到了错误的位置。这个操作应该位于检查该命令结果是否需要写入管道之前,以及对kvDataBase操作后。
2、修正后,发现程序卡在了TestSnapshotUnreliable3B
。现象就是系统有时会出现停滞的情况,整个系统除了raft还在发送心跳外,不再打印其他日志信息,很明显系统出现了死锁。我的第一个措施是将server.go
中的每个函数尽量用一把大锁锁住,而不是多次释放并获得锁。第二个措施是在clent.go
和server.go
中都添加了一个tricker()
语句,定时获取锁,并打印信息。观察到的结果就是:如果开启了日志打印,那么日志打印会卡住,但是如果开一个定时获取锁并打印“我还活着”的线程,当日志卡住时,还是会持续输出“我还活着”信息,但通过检查发现client均存活,但server2出现了死锁。排查后发现,在InstallSnapshot
中的if-else语句中,else情况未释放锁的情况,导致出现了死锁。
3、第二个问题折磨了我好久,最终还是逐过程添加打印语句,定位到了问题所在。修改完后,通过所有测试