MIT 6.5840 Lab2:KV Server

顺序:Lab1 MapReduce / Lab2 KV Server / Lab3 Raft / Lab4 KV over Raft / Lab5 Sharded KV

KV Server 请求处理流程
Lab2 的核心:Client 请求 → Server 去重检查 → 执行/返回缓存 → Client 确认清理。

一句话总结

Lab2 在单个服务器上实现一个 KV 存储,核心挑战是在不可靠网络下保证每个请求最多执行一次(at-most-once)。

从 Lab1 到 Lab2:解决什么新问题?

Lab1 的 MapReduce 是无状态计算——Worker 处理完就结束,结果写文件,没有”正在服务”的概念。Lab2 开始进入有状态服务的世界:Server 持续运行,维护 KV 数据,Client 随时来读写。新的挑战:网络可能丢消息,同一个请求可能被执行多次。

为什么要做这个

网络是不可靠的。Client 发了一个 Append("balance", "+100") 请求:

  • Server 执行了,回复丢了 → Client 重试 → 余额多加了 100!
  • 这就是”exactly-once”问题。

Lab2 教你如何用请求去重解决这个问题,这个模式会贯穿后面所有 Lab。

核心问题:重复请求

1
2
3
4
5
6
Client                    Server
│── Put("x", "1") ────→ │ ✓ 执行,x="1"
│ │
│←── OK ─────────────── │ ← 这个回复丢了!
│ │
│── Put("x", "1") ────→ │ ← Client 重试,Server 怎么知道这是重复的?

如果 Server 不做任何处理,第二次请求会被当作新请求执行。对于 Put 还好(幂等),但对于 Append 就会重复追加。

解决方案:去重表 + Finish 确认

每个请求带一个全局唯一的 ID。Server 维护一张表记录已处理的请求:

1
2
3
4
5
type KVServer struct {
mu sync.Mutex
data map[string]string // KV 存储
request map[int64]string // 去重表:requestId -> 上次返回值
}

处理流程(以 Append 为例):

1
2
3
4
5
6
7
8
9
10
11
12
func (kv *KVServer) Append(args *PutAppendArgs, reply *PutAppendReply) {
kv.mu.Lock()
defer kv.mu.Unlock()
if _, ok := kv.request[args.Id]; ok {
reply.Value = kv.request[args.Id] // 命中缓存,直接返回
return
}
oldValue := kv.data[args.Key]
kv.data[args.Key] = oldValue + args.Value
kv.request[args.Id] = oldValue // 缓存旧值作为返回值
reply.Value = oldValue
}

但是——内存会爆炸

去重表会无限增长!每个成功的请求都缓存着。解决方案是 Client 确认机制(Finish RPC)

1
2
3
4
5
6
7
8
9
10
11
12
// Client 端:收到成功响应后,显式告诉 Server "我收到了,你可以清缓存了"
func (ck *Clerk) PutAppend(key string, value string, op string) string {
args := PutAppendArgs{Key: key, Value: value, Id: atomic.AddInt64(&id, 1)}
reply := PutAppendReply{}
for !ck.server.Call("KVServer."+op, &args, &reply) {}

// 关键:通知 Server 删除缓存
Finishreply := PutAppendReply{}
for !ck.server.Call("KVServer.Finish", &args, &Finishreply) {}

return reply.Value
}
1
2
3
4
5
6
// Server 端:Finish 清除缓存
func (kv *KVServer) Finish(args *PutAppendArgs, reply *PutAppendReply) {
kv.mu.Lock()
defer kv.mu.Unlock()
delete(kv.request, args.Id)
}

三种操作的处理

操作 幂等? 需要去重? 说明
Get 读操作天然幂等,重复执行无影响
Put 否(但实际做了) 覆盖写,结果相同
Append 追加写,重复执行会多加一次

严格来说只有 Append 需要去重,但实现中对 Put/Append 统一去重简化了逻辑。

Client 的设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var id int64  // 全局原子递增

func (ck *Clerk) PutAppend(key string, value string, op string) string {
args := PutAppendArgs{
Key: key,
Value: value,
Id: atomic.AddInt64(&id, 1), // 全局唯一 ID
}
reply := PutAppendReply{}
for !ck.server.Call("KVServer."+op, &args, &reply) {
// 网络失败,带着相同 args(相同 ID)重试
}
// 确认收到,清除 Server 缓存
Finishreply := PutAppendReply{}
for !ck.server.Call("KVServer.Finish", &args, &Finishreply) {}
return reply.Value
}

关键点:

  • 全局 atomic 自增保证 ID 唯一
  • 同一个请求的所有重试使用相同的 ID,Server 识别重复
  • 成功后 Finish 清除缓存,防止内存无限增长

时序图:完整的请求生命周期

1
2
3
4
5
6
7
8
9
Client                         Server
│ │
│── PutAppend(id=42) ────────→ │ 查 request[42]:不存在
│ │ 执行 Append,缓存 request[42]=result
│←── OK(result) ──────────── │
│ │
│── Finish(id=42) ──────────→ │ delete(request[42])
│←── OK ─────────────────── │
│ │

如果第一步的回复丢了:

1
2
3
4
5
6
7
8
9
Client                         Server
│ │
│── PutAppend(id=42) ────────→ │ 执行,缓存 request[42]=result
│←── OK(result) ──── ✗ 丢了 │
│ │
│── PutAppend(id=42) ────────→ │ 查 request[42]:已存在!
│←── result(缓存值)──────── │ 不重复执行
│ │
│── Finish(id=42) ──────────→ │ 清除缓存

这个 Lab 建立的思维模式

  1. 不可靠网络是常态:任何 RPC 都可能丢失、延迟、重复
  2. 去重是幂等的基础:非幂等操作必须有去重机制
  3. Client 和 Server 要协作:只靠一方无法解决问题

去重机制的演进:Lab2 vs Lab4/5

Lab2 的去重方式(全局 ID + Finish 清理)和后面 Lab 的方式不同,理解这个演进很重要:

Lab2 Lab4/5
ID 方式 全局 atomic 自增 ClientId + 每 Client 单调递增 RequestId
内存管理 Finish RPC 显式清理 只保留每个 Client 的最大 RequestId,自动覆盖
为什么变了 单机可以靠 Finish 分布式下 Finish 也可能丢失,且需要过 Raft

Lab4/5 的方案更优雅:每个 Client 的 RequestId 单调递增,Server 只需记住 max(RequestId),小于它的请求都是重复的。不需要额外的 Finish 清理步骤,天然 bounded。

1
2
// Lab4/5 的去重:只记最大 ID,自动丢弃旧缓存
LastRequestMap[clientId] = max(LastRequestMap[clientId], requestId)