MIT 6.824 Lab 3 通关实录:从 Raft 到高可用 KV 存储
在完成了 MIT 6.824 Lab 2 的 Raft 共识算法后,Lab 3 的任务是将这个"仅仅能选主和复制日志"的库,真正应用到一个分布式的键值存储系统(Key-Value Service)中。
Lab 3 分为两个部分:**3A(KVServer 基本功能)**和 3B(日志压缩/快照) 。虽然代码量相比 Raft 核心逻辑少了一些,但在并发模型、线性一致性以及快照死锁处理上,坑点依然不少。
本文将复盘我的实现思路,并着重分享在 Lab 3B 中遇到的一个关于 InstallSnapshot 的"幽灵数据丢失"Bug 及其修复方案。
1. 整体架构:数据的流转
KVServer 的核心实际上是一个 复制状态机 (Replicated State Machine) 。所有的节点都运行着相同的代码,只要它们按相同的顺序执行相同的命令(Raft Log),它们的状态(Map)就一定是一致的。
数据流向大致如下: Client -> RPC Handler (Put/Get) -> Raft.Start() -> Raft Log -> ApplyCh -> KVServer Applier -> KVStore (Map)
核心结构体设计
为了在 Raft 层传输具体的操作,定义了 Op 结构体。这里利用了 Go 的 interface{} 特性,Raft 只负责搬运这个"黑盒",只有 KVServer 知道如何拆解它。
js
type Op struct {
Op string // "Get", "Put", "Append"
Key string
Value string
ClientId int64 // 用于去重
RequestId int64 // 用于去重
}
2. Lab 3A:线性一致性与幂等性
Lab 3A 的难点在于如何保证 线性一致性 (Linearizability) ,即每个操作看起来是原子且即时发生的。在分布式环境下,网络重传是家常便饭,如果一个 Append("x", "a") 请求因为网络超时被重发了两次,我们绝不能让状态机里出现两个 "a"。
解决方案:ClientId + RequestId
我在 KVServer 中维护了一个去重表:
js
clientRequests map[int64]int64 // Key: ClientId, Value: LastAppliedRequestId
在 applier 协程应用日志时,严格检查:
js
lastReqId, isSeen := kv.clientRequests[op.ClientId]
if !isSeen || op.RequestId > lastReqId { // 执行状态机更新 // 更新去重表 }
对于读操作(Get),虽然不需要更新状态机,但也必须走 Raft 流程,以防止读取到旧 Leader 的陈旧数据(Stale Read)。
异步转同步:Notification Channel
RPC Handler 是阻塞等待结果的,而 Raft 的提交是异步的。为了连接两者,我使用了一个 Map:
js
notifyChans map[int]chan OpResult // Key: Raft Log Index
-
RPC Handler 调用
rf.Start()拿到index -
创建一个 channel 放入
notifyChans[index]。 -
Handler
select监听这个 channel。 -
后台
applier协程执行完index处的日志后,将结果写入对应的 channel。
3. Lab 3B:快照与死锁陷阱
这是 Lab 3 最难的部分。当 Raft 日志无限增长时,节点重启回放日志会非常慢,且占用大量磁盘空间。我们需要引入快照(Snapshot)机制。
主动快照 vs 被动快照
- 主动快照 :KVServer 发现日志太大,序列化自己的 Map,调用
rf.Snapshot()。Raft 截断日志。 - 被动快照 :落后的 Follower 接收 Leader 发来的
InstallSnapshotRPC。
踩坑复盘:InstallSnapshot 的死锁与数据丢失
在最初的实现中,我遇到了一个诡异的 Bug:测试脚本报错 key 4 got wrong value,也就是数据丢失。
错误现场
我的原始逻辑是:Raft 收到 InstallSnapshot RPC 后,立即更新 commitIndex 并截断日志,然后发消息给 KVServer。 然而,KVServer 在收到快照消息后,会回调 rf.CondInstallSnapshot 询问 Raft 是否可以安装。
Bug 逻辑:
- RPC 层 :
InstallSnapshot把commitIndex更新到了 100。 - 回调层 :
CondInstallSnapshot检查if snapshotIndex(100) <= rf.commitIndex(100),判断快照过期(以为已经提交了),返回false。 - 结果 :KVServer 丢弃了快照数据。但 Raft 已经把对应的日志删了。数据永久丢失。
修复方案:两阶段提交
修复的核心在于解耦"接收"与"安装" 。
-
InstallSnapshot (RPC) :只负责接收数据,验证 Term,不修改 Raft 的 Log 和 CommitIndex。它只是把快照数据封装进
ApplyMsg发给applyCh。 -
CondInstallSnapshot (回调) :这是真正执行状态变更的地方。当 KVServer 准备好安装时调用它。
- 再次检查
snapshotIndex > commitIndex。 - 原子性地截断 Log。
- 原子性地更新
lastIncludedIndex,commitIndex,lastApplied。 - 持久化 State + Snapshot。
- 再次检查
js
func (rf *Raft) CondInstallSnapshot(snapshotTerm int, snapshotIndex int, snapshotData []byte) bool {
rf.mu.Lock()
defer rf.mu.Unlock()
// 防止回滚
if snapshotIndex <= rf.commitIndex {
return false
}
// 截断日志逻辑 (支持前缀截断优化)
if snapshotIndex > rf.getLastLogIndex() {
rf.log = make([]LogEntry, 1)
rf.log[0] = LogEntry{Term: snapshotTerm, Command: nil}
} else {
// ... 保留后续有效日志 ...
}
// 关键:在这里才更新 commitIndex
rf.commitIndex = max(rf.commitIndex, snapshotIndex)
rf.lastApplied = max(rf.lastApplied, snapshotIndex)
// 持久化
rf.persister.SaveStateAndSnapshot(rf.encodeRaftState(), snapshotData)
return true
}
这是一个为你定制的技术博客草稿。我将其设定为"回顾与总结"的风格,既包含了架构设计,也重点复盘了我们在对话中解决的那个关于快照的棘手 Bug。
你可以根据实际情况修改发布平台(如知乎、掘金、个人博客等)。
MIT 6.824 Lab 3 通关实录:从 Raft 到高可用 KV 存储
在完成了 MIT 6.824 Lab 2 的 Raft 共识算法后,Lab 3 的任务是将这个"仅仅能选主和复制日志"的库,真正应用到一个分布式的键值存储系统(Key-Value Service)中。
Lab 3 分为两个部分:**3A(KVServer 基本功能)**和 3B(日志压缩/快照) 。虽然代码量相比 Raft 核心逻辑少了一些,但在并发模型、线性一致性以及快照死锁处理上,坑点依然不少。
本文将复盘我的实现思路,并着重分享在 Lab 3B 中遇到的一个关于 InstallSnapshot 的"幽灵数据丢失"Bug 及其修复方案。
1. 整体架构:数据的流转
KVServer 的核心实际上是一个 复制状态机 (Replicated State Machine) 。所有的节点都运行着相同的代码,只要它们按相同的顺序执行相同的命令(Raft Log),它们的状态(Map)就一定是一致的。
数据流向大致如下: Client -> RPC Handler (Put/Get) -> Raft.Start() -> Raft Log -> ApplyCh -> KVServer Applier -> KVStore (Map)
核心结构体设计
为了在 Raft 层传输具体的操作,定义了 Op 结构体。这里利用了 Go 的 interface{} 特性,Raft 只负责搬运这个"黑盒",只有 KVServer 知道如何拆解它。
Go
go
type Op struct {
Op string // "Get", "Put", "Append"
Key string
Value string
ClientId int64 // 用于去重
RequestId int64 // 用于去重
}
2. Lab 3A:线性一致性与幂等性
Lab 3A 的难点在于如何保证 线性一致性 (Linearizability) ,即每个操作看起来是原子且即时发生的。在分布式环境下,网络重传是家常便饭,如果一个 Append("x", "a") 请求因为网络超时被重发了两次,我们绝不能让状态机里出现两个 "a"。
解决方案:ClientId + RequestId
我在 KVServer 中维护了一个去重表:
Go
go
clientRequests map[int64]int64 // Key: ClientId, Value: LastAppliedRequestId
在 applier 协程应用日志时,严格检查:
Go
go
lastReqId, isSeen := kv.clientRequests[op.ClientId]
if !isSeen || op.RequestId > lastReqId {
// 执行状态机更新
// 更新去重表
}
对于读操作(Get),虽然不需要更新状态机,但也必须走 Raft 流程,以防止读取到旧 Leader 的陈旧数据(Stale Read)。
异步转同步:Notification Channel
RPC Handler 是阻塞等待结果的,而 Raft 的提交是异步的。为了连接两者,我使用了一个 Map:
Go
go
notifyChans map[int]chan OpResult // Key: Raft Log Index
- RPC Handler 调用
rf.Start()拿到index。 - 创建一个 channel 放入
notifyChans[index]。 - Handler
select监听这个 channel。 - 后台
applier协程执行完index处的日志后,将结果写入对应的 channel。
3. Lab 3B:快照与死锁陷阱
这是 Lab 3 最难的部分。当 Raft 日志无限增长时,节点重启回放日志会非常慢,且占用大量磁盘空间。我们需要引入快照(Snapshot)机制。
主动快照 vs 被动快照
- 主动快照 :KVServer 发现日志太大,序列化自己的 Map,调用
rf.Snapshot()。Raft 截断日志。 - 被动快照 :落后的 Follower 接收 Leader 发来的
InstallSnapshotRPC。
踩坑复盘:InstallSnapshot 的死锁与数据丢失
在最初的实现中,我遇到了一个诡异的 Bug:测试脚本报错 key 4 got wrong value,也就是数据丢失。
错误现场
我的原始逻辑是:Raft 收到 InstallSnapshot RPC 后,立即更新 commitIndex 并截断日志,然后发消息给 KVServer。 然而,KVServer 在收到快照消息后,会回调 rf.CondInstallSnapshot 询问 Raft 是否可以安装。
Bug 逻辑:
- RPC 层 :
InstallSnapshot把commitIndex更新到了 100。 - 回调层 :
CondInstallSnapshot检查if snapshotIndex(100) <= rf.commitIndex(100),判断快照过期(以为已经提交了),返回false。 - 结果 :KVServer 丢弃了快照数据。但 Raft 已经把对应的日志删了。数据永久丢失。
修复方案:两阶段提交
修复的核心在于解耦"接收"与"安装" 。
-
InstallSnapshot (RPC) :只负责接收数据,验证 Term,不修改 Raft 的 Log 和 CommitIndex。它只是把快照数据封装进
ApplyMsg发给applyCh。 -
CondInstallSnapshot (回调) :这是真正执行状态变更的地方。当 KVServer 准备好安装时调用它。
- 再次检查
snapshotIndex > commitIndex。 - 原子性地截断 Log。
- 原子性地更新
lastIncludedIndex,commitIndex,lastApplied。 - 持久化 State + Snapshot。
- 再次检查
修复后的 CondInstallSnapshot 关键逻辑:
Go
go
func (rf *Raft) CondInstallSnapshot(snapshotTerm int, snapshotIndex int, snapshotData []byte) bool {
rf.mu.Lock()
defer rf.mu.Unlock()
// 防止回滚
if snapshotIndex <= rf.commitIndex {
return false
}
// 截断日志逻辑 (支持前缀截断优化)
if snapshotIndex > rf.getLastLogIndex() {
rf.log = make([]LogEntry, 1)
rf.log[0] = LogEntry{Term: snapshotTerm, Command: nil}
} else {
// ... 保留后续有效日志 ...
}
// 关键:在这里才更新 commitIndex
rf.commitIndex = max(rf.commitIndex, snapshotIndex)
rf.lastApplied = max(rf.lastApplied, snapshotIndex)
// 持久化
rf.persister.SaveStateAndSnapshot(rf.encodeRaftState(), snapshotData)
return true
}
4. 几个优雅的实现细节
1. 哨兵节点的妙用
在引入快照后,rf.log 的物理索引 0 不再对应逻辑索引 0。我保留了 rf.log[0] 作为哨兵 ,存储 lastIncludedIndex 的 Term。这使得 AppendEntries 的一致性检查逻辑(PrevLogTerm)不需要针对"刚做完快照"这种边界情况写丑陋的 if/else。
2. 避免内存泄漏
在截断日志时,我没有简单地使用切片截取(rf.log = rf.log[newStart:]),因为这会导致底层的大数组无法被 GC 回收。我使用了 make + copy 的方式:
Go
go
newLog := make([]LogEntry, len(rf.log)-relIndex)
copy(newLog, rf.log[relIndex:])
rf.log = newLog // 旧数组彻底断开引用,等待 GC
总结
MIT 6.824 Lab 3 不仅考验对 Raft 协议的理解,更考验对并发模型、通道通信以及死锁避免的实战能力。
- Lab 3A 教会了我们如何利用 Raft 构建强一致性服务(去重、通知机制)。
- Lab 3B 教会了我们如何在分布式系统中安全地管理状态压缩,以及处理分层架构中状态同步的微妙时序问题。
通关 Lab 3 后,一个完整的、容错的分布式 KV 存储雏形已经诞生。接下来,就是 Lab 4 ------ 将它分片 (Sharding) ,向大规模存储进发!