课程官网提供的 Lab 代码下载地址,我没有访问成功,于是我从 Github 其他用户那里 clone 到干净的源码,有需要可以访问我的 Gitee 获取
Lab3A: KVRaft 利用分布式存储的特点实现了简单且稳定的 kv 存储数据库,可以通过 Put 或 Append 请求将 kv 键值对存放在其中,并且可以通过 Get 请求来访问 key 所对应的 value 值
现在,Lab4A: Sharded Master 想要实现的是一个类似于 Zookeeper 的分布式存储配置的数据库,为什么利用分布式存储呢?主要是想对外提供稳定的存储配置信息的功能
其实说白了,就是把 Lab3A: KVRaft 单纯的 kv 存储数据库换成 Config 配置信息,前者的 PutAppend 或 Get 请求是用来更新 kv 表的;而 Lab4A: Sharded Master 的 Join/Leave/Move/Query 请求是用来更新 Config 配置信息的
所以,Lab4A: Sharded Master 从本质上和 Lab3A: KVRaft 是没有什么区别的,最大的不同就是业务逻辑的不同
先来梳理一下 Sharded Master 的流程,Client 发出 Join/Leave/Move/Query 请求之后会去寻找 primary sharded server,然后通过 RPC 与其进行交互,要求 server 根据请求更新 Config 配置信息
更新后的 Config 配置信息随着 Raft 机制同步到集群中的每台 server 中,达到分布式稳定存储的目的
可以将 Clerk 当成 Client 看待,每个 Client 都会生成一个 Clerk,由这个 Clerk 全权办理请求事宜,定义如下,
type Clerk struct {
servers []*labrpc.ClientEnd
// Your data here.
leaderId int /* Raft 集群中谁是 leader */
clntId int64 /* client 的编号 */
cmdId int /* 该 client 的第几条命令 */
}
其中的 leaderId
记录了集群 leader 的编号,以便 clerk 下次能够快速找到 primary service;clntId
是 client 的编号,理论上来说应该是唯一的;cmdId
是该 client 的第几条命令。之后在 kvraft/client.go
中完善 MakeClerk()
,
func MakeClerk(servers []*labrpc.ClientEnd) *Clerk {
ck := new(Clerk)
ck.servers = servers
// Your code here.
ck.leaderId = 0
ck.clntId = nrand()
ck.cmdId = 0
return ck
}
之后,就是 Join/Leave/Move/Query 四个请求了,逻辑都差不多,所以代码也长一个样,
func (ck *Clerk) Query(num int) Config {
args := &QueryArgs{}
// Your code here.
args.Num = num
args.CmdId = ck.cmdId
args.ClntId = ck.clntId
ck.cmdId++
for {
// try each known server.
for _, srv := range ck.servers {
var reply QueryReply
ok := srv.Call("ShardMaster.Query", args, &reply)
if ok && reply.WrongLeader == false {
return reply.Config
}
}
time.Sleep(100 * time.Millisecond)
}
}
func (ck *Clerk) Join(servers map[int][]string) {
args := &JoinArgs{}
// Your code here.
args.Servers = servers
args.ClntId = ck.clntId
args.CmdId = ck.cmdId
ck.cmdId++
for {
// try each known server.
for _, srv := range ck.servers {
var reply JoinReply
ok := srv.Call("ShardMaster.Join", args, &reply)
if ok && reply.WrongLeader == false {
return
}
}
time.Sleep(100 * time.Millisecond)
}
}
func (ck *Clerk) Leave(gids []int) {
args := &LeaveArgs{}
// Your code here.
args.GIDs = gids
args.ClntId = ck.clntId
args.CmdId = ck.cmdId
ck.cmdId++
for {
// try each known server.
for _, srv := range ck.servers {
var reply LeaveReply
ok := srv.Call("ShardMaster.Leave", args, &reply)
if ok && reply.WrongLeader == false {
return
}
}
time.Sleep(100 * time.Millisecond)
}
}
func (ck *Clerk) Move(shard int, gid int) {
args := &MoveArgs{}
// Your code here.
args.Shard = shard
args.GID = gid
args.ClntId = ck.clntId
args.CmdId = ck.cmdId
ck.cmdId++
for {
// try each known server.
for _, srv := range ck.servers {
var reply MoveReply
ok := srv.Call("ShardMaster.Move", args, &reply)
if ok && reply.WrongLeader == false {
return
}
}
time.Sleep(100 * time.Millisecond)
}
}
大概就是给 primary sharded server 发送 RPC 请求,要求其根据请求进行相应的更新
Config 配置信息是 Lab4A: Sharded Master 所需要关心的内容,和 Lab3A: KVRaft 的 kv 表一样。我们看一下它的定义,
// The number of shards.
const NShards = 10
// A configuration -- an assignment of shards to groups.
// Please don't change this.
type Config struct {
Num int // config number
Shards [NShards]int // shard -> gid
Groups map[int][]string // gid -> servers[]
}
Num
可以理解成该 Config 的编号;Shards
记录了每个分区所属的 Group;Groups
记录了每个 Group 中有哪些 server;NShards
是约定俗成的分片数,这里框架规定分片总数仅为 10
然后,定义 Join/Leave/Move/Query 的 RPC,主要是 Args 部分,Reply 可以不用修改,
type Args struct {
ClntId int64 /* client 的编号 */
CmdId int /* 该 client 的第几条命令 */
}
type JoinArgs struct {
Args
Servers map[int][]string // new GID -> servers mappings
}
type LeaveArgs struct {
Args
GIDs []int
}
type MoveArgs struct {
Args
Shard int
GID int
}
type QueryArgs struct {
Args
Num int // desired config number
}
这里我将公共部分提炼出来,用结构体 Args 记录了 client 编号和命令编号(主要是为了去重,参见 Lab3A: KVRaft )
最后,别忘了在 labgob 中注册这些 RPC,否则框架代码不能识别,
func init() {
labgob.Register(Config{})
labgob.Register(QueryArgs{})
labgob.Register(QueryReply{})
labgob.Register(JoinArgs{})
labgob.Register(JoinReply{})
labgob.Register(LeaveArgs{})
labgob.Register(MoveArgs{})
labgob.Register(LeaveReply{})
labgob.Register(MoveReply{})
}
这些定义都在 shardmaster/commom.go
中。还需要自己实现一个辅助函数,用其对 Config 配置信息进行深拷贝,
func (cfg *Config) DeepCopy() Config {
newcfg := Config{
Num: cfg.Num,
Shards: cfg.Shards, /* go 数组的赋值就是深拷贝 */
Groups: make(map[int][]string),
}
for gid, svrs := range cfg.Groups {
newcfg.Groups[gid] = append([]string{}, svrs...)
}
return newcfg
}
首先,就是定义操作集 Op,
type Op struct {
// Your data here.
ClntId int64
CmdId int
Kind string
Args interface{}
}
ClntId
让 server 知道这条命令由哪个 client 发来的,cmdId
标记命令的标号,然后就是键值和类型。其后的 ShardMaster 结构体非常重要,
type ShardMaster struct {
mu sync.Mutex
me int
rf *raft.Raft
applyCh chan raft.ApplyMsg
// Your data here.
ack map[int64]int /* 第 int64 位 client 已经执行到第 int 条命令了 */
results map[int]chan Op /* KV 层与 Raft 的接口 */
configs []Config // indexed by config num
}
ack
类似于 TCP 三次握手中的确认机制,大意就是检查发来的请求是否已过期,如果命令是最新发来的,那么就去状态机执行;Result
中有写入时就意味着可以回复 client 了;configs
不用多讲,它就是 Lab3A: KVRaft 的 kv 表,我们需要根据请求进行更新的,
func (sm *ShardMaster) loop() {
for {
msg := <-sm.applyCh /* Raft 集群已同步 */
op := msg.Command.(Op) /* 将 Command 空接口部分强制转换为 Op*/
idx := msg.CommandIndex /* 这是第几条命令 */
sm.mu.Lock()
/* 准备将该命令应用到状态机 */
if sm.isUp2Date(op.ClntId, op.CmdId) { /* 不执行过期的命令 */
switch op.Kind {
case "Join":
if args, ok := op.Args.(JoinArgs); ok {
sm.doJoin(args)
} else {
sm.doJoin(*(op.Args.(*JoinArgs)))
}
break
case "Leave":
if args, ok := op.Args.(LeaveArgs); ok {
sm.doLeave(args)
} else {
sm.doLeave(*(op.Args.(*LeaveArgs)))
}
break
case "Move":
if args, ok := op.Args.(MoveArgs); ok {
sm.doMove(args)
} else {
sm.doMove(*(op.Args.(*MoveArgs)))
}
break
case "Query":
break
default:
fmt.Printf("Unknown fault in server.go:loop\n")
break
}
if op.Kind != "Query" {
sm.ack[op.ClntId] = op.CmdId /* ack 跟踪最新的命令编号 */
}
}
/*
* 分流,回应 client,即继续 Get 或 PutAppend 当中的流程,
* 最后再回复 client,不然会导致 leader 和 follower 制作 snapshot 不同步
*/
ch, ok := sm.results[idx]
if ok { /* RPC Handler 已经准备好读取已同步的命令了 */
select {
case <-sm.results[idx]:
default:
}
ch <- op
}
sm.mu.Unlock()
}
}
根据 op 的类型进行不同的业务逻辑,即 Join/Leave/Move/Query。这里注意,针对 Query 请求不需要做出更新操作,因为它仅仅是获取对应编号的 Config 配置信息而已,不会也不想更改配置信息。在 Query RPC Handler 中做出回应即可,
func (sm *ShardMaster) Query(args *QueryArgs, reply *QueryReply) {
// Your code here.
entry := Op{
ClntId: args.ClntId,
CmdId: args.CmdId,
Kind: "Query",
Args: args,
}
ok := sm.appendEntry2Log(entry)
if !ok {
reply.Err = ErrWrongLeader
reply.WrongLeader = true
return
}
if args.Num >= 0 && args.Num < len(sm.configs) {
reply.Config = sm.configs[args.Num].DeepCopy()
} else {
reply.Config = sm.configs[len(sm.configs)-1].DeepCopy()
}
reply.Err = OK
}
如果请求的编号在合理的范围内,则回复相应的 config;反之则把最新的 config。针对 Join/Leave/Move 请求,server 也有相应的 RPC Handler,
func (sm *ShardMaster) Join(args *JoinArgs, reply *JoinReply) {
// Your code here.
entry := Op{
ClntId: args.ClntId,
CmdId: args.CmdId,
Kind: "Join",
Args: args,
}
ok := sm.appendEntry2Log(entry)
if !ok {
reply.Err = ErrWrongLeader
reply.WrongLeader = true
return
}
reply.Err = OK
}
func (sm *ShardMaster) Leave(args *LeaveArgs, reply *LeaveReply) {
// Your code here.
entry := Op{
ClntId: args.ClntId,
CmdId: args.CmdId,
Kind: "Leave",
Args: args,
}
ok := sm.appendEntry2Log(entry)
if !ok {
reply.Err = ErrWrongLeader
reply.WrongLeader = true
return
}
reply.Err = OK
}
func (sm *ShardMaster) Move(args *MoveArgs, reply *MoveReply) {
// Your code here.
entry := Op{
ClntId: args.ClntId,
CmdId: args.CmdId,
Kind: "Move",
Args: args,
}
ok := sm.appendEntry2Log(entry)
if !ok {
reply.Err = ErrWrongLeader
reply.WrongLeader = true
return
}
reply.Err = OK
}
三者的逻辑都是差不多的,接收请求,将请求同步到集群中的所有 servers 中,然后等待 shard Master 应用到状态机(更新 Config 配置信息),最后才会相应 client。其中的 appendEntry2Log()
方法定义如下,和 Lab3A: KVRaft 一样,
func (sm *ShardMaster) appendEntry2Log(entry Op) bool {
idx, _, isLeader := sm.rf.Start(entry)
if !isLeader {
return false
}
sm.mu.Lock()
ch, ok := sm.results[idx] /* idx 是线性递增的,跟 clntId 没有关系 */
if !ok {
ch = make(chan Op, 1)
sm.results[idx] = ch
}
sm.mu.Unlock()
/* 等待 Raft 集群同步该条命令 */
select {
case op := <-ch:
return entry == op
case <-time.After(time.Millisecond * ReplyTimeOut):
return false
}
}
然后,就是最重要的 doJoin()
、doLeave()
和 doMove()
的业务逻辑了。Join 即是有新的 Groups 加入其中,
func (sm *ShardMaster) doJoin(args JoinArgs) {
cfg := sm.configs[len(sm.configs)-1].DeepCopy()
cfg.Num++
/* args 是新增的 map,,将其添加至 cfg 中 */
for gid, srvs := range args.Servers {
cfg.Groups[gid] = srvs
avg := NShards / len(cfg.Groups)
/* 记录所有 Groups 所拥有的 Shards 个数 */
cnts := make(map[int]int)
for id := range cfg.Groups {
cnts[id] = 0 /* 默认所拥有的 Shards 个数为 0 */
}
for j := 0; j < len(cfg.Shards); j++ {
if _, ok := cnts[cfg.Shards[j]]; ok {
cnts[cfg.Shards[j]]++
}
}
if len(cnts) == 1 {
for id, _ := range cfg.Groups {
for i := 0; i < len(cfg.Shards); i++ {
cfg.Shards[i] = id
}
}
continue
}
for i := 0; i < avg; i++ {
/* 寻找 Shards 最多的 group */
mostShards := -1
mostGid := 0
for id, cnt := range cnts {
if cnt > mostShards {
mostShards = cnt
mostGid = id
}
}
idx := 0 /* mostGid 在 Shards 中第一次出现的位置 */
for j := 0; j < len(cfg.Shards); j++ {
if cfg.Shards[j] == mostGid {
idx = j
break
}
}
/* 寻找 Shards 最少的 group */
leastShards := NShards + 1
leastGid := 0
for id, cnt := range cnts {
if cnt < leastShards {
leastShards = cnt
leastGid = id
}
}
cnts[leastGid]++
cfg.Shards[idx] = leastGid
cnts[mostGid]--
}
}
sm.configs = append(sm.configs, cfg)
}
我不想过多地讲解这种 CRUD 的业务逻辑,大概就将传来的 servers 加入到相应的 Group 当中,需要平均每个 Group 中的分片个数。具体的方法即是,从当前拥有最多分片的 Group 当中抽一个分片给当前拥有分片最少的 Group,以此循环往复,直到所有 Group 的分片都差不多为止
比如,数组 Shards[]
开始是 [1, 1, 1, 1, 1, 2, 2 ,2 , 2 ,2],这时有 Group 3 加入,那么平均分片的流程应该是,
[1, 1, 1, 1, 1, 2, 2, 2, 2, 2] → \rightarrow → [3, 1, 1, 1, 1, 2, 2, 2, 2, 2] → \rightarrow → [3, 1, 1, 1, 1, 3, 2, 2, 2, 2] → \rightarrow → [3, 3, 1, 1, 1, 3, 2, 2, 2, 2]
同样,Leave 即是有 Group 要离开 Sharded Master,
func (sm *ShardMaster) doLeave(args LeaveArgs) {
cfg := sm.configs[len(sm.configs)-1].DeepCopy()
cfg.Num++
/* 将已经标明需要 Leave 的 group 在 cfg's Groups 里除名 */
for i := 0; i < len(args.GIDs); i++ {
for gid, _ := range cfg.Groups {
if gid == args.GIDs[i] {
delete(cfg.Groups, gid)
}
}
}
/* 构建 Group 和 NShard 计数表 */
cnts := make(map[int]int)
for gid := range cfg.Groups {
cnts[gid] = 0 /* 默认所拥有的 Shards 个数为 0 */
}
for j := 0; j < len(cfg.Shards); j++ {
if _, ok := cnts[cfg.Shards[j]]; ok {
cnts[cfg.Shards[j]]++
}
}
/* 瓜分 Leaved group 的 Shards */
for i := 0; i < len(args.GIDs); i++ {
gid := args.GIDs[i]
for j := 0; j < len(cfg.Shards); j++ {
if gid != cfg.Shards[j] {
continue
}
/* 将该 Shard 分配给拥有 Shard 数量最少的 Group */
leastShards := NShards + 1
leastGid := 0
for id, cnt := range cnts {
if cnt < leastShards {
leastShards = cnt
leastGid = id
}
}
cfg.Shards[j] = leastGid
cnts[leastGid]++
}
}
sm.configs = append(sm.configs, cfg)
}
逻辑大概即是将当前 Group 中的所有分片均匀地分配给其他的 Group。比如,数组 Shards[]
开始是 [3, 3, 1, 1, 1, 3, 2, 2, 2, 2], 这时需要将 Group 3 撤走,那么撤走的流程应该是,
[3, 3, 1, 1, 1, 3, 2, 2, 2, 2] → \rightarrow → [1, 3, 1, 1,1, 3, 2, 2, 2, 2] → \rightarrow →[1, 1, 1, 1, 1, 3, 2, 2, 2, 2] → \rightarrow → [1, 1, 1, 1, 1, 2, 2, 2, 2, 2]
Move 即是将指定的分片从当前 Group 移至到特定的 Group,这个较为简单,
func (sm *ShardMaster) doMove(args MoveArgs) {
cfg := sm.configs[len(sm.configs)-1].DeepCopy()
cfg.Num++
cfg.Shards[args.Shard] = args.GID
sm.configs = append(sm.configs, cfg)
}
直接修改数组 Shards[]
值,实现重新选择 Group 挂载分片即可
golang 比较麻烦,它有 GOPATH 模式,也有 GOMODULE 模式,6.824-golabs-2020 采用的是 GOPATH,所以在运行之前,需要将 golang 默认的 GOMODULE 关掉,
$ export GO111MODULE="off"
随后,就可以进入 src/shardmaster
中开始运行测试程序,
$ go test
仅此一次的测试远远不够,可以通过 shell 循环,让测试跑个两百次就差不多了
$ for i in {1..200}; go test
这样,如果还没错误,那应该是真的通过了。分布式的很多 bug 需要通过反复模拟才能复现出来的,它不像单线程程序那样,永远是幂等的情况。也可以用我写的脚本 test_4a.py,
import os
ntests = 200
nfails = 0
noks = 0
if __name__ == "__main__":
for i in range(ntests):
print("*************ROUND " + str(i+1) + "/" + str(ntests) + "*************")
filename = "out" + str(i+1)
os.system("go test | tee " + filename)
with open(filename) as f:
if 'FAIL' in f.read():
nfails += 1
print("✖️fails, " + str(nfails) + "/" + str(ntests))
continue
else:
noks += 1
print("✔️ok, " + str(noks) + "/" + str(ntests))
os.system("rm " + filename)
我已经跑过两百次,无一 FAIL