边界、依赖与验收
在不可靠网络上实现客户端重试与每次操作恰好一次语义。
发布 Lab 2;从 RPC 失败进入重试与去重
Paxos 为后续复制服务建立共识直觉
Key/Value Server,23:59
目标:单机线性一致 KV 与至多一次 Put
本页可离线完成Handout 完整本土化
本实验构建一个单机 key/value server:即使网络故障,也要保证每次 Put 至多执行一次,并让操作满足线性一致性。随后用这个 KV server 实现锁。后续实验会复制类似的服务器以处理服务器崩溃。
客户端通过 Clerk 库发送两类 RPC:Put(key,value,version) 和 Get(key)。服务器在内存 map 中为每个字符串 key 保存 (value,version),value 也是字符串,version 表示该 key 已成功写入的次数。
完成全部测试后,从 Clerk.Get/Clerk.Put 调用者视角看服务应线性一致:非并发操作按真实先后生效;重叠操作的结果和最终状态必须等价于某种逐个串行执行顺序;新操作必须观察所有在它开始前已经完成的操作。
Put/Get 的完整版本语义
本页可离线完成Handout 完整本土化
Put(key,value,version) 只在请求 version 等于服务器当前 version 时安装或替换 value;成功后服务器把该 key 的 version 加一。若版本不匹配,返回 rpc.ErrVersion。客户端用 version 0 创建新 key,成功后服务器保存 version 1。若 Put 的 version 大于 0 而 key 不存在,返回 rpc.ErrNoKey。
Get(key) 返回当前 value 与 version;key 不存在时返回 rpc.ErrNoKey。
每 key version 既支持条件更新/锁,也让相同版本的 Put 重传不能执行两次。所有对 map 和 value/version 元组的检查与修改必须作为一个临界区;若两个 Put 同时匹配同一旧 version,最多一个成功。
代码位置、更新与初始测试
本页可离线完成Handout 完整本土化
骨架和测试位于 src/kvsrv1。kvsrv1/client.go 实现 Clerk 的 Put/Get 与 RPC 交互;kvsrv1/server.go 包含服务器和两个 RPC handler。你需要修改这两个文件。请求、回复与错误值定义在 kvsrv1/rpc/rpc.go,应阅读但不必修改。
获取最新代码并运行:
cd ~/6.5840
git pull
cd src
make kvsrv1初始实现会在 TestReliablePut 中得到 Put err ErrNoKey,这是预期基线失败。测试使用 -race,任何数据竞争都必须修复。
第一阶段:可靠网络上的 KV
本页可离线完成Handout 完整本土化
先假设消息不丢。在 client.go 的 Clerk Put/Get 中加入发送 RPC 的代码,在 server.go 实现 handler。完成标准是通过 Reliable 测试:
cd ~/6.5840/src
make RUN="-run Reliable" kvsrv1测试包括单客户端 Put、多客户端竞争同一 key,以及大量客户端下的内存使用。输出中的统计依次是实际秒数、常数 1、发送 RPC 数(含客户端 RPC)和执行的 Clerk Get/Put 操作数。
大量并发测试要求服务器 map 受同步保护,又不能按客户端无限保存不必要状态。本阶段只需版本条件语义,不需要去重表。
第二阶段:用条件 Put 实现锁
本页可离线完成Handout 完整本土化
分布式应用可用 KV server 协调,ZooKeeper/Etcd 也可通过 conditional put 构造锁。你要在 src/kvsrv1/lock/lock.go 实现 Acquire 与 Release,通过 lk.ck.Put()/lk.ck.Get() 把每个锁的状态存入 KV。
系统可有多个相互独立的锁,名字由 MakeLock 参数给出。同一锁同一时刻最多一个客户端成功 Acquire,其他客户端必须等待持有者 Release。每个锁客户端需要唯一标识,使用 kvtest.RandValue(8) 生成随机字符串。
客户端持锁时若崩溃,本实验的锁永远不会释放;更复杂设计会给锁附 lease,到期由服务代为释放。本实验客户端不崩溃,可忽略此问题。
可靠网络测试:
cd ~/6.5840/src
make RUN="-run Reliable" lock1它覆盖单次 Acquire/Release、一个客户端的两个锁、单客户端循环和十客户端竞争。未实现锁时前两个测试可能仍成功,因此必须以完整 Reliable 集为准。
第三阶段:丢请求、丢回复与 ErrMaybe
本页可离线完成Handout 完整本土化
不可靠网络会重排、延迟或丢弃 RPC 请求/回复。Clerk 必须反复发送同一 RPC 直到收到回复。ck.clnt.Call() 返回 true 表示收到 RPC reply;false 表示在超时内没有 reply。每次重试前稍等,可使用:
time.Sleep(100 * time.Millisecond)丢请求时重发只执行重发副本。丢回复更棘手:首次请求可能已执行,重发到达后同 version Put 得到 rpc.ErrVersion。由于条件版本,服务器不会执行第二次,所以 Put 是 at-most-once。
若 Clerk 的首次、未重传 Put 收到 ErrVersion,可确定它未执行,应向应用返回 ErrVersion。若已经重传后才收到 ErrVersion,Clerk 无法知道:可能首次已成功但成功回复丢失,也可能别的 Clerk 先更新导致本 Clerk 两次都没执行。因此必须返回 rpc.ErrMaybe。
exactly-once 若不暴露 ErrMaybe,需要服务器为每个 Clerk 保存请求/结果状态;本阶段刻意不这样做。修改应只在 kvsrv1/client.go,不应要求服务器变化。
完成标准:
make kvsrv1除 Reliable 测试外还必须通过 TestUnreliableNet。
第四阶段:让锁正确处理 ErrMaybe
本页可离线完成Handout 完整本土化
修改锁实现,使它在不可靠网络和新版 Clerk 上仍正确。完成标准是全部 lock1 测试:
make lock1完整测试包括可靠网络的四组测试,以及 TestOneClientUnreliable 与 TestManyClientsUnreliable。不可靠测试中 RPC 数可能高于逻辑操作数,这是重试的正常结果。
ErrMaybe 不能简单视为失败并重新申请,也不能一律视为成功。应用可通过 Get 读取锁当前 owner 来消解:如果状态显示自己的唯一 ID,原 Put 生效;显示他人或其他版本时按协议继续。Release 同样要避免在不确定结果后破坏新 owner。
本实验的重点是体验 at-most-once API 如何把不确定性推给应用;你需要设计能用唯一 client ID 自证所有权的锁状态,而不是依赖某次 RPC 回复必达。
最终验收与资源说明
本页可离线完成Handout 完整本土化
提交前分别运行:
cd ~/6.5840/src
make kvsrv1
make lock1所有命令都应在 -race 下通过。KV 完整测试覆盖可靠与不可靠网络;锁完整测试覆盖一个和多个客户端。不要只运行 Reliable 子集。
本实验不处理服务器崩溃;服务器内存 map 丢失后状态与版本都会丢失。Lab 3/4 会用 Raft 复制状态机和快照解决这一层。
完整官方 Handout 与配套资料
以下是本讲对应官方材料的可搜索离线文本。中文精读负责解释;资料附录保留原始细节、例子、问答与代码,不以摘要替代原文。
网页讲义labs/lab-kvsrv1.html372 行 · 2,165 词 · 完整收录
6.5840 Lab 2: Key/Value Server
6.5840 - Spring 2026
6.5840 Lab 2: Key/Value Server
Collaboration policy //
Submit lab //
Setup Go //
Guidance //
Piazza
Introduction
In this lab you will build a key/value server for a single machine
that ensures that each Put operation is executed at-most-once
despite network failures and that the operations
are linearizable. You will use this KV server to implement a
lock. Later labs will replicate a server like this one to handle
server crashes.
KV server
Each client interacts with the key/value server using a Clerk,
a set of library routines which sends RPCs to the server.
Clients can send two different RPCs to the server:
Put(key, value, version) and Get(key). The server
maintains an in-memory map that records for each key a (value,
version) tuple. Keys and values are strings. The version number
records the number of times the key has been written.
Put(key, value, version) installs or replaces the value for a
particular key in the map only if the Put's version number
matches the server's version number for the key. If the version
numbers match, the server also increments the version number of the
key. If the version numbers don't match, the server should
return rpc.ErrVersion. A client can create a new key by invoking Put with
version number 0 (and the resulting version stored by the server
will be 1). If the version number of the Put is larger than 0
and the key doesn't exist, the server should return rpc.ErrNoKey.
Get(key) fetches the current value for the key and its associated
version. If the key doesn't exist at the server, the server should
return rpc.ErrNoKey.
Maintaining a version number for each key will be useful for
implementing locks using Put and ensuring
at-most-once semantics for Put's when the network is
unreliable and the client retransmits.
When you've finished this lab and passed all the tests, you'll
have a linearizable key/value service from the point
of view of clients calling
Clerk.Get and Clerk.Put.
That is,
if client operations aren't concurrent, each
client Clerk.Get and Clerk.Put will observe the
modifications to the state implied by the preceding sequence of
operations. For concurrent operations, the return values and final state will be
the same as if the operations had executed one at a time in some
order. Operations are concurrent if they overlap in time: for example, if
client X calls Clerk.Put(), and client Y
calls Clerk.Put(), and then client X's call returns. An operation
must observe the effects of all operations that have completed before the
operation starts. See the FAQ
on linearizability for
more background.
Linearizability is convenient for applications because it's the
behavior you'd see from a single server that processes requests one at
a time. For example, if one client gets a successful response from the
server for an update request, subsequently launched reads from other
clients are guaranteed to see the effects of that update. Providing
linearizability is relatively easy for a single server.
Getting Started
We supply you with skeleton code and tests in src/kvsrv1.
kvsrv1/client.go implements a Clerk that clients use
to manage RPC interactions with the server; the Clerk provides
Put and Get methods.
kvsrv1/server.go contains the server code,
including the Put and Get handlers that
implement the server side of RPC requests.
You will need to modify client.go and server.go.
The RPC requests, replies, and
error values are defined in
the kvsrv1/rpc package in the file kvsrv1/rpc/rpc.go,
which you should look at, though
you don't have to modify rpc.go.
To get up and running, execute the following commands.
Don't forget the git pull to get the latest software.
$ cd ~/6.5840
$ git pull
...
$ cd src
$ make kvsrv1
go build -race -o main/kvsrv1d main/kvsrv1d.go
cd kvsrv1 && go test -v -race
=== RUN TestReliablePut
One client and reliable Put (reliable network)...
kvsrv_test.go:25: Put err ErrNoKey
--- FAIL: TestReliablePut (0.31s)
...
$
Key/value server with reliable network
Your first task is to implement a solution that works when there are
no dropped messages.
You'll need to add RPC-sending code to the Clerk Put/Get
methods in client.go, and implement
Put and Get RPC handlers in
server.go.
You have completed this task when you
pass the Reliable tests in the
test suite:
$ cd src
$ make RUN="-run Reliable" kvsrv1
go build -race -o main/kvsrv1d main/kvsrv1d.go
cd kvsrv1 && go test -v -race -run Reliable
=== RUN TestReliablePut
One client and reliable Put (reliable network)...
... Passed -- time 0.0s #peers 1 #RPCs 5 #Ops 5
--- PASS: TestReliablePut (0.12s)
=== RUN TestPutConcurrentReliable
Test: many clients racing to put values to the same key (reliable network)...
... Passed -- time 6.3s #peers 1 #RPCs 11025 #Ops 22050
--- PASS: TestPutConcurrentReliable (6.36s)
=== RUN TestMemPutManyClientsReliable
Test: memory use many put clients (reliable network)...
... Passed -- time 29.0s #peers 1 #RPCs 50000 #Ops 50000
--- PASS: TestMemPutManyClientsReliable (52.91s)
PASS
ok 6.5840/kvsrv1 60.732s
$
The numbers after each Passed are real time in seconds,
the constant 1,
the number of RPCs sent (including client RPCs), and the number of key/value
operations executed (Clerk Get and Put calls).
Implementing a lock using key/value clerk
In many distributed applications, clients running on different
machines use a key/value server to coordinate their activities. For
example, ZooKeeper and Etcd allow clients to coordinate using a
distributed lock, in analogy with how threads in a Go program can
coordinate with locks (i.e., sync.Mutex). Zookeeper and Etcd
implement such a lock with conditional put.
Your task is to implement locks,
using your key/value server to store whatever per-lock
state your design needs.
There can be multiple independent locks, each with its own
name, passed as an argument to MakeLock.
A lock supports two
methods: Acquire and Release. The specification is that
only one client can successfully acquire a given lock at a time;
other clients
must wait until the first client has released the lock
using Release.
We supply you with skeleton code and tests
in src/kvsrv1/lock/. You will need to
modify src/kvsrv1/lock/lock.go.
Your Acquire and Release should
store each lock's state in your key/value server,
by calling lk.ck.Put() and lk.ck.Get().
If a client crashes while holding a lock,
the lock will never be released.
In a design more sophisticated than this lab,
the client would attach a
lease
to a lock. When the lease expires, the lock server would release the lock on
behalf of the client. In this lab clients don't crash and you
can ignore this problem.
Implement Acquire and Release.
You have completed this exercise when your
code passes these tests:
$ cd src
$ make RUN="-run Reliable" lock1
go build -race -o main/kvsrv1d main/kvsrv1d.go
cd kvsrv1/lock; go test -v -race -run Reliable
=== RUN TestReliableBasic
Test: a single Acquire and Release (reliable network)...
... Passed -- time 0.0s #peers 1 #RPCs 4 #Ops 4
--- PASS: TestReliableBasic (0.13s)
=== RUN TestReliableNested
Test: one client, two locks (reliable network)...
... Passed -- time 0.1s #peers 1 #RPCs 17 #Ops 17
--- PASS: TestReliableNested (0.17s)
=== RUN TestOneClientReliable
Test: 1 lock clients (reliable network)...
... Passed -- time 2.0s #peers 1 #RPCs 477 #Ops 477
--- PASS: TestOneClientReliable (2.14s)
=== RUN TestManyClientsReliable
Test: 10 lock clients (reliable network)...
... Passed -- time 2.2s #peers 1 #RPCs 5704 #Ops 5704
--- PASS: TestManyClientsReliable (2.36s)
PASS
ok 6.5840/kvsrv1/lock 5.817s
$
If you haven't implemented the lock yet, the first two
tests will succeed.
This exercise requires little code but a bit more
independent thought than the previous exercise.
You will need a unique identifier for each lock client;
call kvtest.RandValue(8) to generate a random string.
Key/value server with dropped messages
The main challenge in this exercise is that the network may re-order,
delay, or discard RPC requests and/or replies. To recover from
discarded requests/replies, the Clerk must keep re-trying each RPC
until it receives a reply from the server.
If the network discards an RPC request message, then the
client re-sending
the request will solve the problem: the server will receive and
execute just the re-sent request.
However, the network might instead discard an RPC reply message.
The client does not know which message was discarded; the client only
observes that it received no reply.
If it was the reply that was discarded, and the client re-sends
the RPC request, then the server will receive two copies of
the request.
That's OK for a Get,
since Get doesn't modify the server state.
It is safe to resend a Put RPC with the
same version number, since the server executes Put
conditionally on the version number; if the server received and
executed a
Put RPC, it will respond to a re-transmitted
copy of that RPC with rpc.ErrVersion rather than
executing the Put a second time.
A tricky case is if the server replies with
an rpc.ErrVersion in a response to an RPC that the Clerk
retried. In this case, the Clerk cannot know if the
Clerk's Put was executed by the server or not: the first
RPC might have been executed by the server but the network may have
discarded the successful response from the server, so that the server
sent rpc.ErrVersion only for the retransmitted RPC.
Or, it might be that another
Clerk updated the key before the Clerk's first RPC arrived at the server,
so that the server executed neither of the Clerk's RPCs and replied
rpc.ErrVersion to both.
Therefore, if a Clerk receives rpc.ErrVersion for a
retransmitted Put RPC,
Clerk.Put must return rpc.ErrMaybe to the
application instead of rpc.ErrVersion since the request may
have been executed. It is then up to the application to handle this
case.
If the server responds to an initial (not retransmitted)
Put RPC with rpc.ErrVersion, then the Clerk
should return rpc.ErrVersion to the application, since the RPC
was definitely not executed by the server.
It would be more convenient for application developers if Put's were
exactly-once (i.e., no rpc.ErrMaybe errors) but that is
difficult to guarantee without maintaining state at the server for
each Clerk.
In the last exercise of this lab, you will
implement a lock using your Clerk to explore how to
program with at-most-once Clerk.Put.
Now you should modify your kvsrv1/client.go
to continue in the face of dropped
RPC requests and replies.
A return value of true from
the client's ck.clnt.Call()
indicates that the client
received an RPC reply from the
server; a return value of false indicates that it
did not receive a reply (more precisely, Call() waits
for a reply message for a timeout interval, and returns false
if no reply arrives within that time).
Your
Clerk should keep re-sending an RPC until it receives
a reply.
Keep in mind the discussion of rpc.ErrMaybe above.
Your solution shouldn't require
any changes to the server.
Add code to Clerk to retry if doesn't receive a reply.
Your have completed this task if your code passes all the tests
for kvsrv1:
$ make kvsrv1
go build -race -o main/kvsrv1d main/kvsrv1d.go
cd kvsrv1 && go test -v -race
=== RUN TestReliablePut
One client and reliable Put (reliable network)...
... Passed -- time 0.0s #peers 1 #RPCs 5 #Ops 5
--- PASS: TestReliablePut (0.12s)
=== RUN TestPutConcurrentReliable
Test: many clients racing to put values to the same key (reliable network)...
... Passed -- time 6.4s #peers 1 #RPCs 11021 #Ops 22042
--- PASS: TestPutConcurrentReliable (6.52s)
=== RUN TestMemPutManyClientsReliable
Test: memory use many put clients (reliable network)...
... Passed -- time 28.8s #peers 1 #RPCs 50000 #Ops 50000
--- PASS: TestMemPutManyClientsReliable (52.44s)
=== RUN TestUnreliableNet
One client (unreliable network)...
... Passed -- time 4.0s #peers 1 #RPCs 268 #Ops 422
--- PASS: TestUnreliableNet (4.13s)
PASS
ok 6.5840/kvsrv1 64.442s
$
Before the client retries, it should wait a little bit; you can
use go's time package and call time.Sleep(100 *
time.Millisecond)
Implementing a lock using key/value clerk and unreliable
network
Modify your lock implementation to
work correctly with your modified key/value client
when the network is not reliable.
You
have completed this exercise when your code passes all the
lock1 tests:
$ make lock1
go build -race -o main/kvsrv1d main/kvsrv1d.go
cd kvsrv1/lock; go test -v -race
=== RUN TestReliableBasic
Test: a single Acquire and Release (reliable network)...
... Passed -- time 0.0s #peers 1 #RPCs 4 #Ops 4
--- PASS: TestReliableBasic (0.13s)
=== RUN TestReliableNested
Test: one client, two locks (reliable network)...
... Passed -- time 0.1s #peers 1 #RPCs 17 #Ops 17
--- PASS: TestReliableNested (0.17s)
=== RUN TestOneClientReliable
Test: 1 lock clients (reliable network)...
... Passed -- time 2.0s #peers 1 #RPCs 471 #Ops 471
--- PASS: TestOneClientReliable (2.13s)
=== RUN TestManyClientsReliable
Test: 10 lock clients (reliable network)...
... Passed -- time 2.2s #peers 1 #RPCs 5658 #Ops 5658
--- PASS: TestManyClientsReliable (2.35s)
=== RUN TestOneClientUnreliable
Test: 1 lock clients (unreliable network)...
... Passed -- time 2.1s #peers 1 #RPCs 66 #Ops 57
--- PASS: TestOneClientUnreliable (2.18s)
=== RUN TestManyClientsUnreliable
Test: 10 lock clients (unreliable network)...
... Passed -- time 4.1s #peers 1 #RPCs 778 #Ops 617
--- PASS: TestManyClientsUnreliable (4.23s)
PASS
ok 6.5840/kvsrv1/lock 12.227s
$