2023 MIT 6.5840/6.824 分布式系统 Lab3: 容错键值存储实现笔记

前言

笔者基于先前实现的 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] 一致性模型与共识算法

相关推荐
夜色呦9 分钟前
现代电商解决方案:Spring Boot框架实践
数据库·spring boot·后端
爱敲代码的小冰18 分钟前
spring boot 请求
java·spring boot·后端
Lyqfor31 分钟前
云原生学习
java·分布式·学习·阿里云·云原生
流雨声1 小时前
2024-09-01 - 分布式集群网关 - LoadBalancer - 阿里篇 - 流雨声
分布式
java小吕布1 小时前
Java中的排序算法:探索与比较
java·后端·算法·排序算法
floret*1 小时前
用pyspark把kafka主题数据经过etl导入另一个主题中的有关报错
分布式·kafka·etl
william8232 小时前
Information Server 中共享开源服务中 kafka 的__consumer_offsets目录过大清理
分布式·kafka·开源
Goboy2 小时前
工欲善其事,必先利其器;小白入门Hadoop必备过程
后端·程序员
李少兄2 小时前
解决 Spring Boot 中 `Ambiguous mapping. Cannot map ‘xxxController‘ method` 错误
java·spring boot·后端
P.H. Infinity2 小时前
【RabbitMQ】10-抽取MQ工具
数据库·分布式·rabbitmq