MIT6.824 lab2 实验反思
MIT6.824 lab2 实验反思
0. 实验文档
6.5840 Lab 2: Key/Value Server
1. lab——,启动——
1.1 需求描述
本实验分为4个部分。
基于可靠信道的 Key/value server
- 需要实现一个基本的 Key/value 服务器和使用 Key/value 服务器的客户端代理(clerk),不考虑网络导致的信息丢失。
- 主要需求如下:
- 服务器(server):
- 维护一个内存中的映射(map),记录每个键(key)对应的(值(value),版本号(version))元组。键和值都是字符串类型。
- 处理两个RPC请求:
Put(key, value, version)和Get(key)。 Put(key, value, version):只有当传入的版本号与服务器上该键的当前版本号匹配时,才更新键的值并增加版本号(版本号加1)。如果版本号匹配,更新值并增加版本号,返回成功;如果不匹配,返回错误rpc.ErrVersion。如果要创建新键,客户端传入版本号0,服务器会将该键的版本号设为1(因为版本号从1开始)。如果版本号大于0且键不存在,返回rpc.ErrNoKey。Get(key):返回键的当前值和版本号。如果键不存在,返回错误rpc.ErrNoKey。- 确保操作的线性一致性(linearizability):对于非并发操作,每个操作都能看到前一个操作完成后的状态;对于并发操作,最终状态和返回值必须与某个顺序执行(一次一个操作)的结果相同。
- 客户端代理(clerk):
- 提供
Put和Get方法,用于向服务器发送RPC请求。 - 在可靠信道下,不需要处理网络丢包问题,即每个RPC请求都能到达服务器,并且每个回复都能到达客户端。
- 在客户端代码中,发送RPC请求并接收回复,根据回复返回相应结果(值或错误)。
- 提供
- 服务器(server):
基于 Key/value server 和可靠信道的分布式锁
- 使用上面实现的Key/value服务器(在可靠信道下)来实现一个分布式锁。
- 锁提供两个方法:
Acquire和Release。 - 需求如下:
- 同一时刻只有一个客户端能够成功获取锁(即
Acquire返回成功),其他客户端必须等待直到锁被释放。 - 每个锁客户端需要一个唯一的标识符(可通过
kvtest.RandValue(8)生成随机字符串)。 - 锁服务使用一个特定的键(通过
MakeLock的参数l指定)来存储锁的状态。需要设计锁状态的具体表示(例如,可以存储持有锁的客户端标识和当前锁的版本号)。 - 如果客户端崩溃(但在本实验中不考虑此情况),锁可能永远不会被释放。但在本实验中,假设客户端不会崩溃。
- 同一时刻只有一个客户端能够成功获取锁(即
基于不可靠信道的 Key/value server
- 在不可靠信道下扩展Key/value服务器客户端代理(clerk)的功能,以处理网络丢包、延迟和重排序等问题。
- 主要需求如下:
- 客户端必须重试RPC请求直到收到回复(因为网络可能丢弃请求或回复)。
- 对于
Get请求,重试是安全的,因为它是只读操作。 - 对于
Put请求,由于条件更新的特性(基于版本号),重试也是安全的,因为重复的Put请求(相同的版本号)在第一次成功执行后,第二次会因版本号不匹配而返回rpc.ErrVersion。 - 处理重试时可能出现的特殊情况:当客户端重试一个
Put请求后,如果服务器返回rpc.ErrVersion,客户端必须区分是第一次请求就失败(返回rpc.ErrVersion)还是重试时失败(返回rpc.ErrMaybe,表示不确定是否执行)。具体来说:- 如果是第一次发送请求(未重试)就收到
rpc.ErrVersion,则向应用层返回rpc.ErrVersion(表示未执行)。 - 如果是重试后收到
rpc.ErrVersion,则向应用层返回rpc.ErrMaybe(表示可能执行了,也可能没执行)。
- 如果是第一次发送请求(未重试)就收到
- 在重试之间应等待一段时间(例如100毫秒),避免过度重试。
基于 Key/value server 和不可靠信道的分布式锁
-
使用上面扩展的Key/value服务器再次实现分布式锁,确保在不可靠信道下也能正确工作。
-
需求与可靠信道下的分布式锁相同,但需要处理网络丢包和重试带来的问题。
-
特别注意:锁的
Acquire和Release操作需要能够处理Put返回的rpc.ErrMaybe错误,并确保锁的正确性(即同一时刻只有一个客户端持有锁)。
1.2 详细实现方案
1.2.1 基于可靠信道的 Key/value server 的实现方案
这部分相对来说比较简单。首先需要明确的是,基于 Go 标准库的 net/rpc 包实现的 RPC 服务对于每一个到来的 RPC 请求都会起一个 goroutine 进行处理。而这里需要同时实现一个 RPC 服务和一个客户端代理 clerk,所以很容易想到可以在 server.go 中维护一个 map,记录 <key, value, version> 三元组。同时考虑到要支持客户端的并发,所以要对这个 map 加锁,避免竞态条件。
Put 方法
根据需求描述,Put 方法在版本号匹配的时候应该成功插入三元组,在不匹配的时候应该返回 rpc.ErrVersion,如果第一次插入而版本号不是 0 就要返回 rpc.ErrNoKey。显然 Put 方法实现如下:
// 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.
key, val, ver := args.Key, args.Value, args.Version
reply.Err = rpc.OK
kv.mu.Lock()
defer kv.mu.Unlock()
v, ok := kv.table[key]
if !ok {
if ver != 0 {
reply.Err = rpc.ErrNoKey
return
}
kv.table[key] = &ValVerPair{
value: val,
version: ver + 1,
}
return
}
if v.version != ver {
reply.Err = rpc.ErrVersion
return
}
v.value = val
v.version++
}
因为这里需要对 map 做读写操作,所以需要加锁。
Get 方法
对于 Get 方法,返回 value 和 version。如果 map 中没有找到对应的 key,返回 rpc.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.
key := args.Key
reply.Err = rpc.OK
kv.mu.Lock()
defer kv.mu.Unlock()
v, ok := kv.table[key]
if !ok {
reply.Err = rpc.ErrNoKey
return
}
reply.Value = v.value
reply.Version = v.version
}
这里即使Get是只读操作,也需要加锁。以下是详细原因:
-
防止数据竞争(Data Race)
-
Go 的
map在并发读写时会导致运行时 panic(fatal error: concurrent map read and map write)。 -
即使只有多个
Get并发读,如果同时有一个Put在写(修改或删除键值对),也会触发数据竞争。 -
加锁确保对
kv.table的访问是互斥的,避免程序崩溃。
-
-
保证读取的一致性
-
假设
Get不加锁,而Put加锁:-
当
Put修改kv.table[key]时(例如更新值或版本号),Get可能读到部分更新的脏数据(如旧值配新版本)。 -
锁确保
Get读取时,kv.table[key]处于稳定状态(例如值和版本号是原子更新的)。
-
-
-
避免过时数据(Stale Read)
-
如果
Get不加锁,可能读到已被修改但未完成写入的数据(例如Put只更新了值,尚未更新版本号)。 -
加锁后,
Get会等待Put完成整个操作,确保读取到最新提交的数据。
-
而客户端代理 clerk 的操作就相对简单了,通过 rpc 调用 server 这边提供的 Put 方法和 Get 方法执行对应的功能就行了。
1.2.2 基于 Key/value server 和可靠信道的分布式锁的实现方案
所谓分布式锁,是指当某个共享资源被集群中的多台机器共享的时候,我们需要对它加一个全局的锁,以保证不会在机器层面出现竞态条件。
我们可以依赖于 value + version 两个字段实现一个分布式锁。实现思路如下:
- 当某个机器拿到这个共享资源的时候,要对其加锁。加锁就是往 Key/value server 中写入一个键值对,key 是锁的名称,value 是持有锁的机器ID,version 是当前锁的版本号,用于比对加锁释放锁操作是否合法。
- 当机器对这个资源的操作结束以后,就要及时释放锁。释放锁可以把 value 字段记录的 ID 改成空字符串,同时更新 version 字段。这样当另外一个机器要对其加锁的时候,首先验证锁是否被其他机器持有,没有的话再根据获得的版本号写入自己的 ID 到 value,这样就成功加锁了。
理清楚两个操作具体做的事情以后,不难写出下面的代码:
func (lk *Lock) Acquire() {
// Your code here
delay := lk.backoff
for {
val, ver, err := lk.ck.Get(lk.key)
if err != rpc.ErrNoKey && val != "" {
delay = min(lk.maxBackoff, 2*delay)
time.Sleep(delay)
continue
}
err = lk.ck.Put(lk.key, lk.id, ver)
if err == rpc.ErrVersion || err == rpc.ErrNoKey {
delay = min(lk.maxBackoff, 2*delay)
time.Sleep(delay)
continue
}
break
}
}
func (lk *Lock) Release() {
// Your code here
delay := lk.backoff
for {
val, ver, err := lk.ck.Get(lk.key)
if err == rpc.ErrNoKey || val == "" {
delay = min(lk.maxBackoff, 2*delay)
time.Sleep(delay)
continue
}
err = lk.ck.Put(lk.key, "", ver)
if err == rpc.ErrVersion || err == rpc.ErrNoKey {
delay = min(lk.maxBackoff, 2*delay)
time.Sleep(delay)
continue
}
break
}
}
1.2.3 基于不可靠信道的 Key/value server 的实现方案
不可靠信道下,可能出现网络丢包、延迟和重排序等问题,但是我们要保证 Key/value server 不受这些问题的影响。其实核心就是一个点——在没有得到确切回复的情况下重试。
对于 Get 方法,这相当好实现,只要写一个循环,在得到确切结果之前重复请求,设置好间隔时间就行了。但是对于 Put 方法来说,就要稍微复杂一些。Put 方法在调用过程中可能出现的情况是:键值对已经存放到服务器里面了,并且服务器也正确返回了消息,但是这个消息丢包了。那下一次重试过程中,Put 方法仍然使用的是旧的版本号,这个时候就会返回版本号不匹配的错误。但是实际上我们是正确存放了结果的。那么针对这个情景,实验文档给出的方案是,在不能确定是否插入键值对的时候,返回 ErrMaybe,交由应用层进行处理。
我们可以在Put 方法中维护一个计数器,计数重试次数。如果是第一次尝试或者返回的结果是OK,就正常返回;否则返回 ErrMaybe。所以 Put 方法的实现如下:
// Put updates key with value only if the version in the
// request matches the version of the key at the server. If the
// versions numbers don't match, the server should return
// ErrVersion. If Put receives an ErrVersion on its first RPC, Put
// should return ErrVersion, since the Put was definitely not
// performed at the server. If the server returns ErrVersion on a
// resend RPC, then Put must return ErrMaybe to the application, since
// its earlier RPC might have been processed by the server successfully
// but the response was lost, and the Clerk doesn't know if
// the Put was performed or not.
//
// You can send an RPC with code like this:
// ok := ck.clnt.Call(ck.server, "KVServer.Put", &args, &reply)
//
// The types of args and reply (including whether they are pointers)
// must match the declared types of the RPC handler function's
// arguments. Additionally, reply must be passed as a pointer.
func (ck *Clerk) Put(key, value string, version rpc.Tversion) rpc.Err {
// You will have to modify this function.
delay := ck.backoff
var attempt int
for {
attempt++
args := rpc.PutArgs{
Key: key,
Value: value,
Version: version,
}
reply := rpc.PutReply{}
ok := ck.clnt.Call(ck.server, "KVServer.Put", &args, &reply)
if ok {
if attempt == 1 || reply.Err == rpc.OK { // first call or clearly success
return reply.Err
}
return rpc.ErrMaybe
}
time.Sleep(delay)
delay = min(delay*2, ck.maxBackoff)
}
}
1.2.4 基于 Key/value server 和不可靠信道的分布式锁的实现方案
这里的分布式锁其实就对应上文中提到的应用层。在加锁释放锁的时候,Put 方法可能会返回 ErrMaybe 这种错误。对于这种错误,我们采取最直接的解决方案——重试。实现代码如下:
func (lk *Lock) Acquire() {
// Your code here
delay := lk.backoff
for {
val, ver, err := lk.ck.Get(lk.key)
if err != rpc.ErrNoKey && val != "" {
time.Sleep(delay)
delay = min(lk.maxBackoff, 2*delay)
continue
}
err = lk.ck.Put(lk.key, lk.id, ver)
if err == rpc.ErrVersion || err == rpc.ErrNoKey {
time.Sleep(delay)
delay = min(lk.maxBackoff, 2*delay)
continue
}
if err == rpc.ErrMaybe {
value, _, e := lk.ck.Get(lk.key)
if e != rpc.OK || value != lk.id {
time.Sleep(delay)
delay = min(lk.maxBackoff, 2*delay)
continue
}
}
break
}
}
func (lk *Lock) Release() {
// Your code here
delay := lk.backoff
for {
val, ver, err := lk.ck.Get(lk.key)
if err == rpc.ErrNoKey || val == "" {
time.Sleep(delay)
delay = min(lk.maxBackoff, 2*delay)
continue
}
if val != lk.id { // if current goroutine doesn't get the lock, exit straightly
return
}
err = lk.ck.Put(lk.key, "", ver)
if err == rpc.ErrVersion || err == rpc.ErrNoKey {
time.Sleep(delay)
delay = min(lk.maxBackoff, 2*delay)
continue
}
if err == rpc.ErrMaybe {
value, _, e := lk.ck.Get(lk.key)
if e != rpc.OK || value != "" {
time.Sleep(delay)
delay = min(lk.maxBackoff, 2*delay)
continue
}
}
break
}
}
2. 总结
本实验旨在实现一个简单的 Key/value server 和一个简单的分布式锁。
对于本实验来说,有以下两个难点:
- 在 Key/value server 不支持删除操作的情况下,给出分布式锁的实现方案
- 基于不可靠信道的改进方案
对于绝大多数问题,它都不是难,而是复杂。难是无从下手,复杂是好像有那么一点思路,但是细枝末节的地方不好搞定。那么面对复杂问题的时候,我们可以把问题拆解成一个一个的简单问题,就像这个实验一样,逐个击破,就一定能够成功。那么对于剩下的那些难题,就忘了它罢,难道这世上所有的问题都能够解决吗?

浙公网安备 33010602011771号