在这个实验中,你将构建一个单机版的键值服务器,该服务器能够确保每个操作在网络故障的情况下依然能被精确地执行一次,并且这些操作是线性化的。在后续实验中,你将实现类似的服务器以支持服务器崩溃的情况下进行复制。
客户端可以向键值服务器发送三种不同的 RPC 调用:Put(key, value)
、Append(key, arg)
和 Get(key)
。
服务器维护一个内存中的键值映射,键和值均为字符串:
Put(key, value)
用于安装或替换映射中某个键的值。Append(key, arg)
将 arg
追加到键的值之后,并返回旧值。Get(key)
获取键的当前值。如果键不存在,则返回空字符串。如果对一个不存在的键执行 Append
,行为与现有值为空字符串时相同。每个客户端通过一个 Clerk
与服务器交互,Clerk
提供了 Put
/Append
/Get
方法以管理与服务器的 RPC 通信。
你的服务器必须确保,客户端对 Clerk
方法(如 Get
/Put
/Append
)的调用是线性化的。
Clerk.Put()
,客户端 Y 调用了 Clerk.Append()
,随后客户端 X 的调用返回。在这种情况下,每个调用都必须观察到所有已完成调用对状态的影响。线性化非常方便,因为它模拟了一个按顺序处理请求的单服务器行为。例如,当一个客户端对更新请求得到成功响应后,其他客户端随后发起的读取请求必须能看到该更新的效果。对于单机服务器,提供线性化行为相对容易。
Key/value server with no network failures (easy)
你的第一个任务是实现一个在没有消息丢失的情况下可以正常工作的解决方案。
你需要完成以下内容:
- 在
client.go
中,为 Clerk 的Put
/Append
/Get
方法添加发送 RPC 请求的代码。- 在
server.go
中,编写 RPC 处理器,分别实现Put
、Append
和Get
的逻辑。当你通过测试套件中的前两个测试时,即“单个客户端”(
one client
)和“多个客户端”(many clients
),就说明你已经完成了这项任务。最后,请通过以下命令检查代码是否无竞态问题:
go test -race
这里的rpc服务器并不是基于tcp或者unix的,而是6.5840/labrpc库一个模拟的 RPC 服务器,其中包含服务注册和服务绑定。核心功能是通过反射机制动态注册方法,并使用一个自定义的模拟网络 (labrpc.Network
) 将客户端与服务器连接起来。
func (cfg *config) StartServer() {
cfg.kvserver = StartKVServer()
kvsvc := labrpc.MakeService(cfg.kvserver)
srv := labrpc.MakeServer()
srv.AddService(kvsvc)
cfg.net.AddServer(0, srv)
}
StartServer
是启动 RPC 服务器的入口:
StartKVServer
创建一个 KVServer
实例,这里假设它是实现 Put
、Append
和 Get
的具体服务逻辑。labrpc.MakeService
将 KVServer
包装为一个 Service
对象,反射分析出其中符合规范的方法,作为可供 RPC 调用的接口。labrpc.MakeServer
创建一个 Server
实例,负责维护已注册的服务。KVServer
服务注册到 Server
中 (srv.AddService(kvsvc)
),通过服务名称识别。Server
添加到模拟网络中 (cfg.net.AddServer(0, srv)
),绑定唯一标识符 0
。对于server.go,定义rpc处理函数
type KVServer struct {
mu sync.Mutex
kvs map[string]string
// Your definitions here.
}
func (kv *KVServer) Get(args *GetArgs, reply *GetReply) {
kv.mu.Lock()
defer kv.mu.Unlock()
reply.Value = kv.kvs[args.Key]
}
func (kv *KVServer) Put(args *PutAppendArgs, reply *PutAppendReply) {
kv.mu.Lock()
defer kv.mu.Unlock()
kv.kvs[args.Key] = args.Value
}
func (kv *KVServer) Append(args *PutAppendArgs, reply *PutAppendReply) {
kv.mu.Lock()
defer kv.mu.Unlock()
old := kv.kvs[args.Key]
kv.kvs[args.Key] += args.Value
reply.Value = old
}
func StartKVServer() *KVServer {
kv := new(KVServer)
kv.kvs = make(map[string]string)
return kv
}
对于client.go
func (ck *Clerk) Get(key string) string {
// You will have to modify this function.
args := &GetArgs{Key: key}
reply := &GetReply{}
ck.server.Call("KVServer.Get", args, reply)
return reply.Value
}
func (ck *Clerk) PutAppend(key string, value string, op string) string {
args := &PutAppendArgs{Key: key, Value: value}
reply := &PutAppendReply{}
ck.server.Call("KVServer."+op, args, reply)
return reply.Value
}
func (ck *Clerk) Put(key string, value string) {
ck.PutAppend(key, value, "Put")
}
// Append value to key's value and return that value
func (ck *Clerk) Append(key string, value string) string {
return ck.PutAppend(key, value, "Append")
}
Key/value server with dropped messages (easy)
当消息可能丢失时,需要对客户端的重试进行处理,同时保证服务端每个请求只执行一次。
- 如果
Clerk
发送的 RPC 请求超时 (Call()
返回false
),则客户端需要重试。- 客户端需要重复发送相同的请求,直到成功接收到服务器的回复。
- 由于客户端可能重发请求,服务端必须避免对同一请求重复执行。
- 服务端需要保存已处理的请求及其结果,避免重复执行。
- 服务端不能无限制地保存所有处理过的请求,需及时释放内存,也就是处理过的请求对应的客户端id需要及时删除。
之前好奇为啥有个nrand()生成随机数的方法,看来是生成唯一请求id的,而服务端还需要保存请求以及其对应的结果,那还是用map,key是请求id,value是对应结果,如果重复请求了直接查这个map。
至于删除客户端id操作逻辑可以设置为当客户端发送rpc请求得到正确结果时,会再发送一个rpc请求用来告诉server删除,在请求参数中用个type字段标识。
对于server.go,根据请求参数的type作不同处理,第一次处理请求并且成功时把结果写入缓存。
type KVServer struct {
mu sync.Mutex
kvs map[string]string
exist map[int64]string
}
func (kv *KVServer) Get(args *GetArgs, reply *GetReply) {
kv.mu.Lock()
defer kv.mu.Unlock()
if args.Type == Committed {
delete(kv.exist, args.ClientID)
return
}
if val, ok := kv.exist[args.ClientID]; ok {
//重复请求
reply.Value = val
return
}
reply.Value = kv.kvs[args.Key]
kv.exist[args.ClientID] = reply.Value
}
func (kv *KVServer) Put(args *PutAppendArgs, reply *PutAppendReply) {
kv.mu.Lock()
defer kv.mu.Unlock()
if args.Type == Committed {
delete(kv.exist, args.ClientID)
return
}
if _, ok := kv.exist[args.ClientID]; ok {
//重复请求
return
}
kv.kvs[args.Key] = args.Value
kv.exist[args.ClientID] = args.Value
}
func (kv *KVServer) Append(args *PutAppendArgs, reply *PutAppendReply) {
kv.mu.Lock()
defer kv.mu.Unlock()
if args.Type == Committed {
delete(kv.exist, args.ClientID)
return
}
if val, ok := kv.exist[args.ClientID]; ok {
//重复请求
reply.Value = val
return
}
old := kv.kvs[args.Key]
kv.kvs[args.Key] += args.Value
reply.Value = old
kv.exist[args.ClientID] = old
}
对于client.go,用for循环执行重复发送rpc请求
func (ck *Clerk) Get(key string) string {
clientID := nrand()
args := &GetArgs{Key: key, ClientID: clientID, Type: Pending}
reply := &GetReply{}
for !ck.server.Call("KVServer.Get", args, reply) {
}
args = &GetArgs{Key: key, ClientID: clientID, Type: Committed}
for !ck.server.Call("KVServer.Get", args, reply) {
}
return reply.Value
}
func (ck *Clerk) PutAppend(key string, value string, op string) string {
clientID := nrand()
args := &PutAppendArgs{Key: key, Value: value, ClientID: clientID, Type: Pending}
reply := &PutAppendReply{}
for !ck.server.Call("KVServer."+op, args, reply) {
}
args = &PutAppendArgs{Key: key, Value: value, ClientID: clientID, Type: Committed}
for !ck.server.Call("KVServer."+op, args, reply) {
}
return reply.Value
}
请求参数结构体加上Type int 和 ClientID int64就行
这个LAB的问题可以简化为如何保证消息消费的幂等性,LAB中用随机数生成器生成唯一id,并且在server端用map来记录,对应到传统后端的项目中,也有类似的思路,用一个有唯一索引列的数据库表来记录全局msgID,当消费者消费消息后,把msgID插入到数据库表中,如果有重复的消息请求,因为是唯一索引所以再次插入就会报错,就代表该消息已经被消费过。