编号对照:现行课程已改名 6.5840 并重排了 lab——Lab 1 MapReduce、Lab 2 Key/Value server、Lab 3 Raft(3A/3B/3C/3D)、Lab 4 KV Raft、Lab 5 Sharded KV。本文的 Lab3(基于 Raft 的 KV 服务)对应现行的 Lab 4,本文里的 Lab2 则是现行的 Lab 3。正文中的 TestSpeed3A 等测试名沿用旧编号。

Task

在 raft 框架的基础上建立一个容错的 KV 数据库。重点在于理解 Service 层和 Raft 层的交互,代码方面较为简单,故不进行赘述。

Step

  1. 在 client 和 service 两层完成 Get(), Append(), Put()
  2. 对重复的 Append 或 Put 进行去重。这里有两个点不能省:
    • 去重的键必须是 clientId + seqNum。每个 Clerk 启动时生成一个唯一的 clientId,每次新请求 seqNum++,重试时沿用同一个 seqNum。只看 key 或只看 seqNum 都区分不出「同一个客户端的重试」和「不同客户端的两次写」。
    • service 层维护一张去重表:每个 clientId 已执行的最大 seqNum 以及那次的执行结果。apply 时 seqNum <= 表中记录 就直接返回缓存结果,不再动状态机。
  3. service 层的快照与持久化。快照里除了 Key/Value 数据,去重表也必须一起写进去——重启后只恢复 KV 不恢复去重表的话,快照点之后重放的那些请求会被当成全新请求,重复 apply 一遍,Append 直接把值追加两次。
  4. Get 也必须走 Raft(Start() 之后等 apply)。直接在本地读会读到过期数据:一个已经被网络分区隔离、自己还不知道已经不是 Leader 的节点,本地状态机可能落后好几条日志,这样的读破坏线性一致性。
客户端通过 Clerk 调用三个 Service 实例,中间 Service 连接 Raft leader,三个 Raft 节点相互复制的 Lab 3 架构
MIT 6.824 Lab 3 的客户端、Service 与 Raft 分层。

tips:

值得一提的是在写完 lab3 的大概框架后,TestSpeed3A 一直不通过,因为要求是平均 33ms 一个 commit,但正常来说 commit 时间应该和 heartbeat timeout 差不多,遂修改 Start() 函数,在 command 被 Service 送来的时候发起一次心跳,进行日志同步。

func (rf *Raft) Start(command interface{}) (int, int, bool) {
	rf.mu.Lock()
	defer rf.mu.Unlock()
	if rf.state != Leader {
		return NULL, NULL, false
	}
	index, term := rf.getLastLogL().Index+1, rf.currentTerm
	rf.log = append(rf.log, Entry{
		Command: command,
		Term:    term,
		Index:   index,
	})
	DPrintf(dInfo, "S%v <- %vst command %v", rf.me, index, command)
	rf.persist()
	rf.startHeartbeatL(false)
	rf.heartbeatTimer.Reset(getHeartbeatDuration())
	return index, term, true
}

我们还可以进行一点优化:当 Leader 发送心跳时,加上一个 bool,表明 Leader 真的只是发送心跳,或者是为了快速同步日志,如果是后者,还需要判断每一个 Follower 的 nextIndex 是否需要同步,如果不满足 (rf.getLastLogL().Index > rf.nextIndex[i]),则不再同步日志。

正常来说到这一步就没问题了,但是我又发现了 lab2 的一个遗留 bug,当 commit 频率变得很快时 Leader 就会暴毙(之前的测试仅仅通过 heartbeat timeout 同步日志,频率较低,基本没有出现这个问题)。通过打 log,发现 commit 是通过单独的 go routine 接收 channel 的信号实现的,因为 channel 初始化为make(chan int,1),在 Leader 连续 commit 时容易堵塞,最后将 channel 扩容成 100 就过了。

2026-08 更新:这不是「玄学 bug」,扩容成 100 也只是把死锁概率压低的权宜手段,随时可能复现。

根因是持锁向 channel 发送:唤醒 applier 的 rf.applyWaker <- 1 是在拿着 rf.mu 的时候执行的,channel 一满就地阻塞,而唯一的消费者 applier 又必须拿到 rf.mu 才能继续消费——两边互等,自死锁。这也正好解释了为什么低频 commit 时看不到:channel 很少填满。缓冲区从 1 改到 100 只是把「填满」这件事变罕见了,并没有消除环。

正确的修法是换成 sync.CondSignal 永不阻塞),或者退一步写成 select { case rf.applyWaker <- 1: default: } 让唤醒信号可丢弃(反正 applier 醒来后会重新读 commitIndex,漏一次信号不影响正确性)。细节见 lab2B