#Lab4A - Sharded Master
I. Source
- MIT-6.824 2020 课程官网
- Lab4: Sharded Key/Value Service 实验主页
- simviso 精品付费翻译 MIT 6.824 课程
- Paper - Raft extended version
II. My Code
课程官网提供的 Lab 代码下载地址,我没有访问成功,于是我从 Github 其他用户那里 clone 到干净的源码,有需要可以访问我的 Gitee 获取
III. Motivation
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 是没有什么区别的,最大的不同就是业务逻辑的不同
IV. Solution
先来梳理一下 Sharded Master 的流程,Client 发出 Join/Leave/Move/Query 请求之后会去寻找 primary sharded server,然后通过 RPC 与其进行交互,要求 server 根据请求更新 Config 配置信息
更新后的 Config 配置信息随着 Raft 机制同步到集群中的每台 server 中,达到分布式稳定存储的目的
S1 - client的Join/Leave/Move/Query请求
可以将 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 请求,要求其根据请求进行相应的更新
S2- common定义Config和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
}
S3 - server回应请求
首先,就是定义操作集 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,<gid, servers>,将其添加至 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 挂载分片即可
V. Result
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