「实验记录」MIT 6.824 Lab4A Sharded Master

#Lab4A - Sharded Master

  • I. Source
  • II. My Code
  • III. Motivation
  • IV. Solution
    • S1 - client的Join/Leave/Move/Query请求
    • S2- common定义Config和RPC
    • S3 - server回应请求
  • V. Result

I. Source

  1. MIT-6.824 2020 课程官网
  2. Lab4: Sharded Key/Value Service 实验主页
  3. simviso 精品付费翻译 MIT 6.824 课程
  4. Paper - Raft extended version

II. My Code

  1. source code 的 Gitee 地址
  2. Lab4A: Sharded Master 的 Gitee 地址

课程官网提供的 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,,将其添加至 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

你可能感兴趣的:(服务器,网络,分布式,golang)