MIT 6.5840 Lab 2: Key/Value Server (2025)
前言
Lab 的页面 6.5840 Lab 2: Key/Value Server
2025 版的 Lab2 相比 2024 版做了一些修改,取消了Append操作,增加了一个version字段。每年的 Lab 都可能会做一些修改,所有的历史页面都在这里,上面的 Lab 页面是最新年份的,阅读题目时请确认是不是 2025 年的页面。
注意,题目的要求是先实现稳定连接版本的,后面再实现 RPC 数据可能丢失的,这边直接实现最终版本。
Client
Get
初始化 RPC 调用的参数和返回值,并且不断尝试调用 RPC 直到成功为止,调用成功后只需要返回 RPC 的结果即可,调用失败稍微等待一会再继续尝试。
Hint: Before the client retries, it should wait a little bit; you can use go‘s
timepackage and calltime.Sleep(100 * time.Millisecond)
func (ck *Clerk) Get(key string) (string, rpc.Tversion, rpc.Err) {
// You will have to modify this function.
args := rpc.GetArgs{Key: key}
var reply rpc.GetReply
for {
ok := ck.clnt.Call(ck.server, "KVServer.Get", &args, &reply)
if ok {
return reply.Value, reply.Version, reply.Err
}
time.Sleep(100 * time.Millisecond)
}
}
Put
Put也需要不断尝试调用 RPC 直到成功为止,但是情况比Get复杂一些,因为 RPC 调用失败,可能是 Server 没有收到 RPC 请求,也可能是 Server 收到了请求但是没有执行操作,还可能是 Server 收到请求也执行了操作但是 Client 没收到响应。对于执行了操作的情况,Get因为是幂等的所以没有问题,但是Put不一样,需要分情况讨论。RPC 调用成功的时候 Server 可能有三种返回结果:
- 返回
OK:RPC 调用可能一次成功;也可能前面失败了几次,最后成功,失败的情况 Server 都没有执行操作;还有可能是调用的版本号参数不为 0,前面 RPC 调用失败但是 Server 执行并且返回ErrNokey(key 不存在)或者ErrVersion(Server 的版本号比这边小),在 RPC 调用成功之前其他 Client 调用 RPC 增加了 Server 的版本号使得和本 Client 调用的版本号参数一致。版本号为 0 代表创建 key 成功;版本号不为 0 代表更新 key 成功 - 返回
ErrNoKey:RPC 调用可能一次成功;也可能前面失败了几次,最后成功,失败的情况 Server 是有可能执行操作的。不过这里所谓的执行操作也就是发现不存在 key 并且 Client 发过来的 version 不为 0 - 返回
ErrVersion:可能是 RPC 调用一次成功或者前面失败了几次,失败的情况 Server 都没有执行操作,最后成功,但是版本号不匹配;也可能是前面执行成功了,Server 返回了OK但是 Client 没收到响应,再进行 RPC 调用的时候版本号就对不上了;或者一开始版本号就不匹配但是 Client 没收到响应,后面成功调用的时候依然收到ErrVersion
对于 Server 返回OK和ErrNoKey的情况 Client 也都原样返回就行;对于 Server 返回ErrVersion的情况,我们只能知道如果是第一次 RPC 调用,并且调用成功返回了ErrVersion,那么一定是源自版本号不匹配,其他任何情况我们都不清楚 Server 有没有修改数据,Client 只能返回ErrMaybe。
func (ck *Clerk) Put(key, value string, version rpc.Tversion) rpc.Err {
// You will have to modify this function.
args := rpc.PutArgs{Key: key, Value: value, Version: version}
var reply rpc.PutReply
for try := 0; ; try++ {
ok := ck.clnt.Call(ck.server, "KVServer.Put", &args, &reply)
if ok {
switch reply.Err {
case rpc.OK:
return rpc.OK
case rpc.ErrNoKey:
return rpc.ErrNoKey
case rpc.ErrVersion:
if try == 0 {
return rpc.ErrVersion
} else {
return rpc.ErrMaybe
}
}
}
time.Sleep(100 * time.Millisecond)
}
}
上面 Server 返回的三种结果里面包含了各种各样的可能性,过于复杂,我们现在只考虑 Client 的Put语义,也就是说,当 Client 执行上面的Put,返回值分别意味着什么?能给我们什么样的关于 Server 的信息?
OK:创建了 key 或者更新了 key,此时 Server 的 key 的版本号增加 1ErrNoKey:key 不存在,Server 什么也不做ErrVersion:版本号不匹配,Server 什么也不做ErrMaybe:Server 可能创建了 key 或者更新了 key,也可能什么都没做
Server
首先定义用于存储键值对的数据结构以及初始化操作。
type valueVersion struct {
value string
version rpc.Tversion
}
type KVServer struct {
mu sync.Mutex
// Your definitions here.
storage map[string]valueVersion
}
func MakeKVServer() *KVServer {
kv := &KVServer{}
// Your code here.
kv.storage = make(map[string]valueVersion)
return kv
}
Server 要实现 Linearizability(线性一致性) 需要保证 Server 侧的Get操作和Put操作具有原子性,也就是涉及到 map 读写的部分都要加锁。上面在实现 Client 的时候提到 Client 和 Server 交互的时候可能会出现各种情况,但是从 Server 的角度去看其实是简单很多的,因为 RPC 调用都加了锁,收到的每次 RPC 调用,该怎么操作就怎么操作,按相应的规则返回结果就行,不必去考虑 Client 是多次重发了消息还是什么。
如果你想知道关于线性一致性的相关知识的话,这篇文章对这个主题的解释很不错,值得一看。
Get
判断 key 是否存在,存在就返回 value 和 version,不存在就返回ErrNoKey。
// Get returns the value and version for args.Key, if args.Key
// exists. Otherwise, Get returns ErrNoKey.
func (kv *KVServer) Get(args *rpc.GetArgs, reply *rpc.GetReply) {
// Your code here.
kv.mu.Lock()
defer kv.mu.Unlock()
vv, ok := kv.storage[args.Key]
if ok {
reply.Err = rpc.OK
reply.Value = vv.value
reply.Version = vv.version
} else {
reply.Err = rpc.ErrNoKey
}
}
Put
需要根据传过来的版本号以及 key 是否存在分情况讨论,我们整理一下各种情况的表格:
| client version | server key | server version | action |
|---|---|---|---|
| 0 | don’t exist | null | set key, return OK |
| 0 | exist | >= 1 | return ErrVersion |
| != 0 | don’t exist | null | return ErrNoKey |
| != 0 | exist | equal to client | update key, return OK |
| != 0 | exist | different from client | return ErrVersion |
对于这类问题我喜欢用多个 if…else… 嵌套来实现,确保不会漏掉任何一种情况,参考这篇博文。
// Update the value for a key if args.Version matches the version of
// the key on the server. If versions don't match, return ErrVersion.
// If the key doesn't exist, Put installs the value if the
// args.Version is 0, and returns ErrNoKey otherwise.
func (kv *KVServer) Put(args *rpc.PutArgs, reply *rpc.PutReply) {
// Your code here.
kv.mu.Lock()
defer kv.mu.Unlock()
if args.Version == 0 {
if _, ok := kv.storage[args.Key]; ok {
reply.Err = rpc.ErrVersion
} else {
kv.storage[args.Key] = valueVersion{value: args.Value, version: 1}
reply.Err = rpc.OK
}
} else {
if vv, ok := kv.storage[args.Key]; ok {
if args.Version == vv.version {
kv.storage[args.Key] = valueVersion{value: args.Value, version: args.Version + 1}
reply.Err = rpc.OK
} else {
reply.Err = rpc.ErrVersion
}
} else {
reply.Err = rpc.ErrNoKey
}
}
}
这里多提一嘴,Go 喜欢用 early return 的形式来优先处理错误。有的人会觉得任何出现了if的情况都应该这样处理,在代码中尽可能不用else,对于上面的分支判断会写成:
if args.Version == 0 {
if _, ok := kv.storage[args.Key]; ok {
reply.Err = rpc.ErrVersion
return
}
kv.storage[args.Key] = valueVersion{value: args.Value, version: 1}
reply.Err = rpc.OK
return
}
if vv, ok := kv.storage[args.Key]; ok {
if args.Version == vv.version {
kv.storage[args.Key] = valueVersion{value: args.Value, version: args.Version + 1}
reply.Err = rpc.OK
return
}
reply.Err = rpc.ErrVersion
return
}
reply.Err = rpc.ErrNoKey
这减少了嵌套的层级,也有利于通过代码复杂度测试,但是存在下面一些缺点:
- 最重要的就是代码变得更难理解了,条件并列的情况在代码中并没有对齐,视觉不能对理解起到很好的帮助;另外还需要结合其中的
return语句来确定一共有哪些情况,导致人脑在理解上需要额外的开销 - 漏掉任何一个
return语句都会导致行为上出现错误,而静态分析以及编译器并不能帮你解决这个问题
当然,怎么选择完全取决于你的个人喜好。
实现分布式锁
本节会基于 Client 的Get和Put方法实现分布式锁。
这个问题一度困扰了我好久,因为有ErrMaybe的存在,根本没法知道Put是自己执行成功了还是别的 Client 执行成功了,连自己加的锁还是别人加的锁都不清楚,那还怎么实现呢?后来我想通了,是思维定势限制了我,分布式锁本来就不是只能有锁与没锁两种不同的状态,让不同的 Client Put不同的值,这样我们不光能知道加锁成功了,还能知道是不是自己加的锁。(PS: 写好之后才发现页面有提示,不过自己想出来的感觉也挺好的)
You will need a unique identifier for each lock client; call
kvtest.RandValue(8)to generate a random string.
对这个例子来说,不同的 Client 运行在不同的 goroutine 中,每个 Client 不同的值可以用随机数生成,在MakeLock的时候填充进去,src/kvtest1/kvtest.go中有一个RandValue函数可以实现这样的功能。
严格意义上来说不同的 Client 运行哈希函数是有很小的概率会出现同样的随机数,生产环境一般是通过配置文件来保证每个 Client 都有不同的身份标识。
type Lock struct {
// IKVClerk is a go interface for k/v clerks: the interface hides
// the specific Clerk type of ck but promises that ck supports
// Put and Get. The tester passes the clerk in when calling
// MakeLock().
ck kvtest.IKVClerk
// You may add code here
key string
id string
}
// The tester calls MakeLock() and passes in a k/v clerk; your code can
// perform a Put or Get by calling lk.ck.Put() or lk.ck.Get().
//
// Use l as the key to store the "lock state" (you would have to decide
// precisely what the lock state is).
func MakeLock(ck kvtest.IKVClerk, l string) *Lock {
lk := &Lock{ck: ck}
// You may add code here
lk.key = l
lk.id = kvtest.RandValue(8)
return lk
}
在实现的时候有一个原则,就是不管用什么次序执行Acquire和Release操作,都要能保证不出问题。比如已经加锁成功,再次加锁应该直接返回;比如只能释放自己加的锁,不能释放别人的。当然,也有解决不了的问题,后面会说。
我们必须定义锁的状态用什么来表示,已加锁状态的话就是指定键的值是某个 Client 生成的随机数;可加锁状态有两种,一种是键不存在,另一种需要一个专门的字符串来表示:
const FREE = "FREE"
加锁
先用Get查一下指定的键是否存在,不存在就Put自己的标识和版本号 0,存在的话再看一下锁的标识,如果是未加锁就Put自己的标识和当前的版本号,如果加了锁要看是自己加的锁还是别人加的锁,还需要根据Put返回的结果分情况处理。另外,加锁需要阻塞直到成功为止,分布式的情况下就是循环调用直到成功为止。
func (lk *Lock) Acquire() {
// Your code here
for {
state, version, errGet := lk.ck.Get(lk.key)
switch errGet {
case rpc.OK:
switch state {
case FREE:
if lk.setLock(version) {
return
} else {
// next round
}
case lk.id:
// self already acquired the lock
return
default:
// someone else acquired the lock, next round
}
case rpc.ErrNoKey:
if lk.setLock(0) {
return
} else {
// next round
}
}
time.Sleep(100 * time.Millisecond)
}
}
func (lk *Lock) setLock(version rpc.Tversion) bool {
errPut := lk.ck.Put(lk.key, lk.id, version)
switch errPut {
case rpc.OK:
return true
case rpc.ErrNoKey:
// should not happen, but if happens, then next round
return false
case rpc.ErrVersion:
// someone else acquired the lock, next round
return false
case rpc.ErrMaybe:
id, _, _ := lk.ck.Get(lk.key)
if id == lk.id {
return true
} else {
// FREE: the lock been acquired by others and released
// OTHERID: someone else acquired the lock, next round
return false
}
default:
return false
}
}
释放锁
检查锁的状态,如果发现是自己加的锁就把值置为FREE,其他情况直接返回。
func (lk *Lock) Release() {
// Your code here
state, version, errGet := lk.ck.Get(lk.key)
if errGet == rpc.OK && state == lk.id {
lk.ck.Put(lk.key, FREE, version)
}
}
上面说存在解决不了的问题,也就是 Client 加锁成功后挂掉了,那么锁永远不会释放。分布式锁一般会在加锁的时候指定过期时间,万一 Client 挂掉之后 Server 检测到锁过期会把锁释放掉。目前的 Server 不具备这个能力,所以不要在实际项目中使用。
If a client crashes while holding a lock, the lock will never be released. In a design more sophisticated than this lab, the client would attach a lease to a lock. When the lease expires, the lock server would release the lock on behalf of the client. In this lab clients don‘t crash and you can ignore this problem.
如果想要实现这个功能,可以参考 Redis,专门用一个超时 map 来存储每一个键对应的过期时间;检测是否超时可以用 lazy 的方法,在获取值的时候先去超时 map 中获取对应键的超时时间,然后和当前时间进行比对;续期就是修改这张超时 map,把超时时间往后延。