YoloKokura

NOTE:本文为6.824(分布式系统)Lab 3的回顾,实验要求见这里。因为要遵守课程的Collaboration Policy,所以本文不会分享任何实现细节的代码(可能还是会有一些逻辑性的简单代码帮助阐明思路)。

在Lab 3中,我们基于之前Lab 2实现的Raft协议构建一个KV存储服务,此外还提供基础的快照功能,确保系统的可容错性。在某种意义上,可以认为这是一个简陋的Redis,去掉了高效的数据结构和IO上的优化,性能上也完全说不过去,但用来加强对Raft的理解倒是很有用。

服务架构

整体而言,整个结构分为客户、服务、Raft和物理存储四层:

  • 客户层即直接面向应用的KV数据库,向外暴露Put、Append和Get三种接口。客户层可以直接和下层Raft服务集群通信,但只会向集群LEADER发送请求。
  • 在服务器中,KV数据是直接存储在内存中的,Lab中直接使用了Golang的map,但这显然是一种简化处理,实际项目中要对内存利用做更多优化(脑子里不禁又开始联想Redis)。
  • Raft层确保了集群各成员之间的强一致性,这部分在Lab 2中已经实现。放在KV存储的应用场景中,上层的服务将log信息传递给Raft,Raft在确保log同步到集群多数之后,向服务层传递信息,此时服务层可将操作应用到状态机中。
  • 物理存储层即Lab 2中用到的persister,负责持久化log和服务器的状态,这样服务器遇到故障重启后不至于落后整个集群状态太远。

三种基本操作的实现

对于每个客户请求,Client实际上是将请求封装到RPC参数中,传递到服务层存储并等待服务层返回RPC reply。在此过程中,Client需要遍历集群,访问集群成员状态,直到找到LEADER,后面可以直接将LEADER保存在Client缓存中,下次访问直接从LEADER开始。另外,我们需要记录Client访问Server时发出的操作请求的顺序,服务器必须按序处理请求过程,这里我直接使用了一个自0开始的序列号作为标记。为了应对多个Client并发操作的情况,我们还应该给每个Client一个特殊标识,在实际的应用中这意味着采用一种分布式Id生成方案(日后我会总结一下),但是在Lab中,直接使用随机数也没啥问题,test代码的用户数量很少,显然是希望把注意力集中在KV存储这一块。

server在接收到Client的RPC请求后,会将对应的操作封装起来传递给下层Raft,等待操作被apply,在那之后返回操作结果。操作从提交到Raft,到Raft通过applyCh返回,是一个异步过程。server中有一个独立线程持续消费applyCh中的内容,每当获取一个操作,首先判断操作是否是顺序操作,要是是乱序,那么可以丢弃,然后再根据操作类型对内存中的数据进行处理。

总结起来看:

  • Client需要提供clientIdrequestId和操作参数(对Get来说是键,对PutAppend来说还有值)。
  • Server需要保存当前的操作序列(由clientIdrequestId标识),和下层Raft通信,并在内存中维护一个map,以存储键值对数据。

server的响应过程

从上面的分析可以看出,client相对轻量化,重要的工作都在server上,因此我们下面重点关注一下server对操作的处理。

首先server收到client的RPC请求,将操作(所有需要Raft来同步的数据)封装到一个Op中,这个结构体代表一个数据库操作。

go
type Op struct {
    Key string
    Value string
    RequestId int64
    ClientId int64
    Type string
}

紧接着,server调用waitForApply函数,其中会调用Lab 2暴露的rf.Start()函数,如果不是LEADER,Start会直接返回相关信息,server也就可以直接返回一个ErrWrongLeader错误。反之,LEADER会返回这个Op对应的log的索引值(回顾一下Lab 2,LEADER在复制log之前,会给客户,也就是这里的server,返回一个计划索引值)。我们在server中临时创建一个channel,用于接收返回的消息。

与此同时,server的applier协程一直在消费来自Raft层applyCh的内容,那么在正常情况下,它必然最终能够接收到Op对应的Applied Message。如果这条message是操作信息,那么我们提取出其中的Command,转换为Op(事实上Command是一个interface),对比并更新Client的操作序列号(这在前一节提到了),如果是顺序数据,就按照要求对KV map进行更新,然后将更新结果写入Op中,传入前面的channel(如果该channel存在)里。

最后,我们在waitForApply中监听这个channel,如果超时就返回ErrTimeout,提示client重试,反之,对比返回的Op和原Op的标识信息,如果相同,则说明这就是我们请求对应的返回,将这个结果返回给client,否则说明Raft层内部可能由于网络或者主机崩溃等原因,LEADER发生了改变,或者消息同步不及时,返回报错,让client重发请求。

快照功能实现

我们在Lab 2中实现了Raft算法的数据持久化,分为两个部分:

  • log compaction:一旦内存中的log超出一定数量,我们就将之前的log裁剪,利用persister将之保存起来。
  • server层状态持久化:Raft层响应server层的Snapshot请求,将server层序列化的数据持久化。

因此,我们这里只需要server层采取一定策略序列化服务器状态,在启动服务器时读取快照即可。

存储的数据主要是当前内存中的map,否则崩溃之后数据丢失,还得对log做replay(again,想到RDB和AOF),以及对各个client的历史序列号的记录。

我采取的策略是在applier中检查Raft层的log长度,一旦超过server层的限制,马上保存一次快照。

Tags: