文章

MIT 6.824 学习记录:Key/Value Server Lab 学习与实践

在完成了 FT VM 论文的阅读之后,我随即查阅了 Lab2 的实验文档,发现其实这部分内容与论文的直接关联并不大,整体难度相比 Lab1 明显降低。因此我决定优先完成 Lab2 的实验。

实验主要分为两个部分:

  1. 实现一个单体的 Key/Value NoSQL 数据库;

  2. 基于该数据库构建一个分布式锁机制。

在实现过程中,关于 unreliable network 的部分思考和分类讨论让我觉得非常有意思:

第二部分任务是实现基于 KV 数据库的分布式锁功能。此前我已经接触过 Redis 实现分布式锁的思路,而本实验中并未要求实现租约机制或看门狗功能,因此实现起来相对简单:

实现单体 Key/Value Server

这一部分实验任务同样被细分为两个阶段:

  1. 实现 Key/value server with reliable network

  2. 实现 Key/value server with dropped messages

可靠网络场景下的 KVServer 实现

第一阶段实现较为直白:完成两对 RPC 接口,即 PutGet 方法。需求规范已由文档详细列出。

客户端代码主要补全了 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
}

具体的 GetPut 实现逻辑较为直接,需注意加锁来保证并发安全:

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 等一致性协议,预期会更加有挑战性,也更加贴近实际分布式系统设计。

期待后续的实验内容🤗🤗

License:  CC BY 4.0