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),不考虑网络导致的信息丢失。
  • 主要需求如下:
    1. 服务器(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):对于非并发操作,每个操作都能看到前一个操作完成后的状态;对于并发操作,最终状态和返回值必须与某个顺序执行(一次一个操作)的结果相同。
    2. 客户端代理(clerk)
      • 提供PutGet方法,用于向服务器发送RPC请求。
      • 在可靠信道下,不需要处理网络丢包问题,即每个RPC请求都能到达服务器,并且每个回复都能到达客户端。
      • 在客户端代码中,发送RPC请求并接收回复,根据回复返回相应结果(值或错误)。

基于 Key/value server 和可靠信道的分布式锁

  • 使用上面实现的Key/value服务器(在可靠信道下)来实现一个分布式锁。
  • 锁提供两个方法:AcquireRelease
  • 需求如下:
    • 同一时刻只有一个客户端能够成功获取锁(即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服务器再次实现分布式锁,确保在不可靠信道下也能正确工作。

  • 需求与可靠信道下的分布式锁相同,但需要处理网络丢包和重试带来的问题。

  • 特别注意:锁的AcquireRelease操作需要能够处理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 方法,返回 valueversion。如果 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是只读操作,也需要加锁。以下是详细原因:

  1. 防止数据竞争(Data Race)

    • Go 的 map 在并发读写时会导致运行时 panic(fatal error: concurrent map read and map write)。

    • 即使只有多个 Get 并发读,如果同时有一个 Put 在写(修改或删除键值对),也会触发数据竞争。

    • 加锁确保对 kv.table 的访问是互斥的,避免程序崩溃。

  2. 保证读取的一致性

    • 假设 Get 不加锁,而 Put加锁:

      • Put 修改 kv.table[key] 时(例如更新值或版本号),Get 可能读到部分更新的脏数据(如旧值配新版本)。

      • 锁确保 Get 读取时,kv.table[key] 处于稳定状态(例如值和版本号是原子更新的)。

  3. 避免过时数据(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 不支持删除操作的情况下,给出分布式锁的实现方案
  • 基于不可靠信道的改进方案

对于绝大多数问题,它都不是难,而是复杂。难是无从下手,复杂是好像有那么一点思路,但是细枝末节的地方不好搞定。那么面对复杂问题的时候,我们可以把问题拆解成一个一个的简单问题,就像这个实验一样,逐个击破,就一定能够成功。那么对于剩下的那些难题,就忘了它罢,难道这世上所有的问题都能够解决吗?

posted @ 2025-09-07 22:02  Led_Zeppelin_死忠粉  阅读(10)  评论(0)    收藏  举报