In this lab you’ll build a key/value storage system that “shards,” or partitions, the keys over a set of replica groups. A shard is a subset of the key/value pairs; for example, all the keys starting with “a” might be one shard, all the keys starting with “b” another, etc. The reason for sharding is performance. Each replica group handles puts and gets for just a few of the shards, and the groups operate in parallel; thus total system throughput (puts and gets per unit time) increases in proportion to the number of groups.
Your sharded key/value store will have two main components. First, a set of replica groups. Each replica group is responsible for a subset of the shards. A replica consists of a handful of servers that use Raft to replicate the group’s shards. The second component is the “shard controller”. The shard controller decides which replica group should serve each shard; this information is called the configuration. The configuration changes over time. Clients consult the shard controller in order to find the replica group for a key, and replica groups consult the controller in order to find out what shards to serve. There is a single shard controller for the whole system, implemented as a fault-tolerant service using Raft.
A sharded storage system must be able to shift shards among replica groups. One reason is that some groups may become more loaded than others, so that shards need to be moved to balance the load. Another reason is that replica groups may join and leave the system: new replica groups may be added to increase capacity, or existing replica groups may be taken offline for repair or retirement.
lab4A
任务
Your task is to implement the interface specified above in
client.go
andserver.go
in theshardctrler/
directory. Your shardctrler must be fault-tolerant, using your Raft library from Lab 2/3. You have completed this task when you pass all the tests inshardctrler/
.
要求实现一个具有容错能力的shardctrler
,实际上就是lab3的一个翻版,同样是client与server的一对多结构,每一个server对应一个raft节点,所有的server与client共同组成了一个shardctrler
。
shardctrler
的功能是保存并修改分片存储系统的config
,也就是保存了分片的存放位置(存放到哪个replica group上),并且提供接口,供管理员(测试程序)进行配置信息的修改,比如添加replica groups(join
),删除replica groups(Leave
),移动某个分片到某个replica group(Move
),查询当前的配置信息(Query
)
分析
在做完了前面的lab后,理解这个lab还是相对简单的,尽管实验手册不再那么详细。
shardctrler
仅仅提供了对config
的保存和修改操作,并不涉及分片内容的存储,所以类似lab3b,只需要将所有的操作:join
、Leave
、Move
、Query
都写入raft的log中,然后从applych
去读取这些命令并执行。不同于lab3的地方有两处,一是不需要拍快照的操作,所以省去了不少代码,且难度相对来说变小了不少;二是join
与Leave
操作需要进行负载均衡,这里负载均衡要求移动的分片数最少,因此需要考虑如何实现。
实现
其他部分基本照搬lab3的代码,这里仅仅说一下负载均衡的代码部分。
前提是,分片的数目是确定的,为NShards
,而replica groups(后面简称为组)的数目是不确定的,从0到NShards
都有可能,那么一个组对应一至多个分片。负载均衡就是要将分片均匀分到每个组。比如现有三个组,如果分片数目是3, 1, 6
,那么第三组的负载显然会比第二组大很多,理想的情况应该是3, 3, 4
的排列。
条件是,join
操作是添加组,可以一次添加多个组,但组的总数不超过NShards
。Leave
操作是删除组,可以一次删除多个组,甚至删除全部的组。
要求是,处理后的负载应该是均衡的,处理过程对应分片的移动应该是最小的。
负载均衡的实现经过了多次修改,直接说一下最终的实现,代码部分大致如下:
/**
* keys:修改后所有group的gid,未排序
也就是包含了join操作添加的group的gid,或者不包含leave操作删除的group的gid
oldShards: 修改前分片的分片情况
返回值:处理后的分片分配情况
*/
func banlance(keys []int, oldShards [NShards]int) [NShards]int {
// 如果没有group可供分配,那么将所有分片分配给zero group(未分配)
if len(keys) == 0 {
return [NShards]int{}
}
newShards := oldShards
// 收集未分配的分片
unsuredShards := make([]int, 0)
for index, gid := range newShards {
if gid == 0 {
unsuredShards = append(unsuredShards, index)
}
}
// 首先对未分配的分片进行分配,未分配的分片对应的gid是0
// 每次将一个分片分给最小负载的group
// 如果两者负载相同,优先考虑gid较小的group
for index := 0; index < len(unsuredShards); index++ {
minLoad := NShards
minLoadGid := -1
shardCount := make(map[int]int)
for _, gid := range newShards {
shardCount[gid] = shardCount[gid] + 1
}
for _, gid := range keys {
currentLoad := shardCount[gid]
if currentLoad < minLoad || (currentLoad == minLoad && gid < minLoadGid) {
minLoad = currentLoad
minLoadGid = gid
}
}
newShards[unsuredShards[index]] = minLoadGid
}
// 每次取得负载最大和负载最小的group编号,从负载最大的移动一个到负载最小的
// 直到两者差值小于等于1
// 如果两个group的负载相同,优先考虑gid小的
for {
maxLoad := -1
maxLoadGid := -1
minLoad := NShards
minLoadGid := -1
shardCount := map[int]int{}
for _, gid := range newShards {
shardCount[gid] = shardCount[gid] + 1
}
for _, gid := range keys {
currentLoad := shardCount[gid]
if currentLoad > maxLoad || (currentLoad == maxLoad && gid < maxLoadGid) {
maxLoad = currentLoad
maxLoadGid = gid
}
if currentLoad < minLoad || (currentLoad == minLoad && gid < minLoadGid) {
minLoad = currentLoad
minLoadGid = gid
}
}
if maxLoad == minLoad || maxLoad == minLoad+1 {
break
}
// 将最大负载一个shard移动到最小负载上
// 从头开始遍历newShards
for index, gid := range newShards {
if gid == maxLoadGid {
newShards[index] = minLoadGid
break
}
}
}
return newShards
}
时间复杂度自然是不低的,但整体上思路是很清晰的,使用了贪心算法的思想。
实验手册中要求我们保证代码的确定性,但map的遍历顺序不是固定的,因此使用了gid作为负载相同时的比较依据。
首先是对所有未分配的分片进行分配,按照贪心思想,每次都分给当前负载最小的组。分配后,所有分组之前的差距一定是最小的,这里的移动次数一定是固定的(等于未分配分片的数目)
假如有两个组A与B,A比B多分配N个分片,空闲的分片数目为M,那么按照上面这种思想,他们之前最后的分片差值结果如下:
- 当N>=M时,等于N-M。所有的分片都会分配到B上
- 当N<M时,为0或1。先有N个分片分配给B,然后剩余分片依次分配给A与B
然后是对处理后的分片进行均衡,也使用贪心的思想,每次从负载最大的分片上移动一个分片到负载最小的分片上,一旦最大负载与最小负载差值小于1,就停止处理。
这样做,首先保证了最终的负载一定是均衡的。也保证了移动次数最少。
考虑是否存在如下情况,一个空闲分片A先是分配给了S1,然后在均衡的步骤中某一步S1的负载最大,S1原有的分片B又分配给了S2。
这种情况下,原本只需一步的操作(将A分配给S2,B不动)变成了两步,增加了移动次数。
答案是不可能。
先举个例子感性分析一下,假设某一步中有如下情况:
S1:A B C
S2:D
S3:E F
其中A是分配给S1的空闲分片,B是S1原有的分片,此时要将A移动到S2,但这种情况存在嘛?
总共有6个分片,B一定是S1原有的分片,也就是最多5个分片是空闲分片分配的,可以依次枚举一下可能的场景,最终可以发现不存在任何一种情况满足要求。
理性的证明一下,我们的分配方法一定保证如下两点:
- 在分配操作中,得到分片的组永远是负载最少的,如果一个负载为N的组分配到了一个分片,那么整个系统中,不存在负载小于N-1的组
- 在均衡操作中,移动分片后,减小负载的节点的负载一定大于等增加负载的节点负载,也就是说,负载小的节点一定不会通过均衡操作变成负载最大的节点(特殊情况除外,比如两个组负载都是5,都是负载最大,但也都是负载最小)
那么显然,上述情况一定不存在。如果S1中存在分配的分片,那么其负载最大时,与负载最小节点的负载差值一定不超过1;如果在均衡阶段S1负载是最大的,那么其一定不会在分配阶段得到分片
问题记录
1、第一个问题是test_test.go:31: shard 0 -> invalid group 0
,经过排查后,发现是go语言中对数组进行遍历时,如果采用for data := range arr
的方式,那么data是下标,只有for _,data := range arr
时,data才是数组中的数据,所以负载均衡相关的算法需要修改。
2、上个问题解决后,程序出现了卡死,通过日志排查发现,目前的负载均衡算法还不完善,当只剩最后一个group,并删除这个group时,出现了卡死,然后查看相关代码,发现负载均衡的算法在传入的group为空时,程序会出现死循环。
3、修改完后,通过所有测试。
lab4B
任务
Your first task is to pass the very first shardkv test. In this test, there is only a single assignment of shards, so your code should be very similar to that of your Lab 3 server. The biggest modification will be to have your server detect when a configuration happens and start accepting requests whose keys match shards that it now owns.
Implement shard migration during configuration changes. Make sure that all servers in a replica group do the migration at the same point in the sequence of operations they execute, so that they all either accept or reject concurrent client requests. You should focus on passing the second test (“join then leave”) before working on the later tests. You are done with this task when you pass all tests up to, but not including,
TestDelete
.
- challenge1:
Cause each replica group to keep old shards no longer than absolutely necessary. Your solution must work even if all the servers in a replica group like G1 above crash and are then brought back up. You have completed this challenge if you pass
TestChallenge1Delete
.
- challenge2:
Modify your solution so that client operations for keys in unaffected shards continue to execute during a configuration change. You have completed this challenge when you pass
TestChallenge2Unaffected
.
- challenge3:
Modify your solution so that replica groups start serving shards the moment they are able to, even if a configuration is still ongoing. You have completed this challenge when you pass
TestChallenge2Partial
.
任务是实现分片存储功能,每个shardkv
对应一个group,存储了某些分片,同样由client
和server
构成。区别于lab3的地方有两点。
一是每个shardkv
需要判断当前查询的分片是否存储在组内。二是配置更改时,需要实现分片在groups间的迁移。
当然,上述所有的过程都需要保证分布式一致性,通过raft算法提供容错能力。
还有三个可选的challenge,将在后面讨论。
分析
代码总体可先直接借用lab3的,然后添加与分片相关的代码。最主要就是要理解这里的分片设计,每个分片实际就是一个lab3中的kvserver
,分片的存储就是和lab3一摸一样的设计,难点在于如何检测到配置更改,以及分片如何迁移。
lab4难在没有详细的实验文档,感觉每次都是写了一大堆代码然后胆战心惊的跑测试。但说起难度,个人认为实际和lab3差不多,主要还是得明白自己要做什么,以及要适应分布式的情景。
lab4中存在两类raft集群,一是存储配置信息的一个raft集群,我称为RC集群,也即是lab4a中实现的集群,之所以用多个raft节点存储配置信息,主要目的还是提高容错;二是存储数据的多个raft集群,我称为RS集群,做完lab4a后,我们也很容易理解, 这里的一个RS集群就是一个组,可以存储多个分片,也就是lab4b中需要我们实现的集群。
很关键的一点就是配置和存储是分开的,因此系统表现出的状态是这样的:管理员修改了配置,很快就收到了配置修改成功的信息,因为只要在RC集群上达成共识就行,但这个新配置什么时候会生效呢?不知道,但一定会生效。
除开从lab3中复制代码,我们要做的就是两件事,一是检测配置更改,二是进行分片迁移。
实验最后的三个challenge建议一开始就考虑到,并体现在自己的设计中。不要把它们当作附加题,而是作为必做题。
那首先看看challenge要我们做什么:
- server对不再使用的分片进行清理
- 在配置更改时,没有进行分片变化的分片数据可以正常读写
- 在配置更改时,尽管其他需要的分片数据还没到达,一旦某一分片的数据到达,该分片就可进行正常读写
不知道你们看了这几个challenge有什么感想,我个人认为它们都强烈的暗示了一个信息:分片是独立的。我后面的设计将遵循 分片是独立的 这个原则。
另一个原则是针对分布式场景的,那就是所有操作都要通过raft集群达成共识,这一点在lab3的实现过我们已经深深体会过了,就是用raft算法保证操作的线性性和一致性。
实现
需要完成的代码在目录src/shardkv
下,client.go
的代码基本都已给出,但还是需要我们添加duplicate detection的字段,也就是clientId和commandId。
最主要的还是server.go
的代码,首先是将lab3的代码给完全复制过来。
第一步,在ShardKV
中体现分片设计,牢记分片是独立的,分片首先是一个key-value数据库,其次还包含duplicate detection的字段,也就是存储每个client最新一条执行命令的id。代码大概是这个样子
const NShards = 10
type ShardState string
const (
READY = "Ready"
UNUSED = "Unused"
WAITFORDATA = "WaitForData"
SENDINGDATA = "SendingData"
)
type Shard struct {
KVDataBase map[string]string
State ShardState
LastCommitOpIndex map[int64]int // 最后一个提交op的commandIndex
}
type ShardKV struct {
//....
// Your definitions here.
// 分片数据库
ShardsKVDB [NShards]*Shard
//...
}
这是我最终的实现版本,中间还有些曲折,也修改了几个版本,这里举例子说明为什么要把
LastCommitOpIndex
存到每个分片上而不是ShardKV
中,也是我遇到过的报错(一路修bug过来的)
假设分片A现在在G1上,新配置中分片A应该存储到G2中。现在A还是在G1,客户端执行一条命令APPEND a 1
,检测到a对应的分片A在G1,于是向G1发起了APPEND a 1
调用,所有的流程都正常执行,只在最后一步从server返回RPC给client时,RPC丢失了。然后G1检测到了新配置,并开始传送A到G2。
客户端这边发现RPC调用超时,然后不断开始重试,直到其发现新配置中分片A在G2上,向G2发起调用,并且G2已经收到了分片A,这条命令才会被正常执行。
开始讨论,如果LastCommitOpIndex
只是存到了ShardKV
中,那么G2收到A时,是没有任何LastCommitOpIndex
数据的,G2收到了客户端的APPEND a 1
命令就会执行,那么恭喜你,喜提一个报错, expect a: xxxxx1 receive a:xxxxx11
。因此,每个分片还得配一个LastCommitOpIndex
字段。这也是我说的,每个分片实际就是一个lab3中的kvserver
。
第二步,实现配置变更的检测,老老实实按照实验手册中说的,定期轮询RC集群,询问是否发生了配置更改。但这里有几个问题:
同样是踩过的坑
- 组内所有server都需要轮询吗?并不是,就算你所有节点都在查,最终也只有leader节点才能写入raft中,所以其他server不用查,但这个循环不能结束,每次循环先判断是不是leader,是的话才进行下一步操作,不是的话,睡一会,再开始下一次循环。
- leader什么时候该轮询?轮询的是最新的配置(Query参数为-1)还是下一版本的配置(相对于当前组的配置更新一个版本的配置)?我将两个问题放在一起讨论,首先轮询的一定是下一版本的配置,配置变更是连续的,这一点必须保证。否则不同RS集群间配置都不同步了,直接乱了套。但全局上看,不同RS集群间配置的版本并不是时时刻刻一致的,它们之间只会相差一个版本(这里是整体上都处于两个连续的版本间,任一RS集群要么处于n,要么处于n+1)。这也能保证配置变更时需要的分片迁移的数据一定能找到。那该什么时候轮询也是明确的,就是当前版本的配置变更已完成(该收的分片收到了,该发送的分片也送到了)。
- leader查到了配置的变更该做什么?对分片啥也不做,但要往raft写一条op,表示发现了配置的变更,并将这个新配置也写入其中。这里体现了所有操作都要通过raft集群达成共识这一原则。做完lab3后,实际上应该对这点应该深有体会。所有的命令收到的时刻不等于完成的时刻,完成的时刻是从applych收到这条命令的时刻。
第三步,正确处理分片迁移。我将从applych收到配置更改命令开始,说明我设计的流程。现在有两个组G1与G2,G1上存储了0,1号分片,G2上存储了2,3号分片。新配置中,G1拥有0,3号分片,G2拥有1,2号分片,也就是G1需要将1号分片发送给G2,G2需要将3号分片送给G1。
G1的循环读取applych函数读取到了配置更改命令,并且知道了新配置中它不再拥有1号分片,将拥有3号分片,那么他会将1号分片标记为SENDINGDATA
,将3号分片标记为WAITFORDATA
。(分片状态只有为READY
才可用),G2同理,然后这一处理结束。
这里有个特殊情况,就是从0开始的配置,一开始所有集群不拥有任何分片,也没有任何数据,当集群知道自己将拥有某分片时,直接将其状态修改为
READY
就行。
分片迁移一定是需要不断循环发送分片数据直到成功的。因此我设计了第三个后台循环函数,该函数的功能就是遍历所有分片,将状态为SENDINGDATA
的分片发送给对应组,这里仍然是只需要leader进行处理,原因也是类似的:分片丢弃的操作需要取得raft共识。
G1中leader将1号分片送给G2,并收到G2确认收到的消息,他不会立即将1号分片的状态从SENDINGDATA
标记为UNUSED
,而同样是送一条op到raft,该op含义是1号分片已经送到,可以丢弃了。只有当G1从applych收到这条op时,才会丢弃1号分片并将其标记为UNUSED
。
上面这短短的一段话,实际包含了两个需要实现的内容,一是接收分片的RPC实现,该RPC作为接口可供其他组的server发送分片数据,在该RPC内,它首先会检查该分片是不是自己需要的(配置版本一致而且该分片处于WAITFORDATA
状态),是的话才会进行下一步处理。这里仍然是只需要leader进行处理,原因是:分片添加的操作需要取得raft共识。
G2收到G1的RPC调用,只有组内的leader才会进行下一步处理,其他server在reply中都会返回err。该leader先判断该RPC内容配置版本与当前一致,且1号分片正是自己需要的,他不会立即响应这条RPC,他也不会立即将1号分片状态变为READY
,同样是先写一个op到raft,该op包含收到的1号分片的内容。只有从applych收到这条命令是,G2内所有节点才会将op中1号分片内容复制到自己的1号分片(注意是复制,深拷贝),然后将1号分片标记为READY
,然后才响应RPC调用,返回OK
这里需要注意区分收到不同配置版本和分片状态不同的响应
- 收到版本过低的配置,响应OK,表示该配置已经采用(实际早就采用了)
- 收到当前版本的配置,且分片状态为
WAITFORDATA
,表示是合理的RPC,按流程处理- 收到当前版本的配置,但分片状态不为
WAITFORDATA
,响应OK,表示该配置已采用- 收到版本过高的配置,响应ERR,希望对方循环发送这个RPC,直到自己的配置版本跟上。
G2向G1发送3号分片的过程同理。这样整个分片迁移流程就结束了。
在过程中,我也看到网上有人采用了PULL数据的方式,也就是当server检测到分片状态为WAITFORDATA
时,会主动先其他组发起调用,这种方式就必须保存上一版本的配置了,因为需要直到上一版本中哪个分片持有当前自己所需的分片,也需要检查其他组发起的请求分片数据RPC的有效性。感觉两者并没有什么大差别,但我从头到尾就没想到过这种方法(太呆了)
问题记录
1、碰到的第一个问题是程序开始后直接卡住,经过代码检查,发现在shardctrler
的server.go
中,Raft()
方法错误的添加了一行atomic.StoreInt32(&sc.dead, 1)
,导致测试程序中调用该方法后,直接杀死了ShardCtrler
。(很傻的问题)
2、第二个问题是在join then leave
测试中出现了labgob warning: Decoding into a non-default variable/field Err may not work
,检查发现,我的移动分片的代码由于存在重复发起调用的行为,因此每次都要使用不同的reply参数。(也是一个简单的语法问题)
3、第三个问题是在concurrent puts and configuration changes
出现了读取到了错误数据和卡死的问题,经过排查发现,第一个原因是我的进行分片迁移响应的函数中,没有考虑收到过旧的分片(configNum小于当前的config.Num)和较新的分片情况,并且没有在该返回的时候及时返回。第二个原因是在配置改变时,没有按顺序改变配置,也就是我每次都会拉取最新的配置,而忽略了中间过程的配置变化,并且配置改变时,还需要保证前一次的配置改变已经完成(分片迁移全部完成),这点需要通过在发起请求前判断一下所有的分片是否都完成了迁移来保证。第三个原因是分片数据库的每一片都需要单独进行duplicate detection,而且进行分片传输时,还需要传送分片用于duplicate detection的数据,其实也可以认为每个分片就是一个单独的lab3数据库,因此我对我的代码进行了一次较大的调整。
4、修复上述问题后,偶尔会出现卡死在concurrent configuration change and restart
测试或者获取到错误数据的情况。检查后发现,分片迁移的过程除了问题,假设group 1中有分片要发送给group 2,那么group 1中server存储的对应分片状态会标记为SENDINGDATA
,group 2中server存储的对应分片状态会标记为WAITFORDATA
;每个server中都有一个RPC调用,用于接收分片;每个server中还有一个定期循环的程序,用于检测状态为SENDINGDATA
的分片,并发送给对应的组。
我的设计原本是这样的:假设group 1中leader为A,group2中leader为B,A中循环检查程序发现了SENDINGDATA
的分片要发送给group 2,那么A会轮询group 2中所有server,向其发起RPC调用传送该分片,直到某个RPC(当前情况下,应该是B)的响应返回了OK,那么其会向raft中写一个op,该op指示前面传送的分片已经送达,group 1中server从applych收到该op后,将对应分片内容删除并标记为UNUSED
。
当B收到来自A的RPC调用时,他首先检查这个分片是否是自己需要的,如果不是,那么RPC调用返回Err就行,
只有当配置编号与自身当前配置编号一致,且该分片处于
WAITFORDATA
状态时,该分片才是自己需要的
如果是,那么B会像处理来自客户端的请求一样,首先写一条op到raft,该op包含了该分片的所有数据,然后通过管道监听该op是否被apply,只有被apply时(说明此时其他raft节点收到了该op),才会返回RPC响应OK。这里同样设置一个超时时间,如果超时,直接返回RPC响应Err。
后面完全按照这个设计来,就没有出问题了。
5、第五个问题是shard deletion (challenge 1)
中出现了snapshot + persisted Raft state are too big: 129833 > 117000
,且这个错误是稳定出现的,数值只会在129000左右波动。一开始想到是不是存了一些不该存的数据,所以调整了一下ShardKV
结构,并且当分片为UNUSED
时就不初始化,结果就是数据从129833减少到了129285。因此选择查看测试程序,并对出错的地方做了如下修改(/******************/注释前为添加的代码),同时在所有key完成插入的地方也进行了打印:
total := 0
lraft := 0 /******************/
lsnap := 0 /******************/
for gi := 0; gi < cfg.ngroups; gi++ {
for i := 0; i < cfg.n; i++ {
raft := cfg.groups[gi].saved[i].RaftStateSize()
snap := len(cfg.groups[gi].saved[i].ReadSnapshot())
lraft += raft /******************/
lsnap += snap /******************/
fmt.Printf("[group:%v server:%v] raft:%v snap:%v\n",
gi, i, raft, snap) /******************/
total += raft + snap
}
}
// 27 keys should be stored once.
// 3 keys should also be stored in client dup tables.
// everything on 3 replicas.
// plus slop.
expected := 3 * (((n - 3) * 1000) + 2*3*1000 + 6000)
if total > expected {
fmt.Printf("raft:%v snap:%v\n", lraft, lsnap) /******************/
t.Fatalf("snapshot + persisted Raft state are too big: %v > %v\n", total, expected)
}
打印信息如下:
(cfg.join(0))[group:0 server:0] raft:650 snap:40933
(cfg.join(0))[group:0 server:1] raft:79 snap:40933
(cfg.join(0))[group:0 server:2] raft:588 snap:40933
(cfg.join(0))[group:1 server:0] raft:79 snap:491
(cfg.join(0))[group:1 server:1] raft:79 snap:491
(cfg.join(0))[group:1 server:2] raft:79 snap:491
(cfg.join(0))[group:2 server:0] raft:79 snap:491
(cfg.join(0))[group:2 server:1] raft:79 snap:491
(cfg.join(0))[group:2 server:2] raft:79 snap:491
(cfg.join(0))total:127536
[group:0 server:0] raft:80 snap:16735
[group:0 server:1] raft:591 snap:16735
[group:0 server:2] raft:591 snap:16735
[group:1 server:0] raft:80 snap:8707
[group:1 server:1] raft:80 snap:8707
[group:1 server:2] raft:80 snap:8707
[group:2 server:0] raft:79 snap:16734
[group:2 server:1] raft:588 snap:16734
[group:2 server:2] raft:588 snap:16734
raft:2757 snap:126528
可以看出来,我的数据的确进行了清理,但是还是snapshot存的东西太多了(难绷),然后考虑了一下当前的设计,在问题3中有提到对分片数据库的每一片都需要单独进行duplicate detection。因此我的分片设计如下:
type Shard struct {
KVDataBase map[string]string
State ShardState
LastCommitOpIndex map[int64]int // 最后一个提交op的commandIndex
LastCommitOpRes map[int64]string // 最后一个提交op的处理结果
}
但这里完全采用了之前的设计,是否可以优化呢?LastCommitOpRes
字段是用来保存上一次get命令的结果的,但是看了别人的一些设计,他们并没有使用该字段,而是很好的利用了client一次只能执行一条命令的设计,可以这么理解:
在我的设计中,一旦applych收到了get命令的op,就会保存get命令的执行结果,后面无论client怎么查都是这个结果(过程中可能有其他client进行修改操作)
而一旦丢弃LastCommitOpRes
,并不对get命令进行duplicate detection,每次client发起get调用时都会得到从applych收到get这条op时,当下的结果。
建立在client一次只能执行一条命令的设计上,这两者都会保证线性一致性。
修改后通过所有测试。