MIT_65840 Lab2 KV Server 与分布式锁
完成笔记
本次作业分成三个清晰的步骤, 分别是构建一个 KV Server, 然后使用这个 KV Server 实现一个分布式锁, 最后是在网络不稳定, 存在丢包与抱错的场景下, 如何保证这个分布式锁的可用性.
KV 服务器的搭建
- 每一个客户端使用
Clerk与服务端通信, 使用 RPC 的方式通信. - 客户端会通过 RPC 向服务端发送两种操作,
Put(key, value, version), 与Get(key). - 服务端中会在内存中使用 MAP 为每一个 Key 记录一个
(Value, Version)的 tuple. - RPC 中的
Get(key)只会返回rpc.ErrNoKey这一种报错, 但是Put(key, value, version)会返回rpc.ErrVersion或者rpc.ErrMaybe.
服务端的实现
在一个 KV 服务器中, 服务端是用来存储数据的, 服务端主要有三部分需要实现, 如何存储数据, 客户端 RPC 调用服务端的 Get() 函数, 服务端如何返回数据, 客户端 PRC 调用服务端的 Put() 函数, 服务端如何执行.
- 服务端的结构体如下:
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
}
- 当服务端收到客户端的
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
}
- 当服务端收到客户端的
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 中存储的数据, 同时还需要考虑网络的不稳定性.
- 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
}
- 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 服务器来实现了一个分布式锁. 分布式锁的本质也是维持与保护临界资源, 也是一个互斥锁. 不同点是, 我们程序内的互斥锁的实现往往较为简单, 在一个进程内的话, 多个线程实际上是共享内存的, 在锁的实现与确认步骤较为简单.
分布式锁是作用在多个客户端的复杂场景下的, 例如服务器中的临界资源, 这在分布式系统中十分常见. 由于在分布式系统中, 服务只能通过网络进行调用, 因此存在更多的不确定性, 例如网络的丢包, 超时, 重复请求等等. 实际生产中, 分布式还必须引入租约机制, 临界资源都需要引入租约机制, 但是在本项目中没有实现.
那么我们在这个项目中实现了一个什么样的分布式锁呢? 我们可以通过测试部分的代码发现.
- 在测试函数中, 最重要的函数是调用下面的
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)
})
}
- 而在启动多个 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
}
- 分布式锁测试的步骤是, 首先初始化一个 TestKV 的测试框架, 主要是读取测试环境的一些参数, 然后是批量生成大量的 Clients 客户端.
- 启动与创建多个 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 绑定了客户端与服务端.
- 创建客户端之后, 每一个协程都会调用函数
oneClient并且使用这个客户端. 在调用函数oneClient的时候, 会使用函数lk := MakeLock(ck, "l")来创建一个锁, 也就是说每个协程对应一个客户端, 每个客户端都有一个锁, 但是!!! 所有的协程在创建锁的时候, key 都是相同的, 也就是都是在服务端相同的 "l", 这也就是说明, 所有的客户端都使用相同的分布式锁. - 由于并发与网络连接不稳定导致分布式锁重复获取锁的问题: 当 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)
}
}
需要避坑的点:
- Acquire() 和 Release() 步骤都是需要修改服务端中锁的状态, 也就是 lk.l 的状态, 因此都需要先使用 Get() 请求锁当前的状态, 然后再修改.
- 需要充分考虑多个 Clinets 同时请求锁以及网络不稳定的情况.
- 为了记录锁的状态, 是否被占用, 以及被谁占用, 可以使用客户端的 ID 判断.

浙公网安备 33010602011771号