MIT_65840 Lab2 KV Server 与分布式锁

完成笔记

本次作业分成三个清晰的步骤, 分别是构建一个 KV Server, 然后使用这个 KV Server 实现一个分布式锁, 最后是在网络不稳定, 存在丢包与抱错的场景下, 如何保证这个分布式锁的可用性.

KV 服务器的搭建

  1. 每一个客户端使用 Clerk 与服务端通信, 使用 RPC 的方式通信.
  2. 客户端会通过 RPC 向服务端发送两种操作, Put(key, value, version), 与 Get(key).
  3. 服务端中会在内存中使用 MAP 为每一个 Key 记录一个 (Value, Version) 的 tuple.
  4. RPC 中的 Get(key) 只会返回 rpc.ErrNoKey 这一种报错, 但是 Put(key, value, version) 会返回 rpc.ErrVersion 或者 rpc.ErrMaybe.

服务端的实现

在一个 KV 服务器中, 服务端是用来存储数据的, 服务端主要有三部分需要实现, 如何存储数据, 客户端 RPC 调用服务端的 Get() 函数, 服务端如何返回数据, 客户端 PRC 调用服务端的 Put() 函数, 服务端如何执行.

  1. 服务端的结构体如下:
type KVServer struct {
	mu sync.Mutex

	// Your definitions here.
	// data structure to store key-value pairs and their versions
	versions map[string]rpc.Tversion
	data     map[string]string
}
  1. 当服务端收到客户端的 Get() 请求时, 会执行下面的 Get() 函数.
// 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()
	value, exists := kv.data[args.Key]
	// if key does not exist, return ErrNoKey
	if !exists {
		reply.Err = rpc.ErrNoKey
		return
	}
	// key exists, return value and version
	reply.Value = value
	reply.Version = kv.versions[args.Key]
	reply.Err = rpc.OK
}
  1. 当服务端收到客户端的 Put() 请求时. 需要执行下面的 Put() 函数.
func (kv *KVServer) Put(args *rpc.PutArgs, reply *rpc.PutReply) {
	// Your code here.
	kv.mu.Lock()
	defer kv.mu.Unlock()
	currentVersion, exists := kv.versions[args.Key]
	// key does not exist
	if !exists {
		// if version is 0, add the key and value, version becomes 1
		if args.Version == 0 {
			// install the value
			kv.data[args.Key] = args.Value
			kv.versions[args.Key] = 1 // initial version is 1
			reply.Err = rpc.OK
		} else {
			// key doesn't exist and version is not 0
			reply.Err = rpc.ErrNoKey
		}
		return
	}
	// key exists, check version
	if args.Version != currentVersion {
		reply.Err = rpc.ErrVersion
		return
	}
	// update value and version
	kv.data[args.Key] = args.Value
	kv.versions[args.Key] = currentVersion + 1
	reply.Err = rpc.OK
}

客户端的实现

客户端的本质是与服务端发送请求, 主要用于存储与获取数据, 是 KV Server 中最常见的操作. 在这个项目中, 请求发送的方式是使用 RPC() 远程调用.
客户端的 Get() 函数用于获取 KV Server 中存储的数据, Put() 用于修改 KV Server 中存储的数据, 同时还需要考虑网络的不稳定性.

  1. Get() 函数的实现
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
		}
		if reply.Err == rpc.ErrNoKey {
			return "", 0, reply.Err
		}
	}
	// return reply.Value, reply.Version, reply.Err
}
  1. Put() 函数的实现
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}
	// 设置 RPC 应答, 需要保证参数与应答的结构体与 Server 中 RPC 函数
	// 声明的结构体保持一致
	var reply rpc.PutReply
	hadRetry := false
	for {
		// RPC 远程调用函数
		ok := ck.clnt.Call(ck.server, "KVServer.Put", &args, &reply)
		// 注意, 这里的 ok==false, 都表示本次 RPC 失败, 比如丢包
		// 在 Put 的时候就需要考虑是丢失请求报文还是应答报文
		if !ok {
			hadRetry = true
			continue
		}
		if reply.Err == rpc.ErrVersion {
			if hadRetry {
				return rpc.ErrMaybe
			}
			return rpc.ErrVersion
		}
		return reply.Err
	}
}

分布式锁的实现

在这个项目中, 我们是通过 KV 服务器来实现了一个分布式锁. 分布式锁的本质也是维持与保护临界资源, 也是一个互斥锁. 不同点是, 我们程序内的互斥锁的实现往往较为简单, 在一个进程内的话, 多个线程实际上是共享内存的, 在锁的实现与确认步骤较为简单.
分布式锁是作用在多个客户端的复杂场景下的, 例如服务器中的临界资源, 这在分布式系统中十分常见. 由于在分布式系统中, 服务只能通过网络进行调用, 因此存在更多的不确定性, 例如网络的丢包, 超时, 重复请求等等. 实际生产中, 分布式还必须引入租约机制, 临界资源都需要引入租约机制, 但是在本项目中没有实现.

那么我们在这个项目中实现了一个什么样的分布式锁呢? 我们可以通过测试部分的代码发现.

  1. 在测试函数中, 最重要的函数是调用下面的 runClients 函数.
func runClients(t *testing.T, nclnt int, reliable bool) {
	// 根据测试选择与配置文件, 新建 KV Server
	ts := kvsrv.MakeTestKV(t, reliable)
	// 有点类似于 C++ 的 Destructor
	defer ts.Cleanup()

	ts.Begin(fmt.Sprintf("Test: %d lock clients", nclnt))
	// Spawn nclnt clients, each of which tries to acquire and release the lock NACQUIRE times.
	// The test passes if no two clients are never holding the lock at the same time.
	ts.SpawnClientsAndWait(nclnt, NSEC*time.Second, func(me int, myck kvtest.IKVClerk, done chan struct{}) kvtest.ClntRes {
		return oneClient(t, me, myck, done)
	})
}
  1. 而在启动多个 Clients 的步骤中, 如下:
// spawn ncli clients
func (ts *Test) SpawnClientsAndWait(nclnt int, t time.Duration, fn Fclnt) []ClntRes {
	ca := make([]chan ClntRes, nclnt)
	done := make(chan struct{})
	for cli := 0; cli < nclnt; cli++ {
		ca[cli] = make(chan ClntRes)
		go ts.runClient(cli, ca[cli], done, ts.mck, fn)
	}
	time.Sleep(t)
	for i := 0; i < nclnt; i++ {
		done <- struct{}{}
	}
	rs := make([]ClntRes, nclnt)
	for cli := 0; cli < nclnt; cli++ {
		rs[cli] = <-ca[cli]
	}
	return rs
}
  1. 分布式锁测试的步骤是, 首先初始化一个 TestKV 的测试框架, 主要是读取测试环境的一些参数, 然后是批量生成大量的 Clients 客户端.
  2. 启动与创建多个 Clients 客户端的方式是通过创建一个 runClient 协程来实现的, 如果是多个并发, 会异步的创建与启动多个 runClient 协程. 每一个协程会调用函数 MakeClerk() 来创建一个客户端, 也就是下面的函数
func (ts *TestKV) MakeClerk() kvtest.IKVClerk {
	clnt := ts.Config.MakeClient()
	ck := MakeClerk(clnt, tester.ServerName(tester.GRP0, 0))
	return &kvtest.TestClerk{ck, clnt}
}

这个函数的服务端 Server 是相同的, TestClerk 绑定了客户端与服务端.

  1. 创建客户端之后, 每一个协程都会调用函数 oneClient 并且使用这个客户端. 在调用函数 oneClient 的时候, 会使用函数 lk := MakeLock(ck, "l") 来创建一个锁, 也就是说每个协程对应一个客户端, 每个客户端都有一个锁, 但是!!! 所有的协程在创建锁的时候, key 都是相同的, 也就是都是在服务端相同的 "l", 这也就是说明, 所有的客户端都使用相同的分布式锁.
  2. 由于并发与网络连接不稳定导致分布式锁重复获取锁的问题: 当 Clinet_A 与 Client_B 在 Acquire() 阶段, 发现 Get(lk.key) 的应答都为空, 也就是没有人持有锁, 两个都想获取锁, 此时, Clinet_A 和 Client_B 都会执行 Put 操作, 如果 Client_A 在 Put 时, 请求丢失, 或者 Put 失败, 此时 Cilent_B 发起了 Put, 并且成功, 然后 Client_A 重试, 此时由于 Client_B 已经写入了 Server, 因此 Client_A 继续失败(由于重复写入), 但是返回值是 rpc.ErrMaybe, 此时, 我们再通过 Get 进行一次检查, 发现应答成功, 并且已经上锁, Client_A 误认为是自己上锁了, 其实是 Client_B 上的锁. 这种并发与网络不稳定导致分布式锁的获取错误.

分布式锁的代码实现

我的代码实现如下:

func (lk *Lock) Acquire() {
	// Your code here
	for {
		val, ver, err := lk.ck.Get(lk.l)
		if err == rpc.ErrNoKey {
			val = ""
			ver = 0
		}
		// If we already hold the lock (previous Put succeeded but response was lost)
		if val == lk.id {
			return
		}
		// If the lock is free, try to acquire it
		if val == "" {
			err = lk.ck.Put(lk.l, lk.id, ver)
			if err == rpc.OK {
				return
			}
			// ErrMaybe: might have succeeded, loop back and check with Get
			// ErrVersion: someone else got it, loop back
		}
		time.Sleep(100 * time.Millisecond)
	}
}

func (lk *Lock) Release() {
	// Your code here
	for {
		val, ver, err := lk.ck.Get(lk.l)
		if err == rpc.OK && val != lk.id {
			// Lock is not held by us - our release already succeeded
			return
		}
		if err == rpc.OK && val == lk.id {
			// We still hold the lock, try to release it
			err = lk.ck.Put(lk.l, "", ver)
			if err == rpc.OK {
				return
			}
			// ErrMaybe: might have succeeded, loop back and check with Get
		}
		time.Sleep(100 * time.Millisecond)
	}
}

需要避坑的点:

  1. Acquire() 和 Release() 步骤都是需要修改服务端中锁的状态, 也就是 lk.l 的状态, 因此都需要先使用 Get() 请求锁当前的状态, 然后再修改.
  2. 需要充分考虑多个 Clinets 同时请求锁以及网络不稳定的情况.
  3. 为了记录锁的状态, 是否被占用, 以及被谁占用, 可以使用客户端的 ID 判断.
posted @ 2026-02-13 10:17  虾野百鹤  阅读(11)  评论(0)    收藏  举报