MIT 6.824 学习记录:Key/Value Server Lab 学习与实践
在完成了 FT VM 论文的阅读之后,我随即查阅了 Lab2 的实验文档,发现其实这部分内容与论文的直接关联并不大,整体难度相比 Lab1 明显降低。因此我决定优先完成 Lab2 的实验。
实验主要分为两个部分:
实现一个单体的 Key/Value NoSQL 数据库;
基于该数据库构建一个分布式锁机制。
在实现过程中,关于 unreliable network 的部分思考和分类讨论让我觉得非常有意思:
第二部分任务是实现基于 KV 数据库的分布式锁功能。此前我已经接触过 Redis 实现分布式锁的思路,而本实验中并未要求实现租约机制或看门狗功能,因此实现起来相对简单:
实现单体 Key/Value Server
这一部分实验任务同样被细分为两个阶段:
实现
Key/value server with reliable network
;实现
Key/value server with dropped messages
。
可靠网络场景下的 KVServer 实现
第一阶段实现较为直白:完成两对 RPC 接口,即 Put
和 Get
方法。需求规范已由文档详细列出。
客户端代码主要补全了 RPC 调用和结果解析的部分:
func (ck *Clerk) Get(key string) (string, rpc.Tversion, rpc.Err) {
args := rpc.GetArgs{key}
reply := rpc.GetReply{}
ok := ck.clnt.Call(ck.server, "KVServer.Get", &args, &reply)
if !ok {
return "", 0, ""
}
return reply.Value, reply.Version, reply.Err
}
func (ck *Clerk) Put(key, value string, version rpc.Tversion) rpc.Err {
args := rpc.PutArgs{key, value, version}
reply := rpc.PutReply{}
ok := ck.clnt.Call(ck.server, "KVServer.Put", &args, &reply)
if !ok {
return ""
}
return reply.Err
}
刚开始实现时我忽略了 Call
函数的返回值,导致在网络模拟不可靠的测试中出现了 bug。
服务端方面,我们需要实现数据存储结构。对于 KV 数据库而言,Go 的 map 十分合适。同时由于每个 value 与其版本号是强绑定关系,因此封装为一个结构体:
type Element struct {
Value string
Version rpc.Tversion
}
type KVServer struct {
mu sync.Mutex
keyValues map[string]Element
}
具体的 Get
和 Put
实现逻辑较为直接,需注意加锁来保证并发安全:
func (kv *KVServer) Get(args *rpc.GetArgs, reply *rpc.GetReply) {
kv.mu.Lock()
defer kv.mu.Unlock()
element, ok := kv.keyValues[args.Key]
if !ok {
reply.Err = rpc.ErrNoKey
reply.Value = ""
reply.Version = 0
return
}
reply.Err = rpc.OK
reply.Value = element.Value
reply.Version = element.Version
}
func (kv *KVServer) Put(args *rpc.PutArgs, reply *rpc.PutReply) {
kv.mu.Lock()
defer kv.mu.Unlock()
key := args.Key
version := args.Version
element, ok := kv.keyValues[key]
if !ok && version > 0 {
reply.Err = rpc.ErrNoKey
return
}
if element.Version != version {
reply.Err = rpc.ErrVersion
return
}
reply.Err = rpc.OK
kv.keyValues[key] = Element{args.Value, version + 1}
}
不可靠网络场景下的容错设计
第二阶段的实现更具挑战性。在不可靠网络下,请求或响应可能丢失,因此系统的鲁棒性显得尤为关键。
难点在于:请求丢失或响应丢失对于客户端和服务端而言是不可感知的。
我们的策略是通过重试机制来应对丢包问题。对于 Get
操作,它是幂等的,因此可以简单地通过循环重试实现:
func (ck *Clerk) Get(key string) (string, rpc.Tversion, rpc.Err) {
args := rpc.GetArgs{key}
reply := rpc.GetReply{}
ok := ck.clnt.Call(ck.server, "KVServer.Get", &args, &reply)
for !ok {
time.Sleep(100 * time.Millisecond)
ok = ck.clnt.Call(ck.server, "KVServer.Get", &args, &reply)
}
return reply.Value, reply.Version, reply.Err
}
对于 Put
操作,由于其可能会改变服务端状态,需要谨慎处理。如果重试之后返回 ErrVersion
,我们无法判断是否已经执行成功,因此需要返回 ErrMaybe
:
func (ck *Clerk) Put(key, value string, version rpc.Tversion) rpc.Err {
args := rpc.PutArgs{key, value, version}
reply := rpc.PutReply{}
ok := ck.clnt.Call(ck.server, "KVServer.Put", &args, &reply)
retried := false
for !ok {
time.Sleep(100 * time.Millisecond)
retried = true
ok = ck.clnt.Call(ck.server, "KVServer.Put", &args, &reply)
}
if reply.Err == rpc.ErrVersion && retried {
return rpc.ErrMaybe
}
return reply.Err
}
至此,我们完成了单体 KVServer 的实现。从设计角度看,整体结构清晰,可扩展性也较好。性能方面还有优化空间,例如:
将全局锁拆分为分段锁,以降低锁竞争;
尝试基于 CAS(Compare-And-Swap)实现乐观并发控制,尤其在冲突较少的场景下性能更优。
实现分布式锁
分布式锁的实现可以基于 KV 数据库的特性来设计,核心是两个操作:Acquire(加锁)与 Release(解锁)。
基本思路是:
将锁表示为数据库中的一个键值对;
若该 key 对应的 value 为空,则视为无锁状态;
加锁时尝试设置该 key 的 value 为当前线程的唯一标识;
解锁时仅允许持有该唯一标识的线程修改 value 为 "",防止他人误释放。
因此,锁的结构如下:
type Lock struct {
ck kvtest.IKVClerk // 提供 KV 操作接口
lockStr string // 锁名,对应 KV 中的 key
id string // 唯一标识,用于解锁权限校验
}
加锁逻辑如下:
func (lk *Lock) Acquire() {
for {
id, version, err := lk.ck.Get(lk.lockStr)
if id == lk.id {
return
}
if err == rpc.ErrNoKey || id == "" {
err := lk.ck.Put(lk.lockStr, lk.id, version)
if err == rpc.OK {
return
}
}
time.Sleep(10 * time.Millisecond)
}
}
解锁逻辑如下:
func (lk *Lock) Release() {
id, version, err := lk.ck.Get(lk.lockStr)
if err == rpc.OK && id == lk.id {
lk.ck.Put(lk.lockStr, "", version)
}
}
目前该锁机制尚不支持自动过期(如租约、超时重试),若持有锁的线程在释放前崩溃,可能造成死锁。这类问题可通过引入租约机制或看门狗续约机制来完善,这也是后续分布式系统设计的重要点
总结
本次 Lab 实验难度适中,更偏向对基础知识的回顾与实践。特别是在处理 unreliable network 场景、设计幂等操作与状态一致性方面,提供了非常实用的训练。后续实验中如果引入 Raft 等一致性协议,预期会更加有挑战性,也更加贴近实际分布式系统设计。
期待后续的实验内容🤗🤗