进度
由于没有Go语言的经验,也没有开发的经验,所以做起来相对吃力。但是比较幸运的是,在Debug的时候没有遭遇特别难的部分,起码看起来都解决了。如果想要完成这个项目的话,一个诚挚的建议是根据代码量来估算进度,以免自己丧失信心。
配置环境,学习Go,完成Project1 1周,代码量大致在200行左右,一天100行保证正确性感觉是我这样的新手比较合理的量。建议写完对着测试集调bug,这个是Project1比较合适的做法,我最开始尝试阅读代码,根本看不懂。
完成Project2, project2a 1000行代码左右,但是很多重复的地方,所以大致四天可以完成,一天Debug;Project2b,这一部分主要是不了解代码的结构,不知道要干什么,所以建议看看白皮书,而且自己推理一下要干写什么事情,反正这一部分查找资料1、2天,代码量大致也是300行左右,完成的话需要2天左右,总共是4天左右;Project2c,这一部分主要的难度也在理解上,理解一天,代码+debug一天,很少有Bug,代码量大致是100多行,但是和Project2a、Project2b高度相似,所以很快。
所以Project2花了两周多一点的时间,多的时间大部分用来修改project2b中的Bug。这一部分一定要充分理解,不理解就看看论文,看看博客,让每一个地方都符合逻辑,如果有一些地方不符合逻辑肯定是代码错了,如果找不到错误或者不知道怎么修改最好记录下来,以后debug搞不出来的时候看看,有可能就是这里的问题。
Project3,这一部分建议先完成Project3a和Project3c,给自己满足感,然后开始Project3b的旅途。Project3a,是在Raft层完成TransferLeader的工作,大概100行不到,半天或者一天就能完成,Project3c是完成一部分的调度器更新操作,两个函数,100行左右,两个函数,我分成两天来做,但是一天半左右就能够完成。总共花费两天的时间。
Project3b,这一部分需要完成TransferLeader,ConfChange,Split三个部分,每个部分是互不干扰的,测试集中是后面的部分包含前面的部分,所以Debug的时候一步一步来。这一部分的代码在200多行,实际写的话由于理解上的难度需要3天或者四天。
Project4,这一部分,代码量偏大,理解难度较高,但是测试比较方便,所以建议看看官网培训的视频,然后再动笔写,写的时候注意逻辑的通畅,再通过测试集看看有什么遗漏的地方,然后再看看别人的博客,看看和自己的理解有什么不同。大概Project4a 1天,Project4b 1天,Project4C 一天半,总共代码量在400到500行左右。
总结
Project1 200行代码,大致两天完成;Project2a 1000行左右的代码,由于很多重复的部分,所以在4天或者5天内完成;Project2b,300行代码,理解两天,完成代码两天,debug另算;Project2C,100多行代码,理解1天,debug1天;Project3a 100行代码,1天即可完成;Project3b 200到300行左右,由于难度原因需要3到4天完成代码,debug另算;Project3c 100多行代码,由于文档写的很清楚,博客也写的很清楚,所以一天半就能够完成;Project4,代码在400多行,四天左右完成。我写代码一天写6个小时左右。
看了这个总结发现没有,效率最高的一天也就100行代码,低的也有50行每天,所以在正确的代码长度不变的情况下,你写代码最困难的是动笔之前的那段时间,在开始之后,最慢也不过相差一倍,每写一点距离结果就近了一部,所以实在不能理清楚不如直接开始写代码,写到一半就会发现自己需要什么了,我在写Project2b的时候就是实在不会了,开始写,然后才发现ApplyEntry这个部分应该在哪个位置,也就是事务在数据库中实现是在什么时候。
项目理解
Project1:这个可以参考raft_storage中的实现,但是还是需要理解。存储到磁盘中需要开辟空间,在代码中应该是新建文件夹和记录下存储的路径,具体的函数可以去学习badgerDB的函数。Reader,是一个接口,这个接口实现了GetCF、IterCF、Close函数,这个接口需要自己实现,自己设计一个数据结构,看看实现GetCF,IterCF需要哪些数据,根据文档中hints,只需要一个Txn即可。Write就更简单了,直接调用对应的函数写入数据库中即可。
raw_api的实现,Get,Write,Delete直接调用函数即可,Scan需要学习Iter的使用,也很简单。
Project2: project2a,这一部分设计好了数据结构,设计好了部分的函数接口,需要根据论文的描述来实现对应的操作。包括SendHeartbeat SendRequestVote SendAppendEntry,发送信息的函数,发送信息我们需要实现的部分是把需要发送的信息存储起来,然后到时候再执行发送操作。执行的部分HandleRequestVote...基本上每个信息都有需要有对应执行的函数。还是比较好理解的。
project2b,这一部分是Raft上面一层的实现。接收消息之后根据论文描述,1. 向各个节点分发事务 2. 各节点保存事务信息后提交事务,再实现事务。这两个部分在不同的函数中实现,1是在ProposeRaftCommand函数中实现 2无论什么什么时候实现都可,但是在本实验中默认在HandleRaftReady函数中实现,接在后面即可。除了上述需要完成的任务,还有修改并保存文档中描述的四种信息、利用Send函数发送2a中存储的信息。
当然了,Raft和这一层之间需要相互沟通,利用的2ac中的Ready信息实现Raft层向上一层发送消息。
project2c,这一部分是快照的实现。如果Follower落后Leader太多Entry,Leader就会发送某个时间对应的状态,让Follower完全转变成当前状态,而不需要一个Entry一个Entry地实现。所以在本地需要处理Snapshot的信息,在上一层也需要实现Snapshot。根据Ready中保存的Snapshot信息更新对应的状态,然后根据文档中的提示还需要发送一些信息来实质性的修改数据库的内容。
project3a,实现TransferLeader,保证被转换的Follower的日志记录和自己至少一样新,就可以发送Timeout信息给对应的节点重新开始选举了,这一部分参考论文,有了上面的基础应该比较容易实现。
project3b,实现了TransferLeader, ConfChange, Split对应的的操作。这一部分的理解比较简单,看白皮书就能明白整个的过程,但是这一部分的Bug非常难找。
project3c,文档的描述加上博客已经足够详细了,仔细阅读,写完代码,然后面向测试集修改不对的地方。
project4,为了维持事务的一致性进行的操作。正常存储数据直接把key value CF存储进去即可。但是事务可能会执行失败导致回滚,所以需要特殊的机制来保证正确性。有"lock" "default" "write"三种CF,其中"default" "write"的Key值里还包含了时间信息,在执行时先写入"lock"+key/lock信息,表示将key给锁住,value为lock的信息;再写入"default"+key+startStamp/value,这里是真正的key/value存储的地方,和之前不同的是,保存了时间的信息。如果我查找某个时刻的值可以通过二分查找直接找到。在事务结束的时候就会去掉"lock"信息,把"write"信息写入,对应为"write"+key+commitStamp/write信息,write信息中包含了对应的"default"的key的时间信息,就可以通过查看当前已经写入的write对应的write信息,并由此得到"default"存储的时间,得到真正需要的value。
除此之外还有回滚操作,就是删除对应key的"lock"信息和"default"信息,并且添加一个Write信息,这个信息中Write的种类为Rollback,存储了事务的StartStamp,标识这这个StartStamp对应的事务已经回滚。
Snapshot的理解
在处理Snapshot时,就是把状态全部保存,将要处理的事物清空,也就是回到某一时刻的状态
感觉Snapshot像是这个逻辑,
- onRaftGCLogTick()中判断已经应用的entry但是未被压缩的条目数目是否超标,如果超标就会调用proposeRaftCommand
- 当这个信息被提交之后Leader就开始处理Compact Log这个操作,步骤是更新
RaftTruncateState
,这个状态包含在RaftApplyState中,然后调用提供的函数ScheduleCompactLog
完成实际上的压缩
- 然后由于Leader压缩了日志,所以在发送AppendEntries的时候有可能会出现Follwer需要的项目已经被压缩,说明Follower太过于落后。接收到Snapshot之后,跟随者丢弃其整个日志;它全部被快照取代,并且可能包含与快照冲突的未提交条目。如果接收到的快照是自己日志的前面部分(由于网络重传或者错误),那么被快照包含的条目将会被全部删除,但是快照后面的条目仍然有效,必须保留。但是网上的帖子说直接删除全部的快照才是对的。发送Snapshot按照文档中描述是通过另外的通道来完成与Append这些信息不同。
- Follower接收到信息之后,通过handleSnapshot进行修改,相应的Ready就会产生,在下一次调用的时候,SaveReadyState就会修改上一层的相关状态,SaveReadyState在project2c中有描述
- SaveReadyState完成之后就是Commit entry的处理,根据提示这里也需要修改RaftTruncatedState,然后需要调用ScheduleCompactLog()这个函数。奇怪的是在SaveReadyState时RaftTruncatedState也被修改过一遍了,是哪一遍多了?
Add/Remove Node 理解总结
- RaftStore收到该请求,发送到Leader的手中,Leader需要将请求复制到大多数成员手中,成功提交之后开始应用
- 在ApplyConfEntry时需要实现大致是两个部分: 更新Region的信息,更新storeMeta的信息
- RemoveNode部分
- 如果执行这个操作的节点是被移除的节点,需要自我销毁,本实验中调用
d.destroyPeer()
,其他的事情不用做了
- 如果不是,需要修改对应Region中的信息,包括Region中有哪些peer
Peers
,和RegionEpoch
版本信息,修改之后需要保存到KvDB中,利用writebatch完成写入操作
- 处理这些信息之外,还有一个部分的信息需要删除,就是发送数据时对应的信息,对应于peer中的peerCache数据结构,这个数据结构的作用是当需要向其他的peer发送消息时查看对应的store id,删除之后发送消息就不会发送到被移除的节点上了。本实验中调用
d.removePeerCache()
方法
- AddNode部分:
- 更新对应Region中的信息,包括Region中有哪些peer
Peers
,和RegionEpoch
版本信息,修改之后需要保存到KvDB中,利用writebatch完成写入操作
- 更新网络的peerCache信息,利用
insertPeerCache
- 在
store_worker.go
的 onRaftMessage()
方法中可以看到,当目标的 peer 不存在时,它会调用 d.maybeCreatePeer()
尝试创建 peer。 而这一步是由Leader向其他节点发送消息时进行判断,通过PeerCache发现应该有却没有的时候尝试创建。
- 调用Raft层的
ApplyConfChange
方法,需要注意可能在删除的时候把Leader删除了,可能会出现Bug,这需要自己去想办法解决。
- 会出现重复发送Conf Change的情况,想办法避免重复操作,可以利用RegionEpoch中的Conf_ver来判断
- 通知调度器更新消息,可以先看看Project3C,调度器通过HeartbeatScheduler来更新调度器中的消息,只用发送一遍即可,当然冗余发送应该也不会出现问题。
- 文档中Hint说的storeMeta要修改什么东西?难道删除到最后可能没有这个Region了还是添加的时候新建了一个Region?更新 b+ tree regionRange的内容 暂时不知道怎么更新
Split理解
peer_msg_handler.go
中HandleMsg()
处理MsgTypeTick
信息调用onTick()
函数,然后再调用onSplitRegionCheckTick()
方法,发送一个SplitCheckTask
到split_checker
如果满足split条件,生成一个MsgTypeSplitRegion
请求
raft_worker
接收到对应的消息后,调用HandleMsg
,在处理MsgTypeSplitRegion
任务时,首先需要预处理调用onPrepareSplitRegion
,传入的参数有分隔的keysplitKey
,版本信息RegionEpoch
,返回的接口cb *message.Callback
在该函数中,完成了三项检查:检查splitKey
是否为空,判断当前节点是否是Leader,版本是否正确。然后把runner.SchedulerAskSplitTask
消息发送到d.ctx.schedulerTaskSender
中。
- 该消息由
scheduler_taskHandler
处理,调用onAskSplit
,一系列操作之后调用sendAdminRequest
把请求发送出去。
raft_worker
接收到请求后调用HandeMsg
开始进行处理
上述是代码中已经给出的处理。我们需要将一个Region划分成两个,因为Region中主要是保存信息,所以修改信息即可。创建新的Region之后需要创建Region需要的新的peer,利用CreatePeer()方法,Peer创建后需要对应的初始化操作,当然也需要更新Region的相关信息。
- 首先是propose该Request,发送之前需要两项检查,1是
CheckKeyInRegion
函数检查被划分的Key是否在范围之内 2是检查RegionEpoch
是否正确,但是在onPrepareSplitRegion
中就检查过了,
- commit之后开始应用。分成两个Region,一个Region只是在原来的基础上修改EndKey和RegeionEpoch,新的Region需要在原来的基础上修改
StartKey, RegionEpoch, RegionId, Peers
. 创建新Peer
- 更新信息
debug
project2b
这个时候没有很好地记录bug,非常乱,而且现在看起来很二
- get wrong value, client2 然后内容存了两遍
- get wrong value 最后缺少一个
- 一部分原因是AppendEntry的代码出现了错误,笔误
- 当时没有解决,在TestSplitConfChangeSnapshotUnreliableRecoverConcurrentPartition3B中有描述
- request timeout
- msg传入ProposeRaftCommand的是指针,然后把这个修改了,导致后续发的时候是空的,一直就没有反应,出现错误。
project3b
这一部分代码的debug除了利用日志文件分析错误之外,我还用了另一种方法,就是尝试法,这一部分的代码长度不长,结构较为简单,所以反复查看代码,觉得有哪些地方不是很合适,或者说可以进行修改就改一改试试看,说不定就解决了麻烦。
TestBasicConfChange3B
MaybeCreatePeer时出现了问题,在发送Heartbeat时发送的Commit需要为0,但是之前的测试集要求能利用Heartbeat更新Commit
- panic: [region 1] 2 unexpected raft log index: lastIndex 0 < appliedIndex 7
这个问题在于在destroyPeer之后继续调用WriteToDB函数
TestConfChangeRecover3B
request timeout
一直发送Heartbeat和HeartbeatResponse,但是这个是正常的,也就是没有收到消息?
将Log级别调整到debug后发现出现Epoch don't Match,看到最后Region不相同,说明有的地方Region信息没有更新,最后发现Snapshot在处理时没有更新相关的信息。添加如下代码
d.peerStorage.SetRegion(result.Region)
storeMeta := d.ctx.storeMeta
storeMeta.Lock()
storeMeta.regions[result.Region.GetId()] = result.Region
storeMeta.regionRanges.Delete(®ionItem{region: result.PrevRegion})
storeMeta.regionRanges.ReplaceOrInsert(®ionItem{region: result.Region})
storeMeta.Unlock()
TestConfChangeUnreliable3B
request timeout,TransferLeader一直失败,在handleHeartbeatResponse和AppendResponse时,出现错误,判断发送Append消息的条件有问题,当时想的是如果我提交的没有Leader提交的多,就要发送Append消息,但是应该是我匹配的没有Leader匹配的多就需要发送Append消息。
request timeout,这个不知道是为什么,但是这个是我在增加判断是否在PendingConfChange的时候出现的BUG,这个很神奇,如果在判断确定现在已经有一个ConfChange在进行中,直接利用cb.Done返回消息,那么就会导致超时,如果不返回,让它自己超时就不会出现这个bug
TestSplitConfChangeSnapshotUnreliableRecoverConcurrentPartition3B
- unexpected log index, lastIndex < applyIndex
这个也是很神奇的Bug,如果lastIndex等于0,很有可能是在stop之后还进行了写入操作。但是如果是其他情况,就有可能是在SaveReadyState函数中,先调用的Append函数,再调用的Snapshot函数,太隐蔽了
- test_test.go:63: 2 missing element x 2 8 y in Append result x 2 0 yx 2 1 yx 2 2 yx 2 3 yx 2 4 yx 2 5 yx 2 6 yx 2 7 y
之前说是解决了这个问题,在胡扯,都不知道为什么。这个问题在于我们在handleHeartbeat的时候将r.Vote置为空。我是把这个操作写到了becomeFollower中。观察下面的日志,发现有Index和Term相同但是entry不同的情况。
这个的原因在日志上反映出来是一个Term出现了两个Leader。过程如下:1 2 3 三个节点,开始选举,1选举成功,但是3是没有收到对应的RequestVote这样的消息,可能是Unrealiable也可能是Partition的原因。然后在1还没有把空Entry成功发送到2中,所以这个时候2的Entry还没有更新,然后1 | 2 3被分隔开,3开始选举,因为2的Vote被置为None,而同一个Term,空的也是可以给别人投票的就出现了同一个任期两个Leader的情况。然后就可能会出现相同Index Term有不同的Entry,导致缺失了一个。
Apply Put Entry [cf: default] [key 2 00000008] [val: x 2 8 y] [peers: 3] [peerid: 11] [entryIndex: 13] [entryTerm: 11] [Leader: 11] [len(d.proposals): 0] [Tag: [region 6] 11]
Apply Put Entry [cf: default] [key 3 00000006] [val: x 3 6 y] [peers: 3] [peerid: 7] [entryIndex: 13] [entryTerm: 11] [Leader: 11] [len(d.proposals): 1] [Tag: [region 6] 7]
Apply Put Entry [cf: default] [key 2 00000008] [val: x 2 8 y] [peers: 3] [peerid: 8] [entryIndex: 13] [entryTerm: 11] [Leader: 11] [len(d.proposals): 0] [Tag: [region 6] 8]
感想
这个项目耗时六个星期,过程非常痛苦,如果不是在老师的实验室待着肯定坚持不了这么久。所以如果要完成这样一个难度的项目最好找到一个适合学习的地方和一起学习的人,否则很容易放弃。
但是这个项目能让没有工程经验的人认识到一个项目到低是什么样的,和洛谷、leetcode的习题的区别是什么,该怎么操作。这个项目中有很多很重要的步骤能丰富我们经验、锻炼我们的能力,包括查找资料、阅读文档、构建整体架构、论文复现。
这个项目也让我认识到了工具的重要性,刚开始的时候利用vscode写代码,因为用的是虚拟机,所以没有怎么装插件,写的非常痛苦,强烈建议用比较好的代码补全插件或者是Goland写,否则很难完成一个项目任务,因为结构实在是太过于复杂,没有代码补全写不下去。
如果想要了解完成一个项目的过程,想要掌握分布式系统的知识,学习这门课程是非常好的选择。