前言
笔者基于先前实现的 Raft 库继续实现了 Lab3:容错键值存储。受课程组的保密要求,无法公开源码,故撰写本文以记录实现思路和需要考虑的问题,旨在助力正在完成实验的读者。
容错键值存储
搭建完 Raft 之后,我们可以基于 Raft 库开发一些分布式服务,Lab3 即是一个分布式容错的键值存储服务。这个服务需支持 Get(读取值)、Put(写入值)、Append(追加值) 等操作,运行多个节点,由 Raft 库选出的主节点(Leader)对外提供服务。若集群出现网络分区、少数节点故障,不应影响到服务的正常运行,若主节点故障,则及时切换为新的主节点对外提供服务。
如图所示,在 Lab3 中,同一个客户端不会并发地发送请求,它们只会在一个请求响应成功之后再发送下一个,若一次未成功,则重试至成功为止,因此客户端不需要加锁。
我们实现的这个键值存储服务必须得是线性一致的,即若操作A的响应先于操作B的请求完成,则服务必须按顺序先执行操作A,再执行操作B。例如客户端 1 分别执行了Put(x, 'a')
、Append(x, 'c')
、Get(x)
,按照线性一致的要求,Get(x)
读到的值一定为 ac
。对于单节点服务来说,实现线性一致是件再简单不过的事情,只需对每个操作加锁按顺序执行即可,而对于分布式服务,就存在着若干需要考虑的问题。
如图所示,Peer1 在 Term1 追加了三个操作日志,并全部复制给了 Peer2,因此三个日志都在 Peer1 提交并执行,此时 Peer1 的状态为 {"x": "ac", "y": "b"}
。不巧的是,Peer1 和另外两个节点之间出现了网络分区,于是 Peer2 在 Term2 成为新的主节点,它仅仅提交了前两个日志,状态为 {"x": "a", "y": "b"}
,此时客户端 1 执行 Get(x)
的结果为 a
,不满足线性一致。
为了解决这个问题,最简单的方案是把 Get
操作也追加至日志中,这样可以保证所有节点的所有操作都是严格按照相同的顺序执行的。如图所示,客户端发起操作请求(可以是 Get
,也可以是 PutAppend
),等待 Raft 将操作日志复制到超过半数节点上,则执行并响应结果。在生产级别的实现上,一般会采用 Read Index 或者 Lease Read 的方式来提高读性能。
为了唯一标识某条操作日志,可以给日志加上客户端 ID 和操作顺序号,也方便把命令执行后的结果响应给客户端。
go
type Op struct {
Key string
Value string
Type OpType
ClientId int64
SequenceNum int64
}
主节点随时可能故障,集群随时可能切换新的主节点提供服务,为了能"记得"客户端的命令运行情况,各个节点应该给每个客户端维护一个会话(Session)。其中,IsOnline
表示这个客户端是否在线,即是否正在等待响应,若在线则通过 OpReplyCh
将执行结果发给它。LastAppliedSequenceNum
表示该客户端最后一个被执行的操作 sequenceNum
,如果已经执行则可以立即响应成功(Get
操作返回 LastGetValue
),如果还未执行则等待 OpReplyCh
发来的结果。
go
type Session struct {
OpReplyCh chan OpReply
LastAppliedSequenceNum int64
IsOnline bool
LastGetSequenceNum int64
LastGetValue string
}
type KVServer struct {
mu sync.Mutex
me int
rf *raft.Raft
applyCh chan raft.ApplyMsg
dead int32 // set by Kill()
persister *raft.Persister
maxraftstate int // snapshot if log grows this big
storage map[string]string
sessions map[int64]Session
snapshotCond *sync.Cond
lastIncludedIndex int
}
服务运行时间久了,同样会存在日志过长的问题。需要本服务及时监控到触发高水位并压缩日志。我们可以用等待-唤醒机制实现一个 snapshoter,每当日志被提交就唤醒它,它检查一旦日志大小超过阈值的 90% 就保存快照、截断日志。
go
func (kv *KVServer) snapshoter() {
kv.snapshotCond.L.Lock()
defer kv.snapshotCond.L.Unlock()
for !kv.killed() {
for kv.maxraftstate == -1 || kv.persister.RaftStateSize() < kv.maxraftstate/10*9 {
kv.snapshotCond.Wait()
}
kv.mu.Lock()
w := new(bytes.Buffer)
e := labgob.NewEncoder(w)
e.Encode(kv.storage)
e.Encode(kv.sessions)
kv.rf.Snapshot(kv.lastIncludedIndex, w.Bytes())
kv.mu.Unlock()
}
}
如图所示,在实现过程中,还需注意几个问题:
脑裂:由于网络分区,集群被分为若干个互不连通的小集群,此时集群中可能有多个不同 Term 的主节点,且它们都认为自己是合法的主节点,对外提供服务。客户端需要识别到这种情况并及时切换到最新 Term 的主节点上,由于先前 Term 主节点所在集群节点数为少数,因此它不能达成共识,客户端可以设定一超时时间,若在规定时间内未能响应,则切换节点。
go
for opReply.SequenceNum != args.SequenceNum {
select {
case opReply = <-session.OpReplyCh:
reply.Err = opReply.Err
case <-time.After(100 * time.Millisecond):
reply.Err = ErrTimeout
opReply.SequenceNum = args.SequenceNum
}
}
同一操作被追加至日志多次 :例如,客户端 1 向 Peer1 发送 Append(x, 'c')
请求,Peer 1 把它复制到所有节点上后就故障了,由于未能按时响应,客户端 1 又再次向 Peer2 发送 Append(x, 'c')
请求,此时若 Peer2 向 Peer3 复制日志,则两条 Append(x, 'c')
命令都会被提交。这两条命令具有相同的 (clientId, sequenceNum)
,在状态机执行命令时,需过滤掉重复的命令。
参考资料
[1] MIT6.824-2021 Lab3 : KVRaft
[2] 一致性模型与共识算法