Back

mit-6.824 lab4: ShardKV

实现一个基于Raft的分布式KV存储服务,其数据在多个副本组上分片存储

lab课程网址 https://pdos.csail.mit.edu/6.824/labs/lab-shard.html

本次lab是最难的一次lab,很多地方需要我们自由发挥,不像lab2那样可以参考论文。

Lab2和Lab3构成基础分布式数据库的框架,实现了多节点间的数据一致性,支持crud,数据同步和快照保存。然而,由于所有的请求都需要由 leader 来处理,当数据增长到一定程度时,若仍然使用单一集群服务所有数据,leader面对的压力会非常大,请求响应时间也会延长,磁盘空间也会不足。在这种模式下,增加机器并不会带来性能的提升,反而存在浪费。一个非常直接的解决方法,就是将数据按照某种方式分开存储到不同的集群上,将不同的请求引流到不同的集群,降低单一集群的压力,提供更为高效、更为健壮的服务。

Lab4就是要实现数据的划分,将不同的数据划分到不同的集群上,保证相应数据请求引流到对应的集群。这里,将互不相交并且组成完整数据的每一个数据子集称为 Shard。在同一阶段中,Shard 与集群的对应关系称为 Config,随着时间的推移,增加或减少机器、某个 Shard 中的数据请求过热,Shard 需要在不同集群之中进行迁移。如何在 Config更新、 Shard 迁移的同时仍能正确对外提供强一致性的服务,是lab4主要挑战。

一个集群只有Leader才能服务,系统的性能与集群的数量成正比。lab3是一个集群,lab4要实现的是多个集群之间的配合。

我画了一个 ShardKV 最终的结构图

图片 1
图片 1

ShardCtrler

Client 在向 Server 发送RPC之前,需要先知道目标 key 所在的 Shard 位于哪一个 Group,以及如何和这个 Group 中的leader通信。这就需要有一个地方保存 shard -> gid 和 gid -> server 信息,这就是lab4A中需要实现的 ShardCtrler,它使用 Config 结构保存这些信息。

// 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[]
}

每次 shard -> gid 的对应关系被更改时,ShardCtrler 创建一个新的 Config 保存新的对应关系。ShardCtrler 支持Join、Leave、Move、Query 4种RPC来添加新的 Group、删除 Group,在 Group 之间移动 Shard 以及查询对应 Num 的 Config,底层也使用Raft协议在多台机器上进行数据同步。因此整体实现和lab3类似。

Client

为了简化逻辑4种请求共用一个RPC,也需要加上 ClientID 和 RequestID 让 server 端能够去重。

type CommandRequest struct {
	ClientID  int
	RequestID int
	OpType
	JoinArgs
	LeaveArgs
	MoveArgs
	QueryArgs
}

type CommandResponse struct {
	Err         Err
	Config      Config
}

type JoinArgs struct {
	Servers   map[int][]string // new GID -> servers mappings
}

type LeaveArgs struct {
	GIDs      []int
}

type MoveArgs struct {
	Shard     int
	GID       int
}

type QueryArgs struct {
	Num       int // desired config number
}

Server

对于RPC的处理模型和lab3是一样的,由于Config数据较小,还不用处理快照。

Join

Join 操作向当前配置中新增一些server,这些server可能被加入现有的 Group 中,也可能是新增的 Group。

新增的 Group 还没有 Shard,需要在 Groups 中对 Shards 进行平衡并且要产生尽可能少的 Shard 迁移,平衡的方法是每次循环让拥有 Shard 最多的 Group 分一个给拥有 Shard 最少的 Group,直到它们之间的差值小等于1。

ShardCtrler 刚启动时还没有 Config 信息,第一次执行 Join 时所有的 Shard 还未被分配到具体的 Group 上,对应的 gid 是0,我称为 zombieShard。因此在处理 Join 时也要分配可能存在的 zombieShard。此外maps数据需要深拷贝。

func (sc *ShardCtrler) executeJoin(servers map[int][]string) {
	length := len(sc.configs)
	lastConfig := sc.configs[length-1]

	newGroups := deepCopy(lastConfig.Groups)
	for gid, servers := range servers {
		newGroups[gid] = servers
	}
	newConfig := Config{
		Num:    length,
		Shards: [NShards]int{},
		Groups: newGroups,
	}

	groupToShards := getGroupToShards(newGroups, lastConfig.Shards)
	zombieShards := []int{}
	for shard, gid := range lastConfig.Shards {
		if gid == 0 {
			zombieShards = append(zombieShards, shard)
		}
	}

	for _, shard := range zombieShards {
		target := getMinGroup(groupToShards)
		groupToShards[target] = append(groupToShards[target], shard)
	}

	groupToShards = balanceShardBetweenGroups(groupToShards)
	for gid, shards := range groupToShards {
		for _, shard := range shards {
			newConfig.Shards[shard] = gid
		}
	}

	sc.configs = append(sc.configs, newConfig)
}

Leave

Group 被删除后,其原先拥有的 Shard 就成了 zombieShard,应当依次分配被拥有 Shard 数量最少的 Group。

func (sc *ShardCtrler) executeLeave(GIDs []int) {
	length := len(sc.configs)
	lastConfig := sc.configs[length-1]
	newGroups := deepCopy(lastConfig.Groups)

	newConfig := Config{
		Num:    length,
		Shards: [NShards]int{},
		Groups: newGroups,
	}

	groupToShards := getGroupToShards(newGroups, lastConfig.Shards)
	zombieShards := []int{}
	for _, gid := range GIDs {
		delete(newConfig.Groups, gid)
		if shards, ok := groupToShards[gid]; ok {
			zombieShards = append(zombieShards, shards...)
			delete(groupToShards, gid)
		}
	}

	for _, shard := range zombieShards {
		target := getMinGroup(groupToShards)
		groupToShards[target] = append(groupToShards[target], shard)
	}

	for gid, shards := range groupToShards {
		for _, shard := range shards {
			newConfig.Shards[shard] = gid
		}
	}

	sc.configs = append(sc.configs, newConfig)
}

Move

将指定的 Shard 交由新的 Group 负责,只需要改动 Shards 数组。

func (sc *ShardCtrler) executeMove(shard int, gid int) {
	length := len(sc.configs)
	lastConfig := sc.configs[length-1]
	newGroups := deepCopy(lastConfig.Groups)

	newConfig := Config{
		Num:    length,
		Shards: lastConfig.Shards,
		Groups: newGroups,
	}

	newConfig.Shards[shard] = gid
	sc.configs = append(sc.configs, newConfig)
}

Query

Query 查询指定版本的 Config。

func (sc *ShardCtrler) executeQuery(num int) Config {
	length := len(sc.configs)
	config := Config{}
	if num == -1 || num >= length {
		config = sc.configs[length-1]
	} else {
		config = sc.configs[num]
	}

	newGroups := deepCopy(config.Groups)
	newConfig := Config{
		Num:    config.Num,
		Shards: config.Shards,
		Groups: newGroups,
	}

	return newConfig
}

测试结果

Test: Basic leave/join ...
  ... Passed
Test: Historical queries ...
  ... Passed
Test: Move ...
  ... Passed
Test: Concurrent leave/join ...
  ... Passed
Test: Minimal transfers after joins ...
  ... Passed
Test: Minimal transfers after leaves ...
  ... Passed
Test: Multi-group join/leave ...
  ... Passed
Test: Concurrent multi leave/join ...
  ... Passed
Test: Minimal transfers after multijoins ...
  ... Passed
Test: Minimal transfers after multileaves ...
  ... Passed
Test: Check Same config on servers ...
  ... Passed
PASS
ok  	6.824/shardctrler	5.641s

ShardKV

整体结构

ShardKV 的状态机 db 由多个 Shard 组成,每个 Shard 包含了自己的状态、kv和客户端请求去重表,这使得不同的 Shard 之间可以在独立迁移的同时不影响未受影响的 Shard 对外正常提供服务,也可以通过 Shard 的状态来进行许多判断,每个状态的含义在注释中。

type ShardKV struct {
	mu           sync.RWMutex
	me           int
	rf           *raft.Raft
	applyCh      chan raft.ApplyMsg
	make_end     func(string) *labrpc.ClientEnd
	gid          int
	ctrlers      []*labrpc.ClientEnd
	maxraftstate int // snapshot if log grows this big

	// Your definitions here.
	prevConfig       shardctrler.Config
	currConfig       shardctrler.Config
	persister        *raft.Persister
	scClerk          *shardctrler.Clerk
	waitChs      	 map[int]chan CommandResponse
	db               map[int]*Shard
	lastAppliedIndex int
}

type ShardStatus int

const (
	// The group serves and owns the shard.
	Serving ShardStatus = iota
	// The group serves the shard, but does not own the shard yet.
	Pulling
	// The group does not serve and own the partition.
	Invalid
	// The group owns but does not serve the shard.
	Erasing
	// The group own the shard and serve it, but it's waiting for ex-owner to delete it
	Waiting
)

type Shard struct {
	Status       ShardStatus
	KV           map[string]string
	LastSessions map[int]*Session
}

leader需要执行多个定时任务,需要在后台启动协程来循环判断状态、执行任务、睡眠。我抽象出了一个 daemon 函数来完成这些。

func StartServer(servers []*labrpc.ClientEnd, me int, persister *raft.Persister, maxraftstate int, gid int, ctrlers []*labrpc.ClientEnd, make_end func(string) *labrpc.ClientEnd) *ShardKV {
	···
	// Your initialization code here.

	// Use something like this to talk to the shardctrler:
	// kv.mck = shardctrler.MakeClerk(kv.ctrlers)
	kv.applyCh = make(chan raft.ApplyMsg)
	kv.rf = raft.Make(servers, me, persister, kv.applyCh)
	kv.scClerk = shardctrler.MakeClerk(kv.ctrlers)
	kv.mu = sync.RWMutex{}
	kv.waitChs = make(map[int]chan CommandResponse)
	kv.db = make(map[int]*Shard)
	for i := 0; i < shardctrler.NShards; i++ {
		kv.db[i] = &Shard{
			Status:       Invalid,
			KV:           make(map[string]string),
			LastSessions: make(map[int]*Session),
		}
	}

	kv.lastAppliedIndex = -1
	kv.prevConfig = shardctrler.Config{}
	kv.currConfig = shardctrler.Config{}
	kv.applySnapshot(persister.ReadSnapshot())

	go kv.applier()
	go kv.daemon(kv.fetchConfig)
	go kv.daemon(kv.pullData)
	go kv.daemon(kv.eraseData)
	go kv.daemon(kv.proposeEmpty)
	return kv
}

func (kv *ShardKV) daemon(action func()) {
	for !kv.killed() {
		if _, isLeader := kv.rf.GetState(); isLeader {
			action()
		}

		time.Sleep(50 * time.Millisecond)
	}
}

applier 的结构和lab3类似,日志被 commit 之后根据 CommandType 的不同执行不同的applyxxx,其中 EraseDataClientRequest 需要返回 response。

type CommandType int

const (
	ClientRequest CommandType = iota
	ConfChange
	InsertData
	EraseData
	StopWaiting
	Empty
)

type RaftLogCommand struct {
	CommandType
	Data interface{}
}

func newRaftLogCommand(commandType CommandType, data interface{}) RaftLogCommand {
	return RaftLogCommand{
		CommandType: commandType,
		Data: data,
	}
}

func (kv *ShardKV) applier() {
	for !kv.killed() {
		select {
		case applyMsg := <-kv.applyCh:
			if applyMsg.CommandValid {
				command := applyMsg.Command.(RaftLogCommand)
				kv.mu.Lock()

				if applyMsg.CommandIndex <= kv.lastAppliedIndex {
					DPrintf("[Group %d][Server %d] discard out-of-date apply Msg [index %d]", kv.gid, kv.me, applyMsg.CommandIndex)
					kv.mu.Unlock()
					continue
				}

				kv.lastAppliedIndex = applyMsg.CommandIndex
				response := &CommandResponse{}
				switch command.CommandType {
				case Empty:
					DPrintf("[Group %d][Server %d] get empty in apply Msg [index %d]", kv.gid, kv.me, applyMsg.CommandIndex)
				case ConfChange:
					lastestConf := command.Data.(shardctrler.Config)
					kv.applyConfChange(lastestConf, applyMsg.CommandIndex)

				case InsertData:
					resp := command.Data.(PullDataResponse)
					kv.applyInsertData(resp, applyMsg.CommandIndex)

				case StopWaiting:
					req := command.Data.(EraseDataRequest)
					kv.applyStopWaiting(req, applyMsg.CommandIndex)

				case EraseData:
					req := command.Data.(EraseDataRequest)
					response = kv.applyEraseData(req, applyMsg.CommandIndex)
					if currentTerm, isLeader := kv.rf.GetState(); currentTerm == applyMsg.CommandTerm && isLeader {
						ch := kv.getWaitCh(applyMsg.CommandIndex)
						ch <- *response
					}

				case ClientRequest:
					request := command.Data.(CommandRequest)
					response = kv.applyClientRequest(&request, applyMsg.CommandIndex)
					if currentTerm, isLeader := kv.rf.GetState(); currentTerm == applyMsg.CommandTerm && isLeader {
						ch := kv.getWaitCh(applyMsg.CommandIndex)
						ch <- *response
					}
				}

				if kv.needToSnapshot(applyMsg.RaftStateSize) {
					DPrintf("[Group %d][Server %d] take a snapshot till [index %d]", kv.gid, kv.me, applyMsg.CommandIndex)
					kv.takeSnapshot(applyMsg.CommandIndex)
				}

				kv.mu.Unlock()
			} else {
				kv.mu.Lock()
				DPrintf("[Group %d][Server %d] received a snapshot from raft layer [index %d]", kv.gid, kv.me, applyMsg.SnapshotIndex)
				if kv.rf.CondInstallSnapshot(applyMsg.SnapshotTerm, applyMsg.SnapshotIndex, applyMsg.Snapshot) {
					kv.applySnapshot(applyMsg.Snapshot)
					kv.lastAppliedIndex = applyMsg.SnapshotIndex
				}

				kv.mu.Unlock()
			}
		}
	}
}

客户端请求

这里和lab3基本一样,不同的是 handle RPC 以及日志apply时都需要额外判断在当前版本的 Config 下本 Group 是否负责该 key 所属的 Shard。

func (kv *ShardKV) isShardMatch(shardId int) bool {
	return kv.currConfig.Shards[shardId] == kv.gid && (kv.db[shardId].Status == Serving || kv.db[shardId].Status == Waiting)
}

配置更新

每个 Group 中的 leader 需要在后台启动一个协程向 ShardCtrler 定时使用 Query 拉取最新的 Config,一旦拉取到就需要提交一条 raft 日志,以在每台机器上更新配置。

此外,每次只能拉取高一个版本的配置,而且为了防止集群的分片状态被覆盖,从而使得某些任务永远不会被执行,只有在每一 Shard 的状态都为 ServingInvalid 时才能拉取、更新配置。

func (kv *ShardKV) fetchConfig() {
	canFetchConf := true
	kv.mu.RLock()
	currConfNum := kv.currConfig.Num
	for shardId, shard := range kv.db {
		if shard.Status != Serving && shard.Status != Invalid {
			canFetchConf = false
			break
		}
	}

	kv.mu.RUnlock()
	if canFetchConf {
		latestConfig := kv.scClerk.Query(currConfNum + 1)
		if latestConfig.Num == currConfNum+1 {
			kv.rf.Start(newRaftLogCommand(ConfChange, latestConfig))
		}
	}
}

在每台机器上,新配置对应的 raft 日志被 commit 之后,都需要更新本地的 prevConfigcurrConfig,以及更新 db 中对应的 Shard 状态,以便让数据拉取、数据清理协程能检测到去进行数据迁移。

在新版本的 Config 中新增的 Shard 状态改为 Pulling,等待拉取数据协程去其他 Group 上拉数据。失去的 Shard 状态改为 Erasing,等待其他 Group 来拉取数据。若当前 Config 的版本为1,则代表集群刚初始化,不需要去其他 Group 拉取数据,只需更改对应的 Shard 状态为 Serving。

数据拉取

新的 Config 在 applier 协程中被应用并不表示所属分片可以立刻对外提供服务,还需要等待在上一个版本的 Config 中不属于自身的 Shard 从它之前所属的 Group 中迁移到本 Group。

这里显然不能在配置更新时同步阻塞的去拉取 Shard,这会阻塞 applier 协程,严重影响对外服务的可用性。那么是否可以异步的去拉取数据并提交日志?其实不行,leader 可能会在 apply 新配置之后到新数据被异步拉取到并提交日志之前宕机,而 follower 虽然会 apply 配置但是不会去拉数据,这样这些数据将永远无法被更新。

因此,我们不能在 apply 配置的时候启动异步任务,而是应该只更新 shard 的状态,由单独的后台协程去检测每个 Shard 的状态,从而判断是否需要并执行分片迁移,分片清理等任务。为了让单独的协程能知道该向哪个 Group 去拉取数据或让它去删除数据,ShardKV 需要维护 currConfig 和 prevConfig,这样其他协程能够通过它们来得知所有 Shard 的 ex-owner。

需要定义新的RPC来完成数据拉取。

type PullDataRequest struct {
	ConfNum  int
	ShardIds []int
}

type PullDataResponse struct {
	Err     Err
	ConfNum int
	Shards  map[int]*Shard
}

并行向状态为 Pulling 的不同 Shard 的 ex-owner 发送RPC来拉取数据,使用 waitGroup 来保证尝试拉取了一遍当前版本的配置所需要的所有 Shard 之后才能进行下一轮循环。

func (kv *ShardKV) pullData() {
	kv.mu.RLock()
	groupToShards := kv.getGroupToShards(Pulling)
	currConfNum := kv.currConfig.Num
	wg := sync.WaitGroup{}
	for gid, shards := range groupToShards {
		wg.Add(1)
		servers := kv.prevConfig.Groups[gid]
		go func(servers []string, shards []int, confNum int) {
			defer wg.Done()
			for _, server := range servers {
				shardOwner := kv.make_end(server)
				args := PullDataRequest{
					ConfNum:  confNum,
					ShardIds: shards,
				}

				reply := PullDataResponse{}
				if shardOwner.Call("ShardKV.PullData", &args, &reply) && reply.Err == OK {
					kv.rf.Start(newRaftLogCommand(InsertData, resp))
					break
				}
			}
		}(servers, shards, currConfNum)
	}

	kv.mu.RUnlock()
	wg.Wait()
}

数据的被拉取方在处理 RPC 时,只有在 PullDataRequest 中的配置版本与自身的配置版本相同时,才回应其需要的 Shard 信息。需要注意正确的对所有 Shard 深拷贝。

func (kv *ShardKV) PullData(args *PullDataRequest, reply *PullDataResponse) {
	defer DPrintf("[Group %d][Server %d] reply %s for PULL DATA request %s", kv.gid, kv.me, reply, args)
	DPrintf("[Group %d][Server %d] received a PULL DATA request %s", kv.gid, kv.me, args)
	if _, isLeader := kv.rf.GetState(); !isLeader {
		reply.Err = ErrWrongLeader
		return
	}

	kv.mu.RLock()
	defer kv.mu.RUnlock()

	if kv.currConfig.Num < args.ConfNum {
		reply.Err = ErrNotReady
		return
	}

	if kv.currConfig.Num > args.ConfNum {
		panic("duplicated pull data request")
	}

	replyShards := make(map[int]*Shard)

	for _, shardId := range args.ShardIds {
		shard := kv.db[shardId]
		replyShards[shardId] = deepCopyShard(shard)
	}

	reply.ConfNum = kv.currConfig.Num
	reply.Shards = replyShards
	reply.Err = OK
}

在 applyInsertData 时,为了保证集群数据变更的幂等性,要保证 Config 的版本与当前版本相同时以及其 Shard 的本地状态为 Pulling 时才能更新 Shard 的状态。将其状态改为 Waiting 让数据清理协程去检测。

数据清理

current owner

在完成数据拉取之后,需要清理掉每个新拉到的 Shard 对应的 ex-owner 机器上的旧数据。后台协程检查所有状态为 Waiting 的 Shard,并行向它们的 ex-owners 分别发送 RPC,告知它们:我已拉取到我要的数据,现在你可以把它们(对应的 Shard 状态为 Erasing)删了。这里 waitGroup 的用法同上。

RPC返回且得知 ex-owners 上的数据清理已经完成后需要提交一条 StopWaiting 类型的 raft 日志,将这个信息同步到 Group 内所有机器上。

type EraseDataRequest struct {
	ConfNum  int
	ShardIDs []int
}

type EraseDataResponse struct {
	Err Err
}

func (kv *ShardKV) eraseData() {
	kv.mu.RLock()
	groupToShards := kv.getGroupToShards(Waiting)
	currConfNum := kv.currConfig.Num
	wg := sync.WaitGroup{}
	for gid, shards := range groupToShards {
		wg.Add(1)
		servers := kv.prevConfig.Groups[gid]
		go func(servers []string, shards []int, confNum int) {
			defer wg.Done()
			for _, server := range servers {
				shardOwner := kv.make_end(server)
				args := EraseDataRequest{
					ConfNum:  confNum,
					ShardIDs: shards,
				}

				reply := EraseDataResponse{}
				if shardOwner.Call("ShardKV.EraseData", &args, &reply) && reply.Err == OK {
					kv.rf.Start(newRaftLogCommand(StopWaiting, req))
					break
				}
			}
		}(servers, shards, currConfNum)
	}

	kv.mu.RUnlock()
	wg.Wait()
}

StopWaiting 日志以及 Shard 的 Waiting 状态存在的用途是标记我是否已经成功在 ex-owner 上删除过期的 Shard。applyStopWaiting 时,在 Config 版本相同时将对应的状态为 Waiting 的 Shard 更新状态为 Serving

ex-owner

ex-owner 在 handle EraseData 的RPC时,需要返回数据清理是否完成,这里的处理类似处理客户端请求,不需要进行去重。

func (kv *ShardKV) EraseData(req *EraseDataRequest, resp *EraseDataResponse) {
	defer DPrintf("[Group %d][Server %d] resp %s for ERASE DATA request %s", kv.gid, kv.me, resp, req)
	DPrintf("[Group %d][Server %d] received a ERASE DATA request %s", kv.gid, kv.me, req)
	index, _, isLeader := kv.rf.Start(newRaftLogCommand(EraseData, *req))
	if !isLeader {
		resp.Err = ErrWrongLeader
		return
	}

	kv.mu.Lock()
	ch := kv.getWaitCh(index)
	kv.mu.Unlock()

	select {
	case response := <-ch:
		resp.Err = response.Err

	case <-time.NewTimer(500 * time.Millisecond).C:
		resp.Err = ErrTimeout
	}

	go func() {
		kv.mu.Lock()
		kv.removeWaitCh(index)
		kv.mu.Unlock()
	}()
}

apply 时,在版本号相同的情况下将对应的状态为 Erasing 的 Shard 更新为 Invalid,表明对应 Shard 已经成功被清除,清空 kv 和客户端请求去重表。不要忘了返回OK。

提交空日志

在某个涉及重启的测试中,有时候会出现集群对外出现活锁,无法再服务请求直到超时。我重新打了很多日志,发现这时各个 Group 间的 Config 版本不一致,且版本较低的 Group 的一些 Shard 状态不为 ServingInvalid,这卡着配置更新协程无法拉取最新的 Config。按理说 Config 的版本只能以1为公差递增,其余的 Group 版本高说明也经历过较低的这个版本,应该有向这个 Group 发送过拉取数据和清理数据的RPC来更新 Shard 状态,那么为什么状态并没有被更新呢?

仔细读了很久日志,我发现版本较低的 Group 在推进 Config 到这个版本之后已经正确处理过拉取数据或是清理数据的RPC也更新了 Shard 状态,但在重启后这最后处理的关键RPC对应的日志并没有重新被commit。原来,此时 leader 的 currentTerm 高于这个RPC对应的日志的 term,且这个时间节点客户端碰巧没有向该 Group 组执行读写请求,导致 leader 无法拥有当前任期的 term 的日志,无法将状态机更新到最新。

lab4的最后一部分是我在写完 TinyKV 之后做的,我想到 TinyKV (其实 etcd 也是这么做的)中要求的 leader 在当选时要先提交一条空日志,这样可以保证集群的可用性,于是我也移植了这个特性到 6.824 中。

想起了以前几个月前看过的 谭新宇 的文章,我知道了不能把这个特性加到 raft 层。于是我也让 leader 在 kv 层周期性的去检测下层是否包含当前 term 的日志,如果没有便 append 一条空日志,这样即可保证新选出的 leader 状态机能够迅速达到最新。

func (kv *ShardKV) proposeEmpty() {
	if !kv.rf.HasLogAtCurrentTerm() {
		kv.rf.Start(newRaftLogCommand(Empty, nil))
	}
}

测试结果

Test: static shards ...
  ... Passed
Test: join then leave ...
  ... Passed
Test: snapshots, join, and leave ...
  ... Passed
Test: servers miss configuration changes...
  ... Passed
Test: concurrent puts and configuration changes...
  ... Passed
Test: more concurrent puts and configuration changes...
  ... Passed
Test: concurrent configuration change and restart...
  ... Passed
Test: unreliable 1...
  ... Passed
Test: unreliable 2...
  ... Passed
Test: unreliable 3...
  ... Passed
Test: shard deletion (challenge 1) ...
  ... Passed
Test: unaffected shard access (challenge 2) ...
  ... Passed
Test: partial migration shard access (challenge 2) ...
  ... Passed
PASS
ok  	6.824/shardkv	108.040s
Built with Hugo
Theme Stack designed by Jimmy