MIT 6.824 Lab 3 通关实录:从 Raft 到高可用 KV 存储

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 发来的 InstallSnapshot RPC。

踩坑复盘:InstallSnapshot 的死锁与数据丢失

在最初的实现中,我遇到了一个诡异的 Bug:测试脚本报错 key 4 got wrong value,也就是数据丢失

错误现场

我的原始逻辑是:Raft 收到 InstallSnapshot RPC 后,立即更新 commitIndex 并截断日志,然后发消息给 KVServer。 然而,KVServer 在收到快照消息后,会回调 rf.CondInstallSnapshot 询问 Raft 是否可以安装。

Bug 逻辑:

  1. RPC 层InstallSnapshotcommitIndex 更新到了 100。
  2. 回调层CondInstallSnapshot 检查 if snapshotIndex(100) <= rf.commitIndex(100),判断快照过期(以为已经提交了),返回 false
  3. 结果 :KVServer 丢弃了快照数据。但 Raft 已经把对应的日志删了。数据永久丢失。
修复方案:两阶段提交

修复的核心在于解耦"接收"与"安装"

  1. InstallSnapshot (RPC) :只负责接收数据,验证 Term,不修改 Raft 的 Log 和 CommitIndex。它只是把快照数据封装进 ApplyMsg 发给 applyCh

  2. 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
  1. RPC Handler 调用 rf.Start() 拿到 index
  2. 创建一个 channel 放入 notifyChans[index]
  3. Handler select 监听这个 channel。
  4. 后台 applier 协程执行完 index 处的日志后,将结果写入对应的 channel。

3. Lab 3B:快照与死锁陷阱

这是 Lab 3 最难的部分。当 Raft 日志无限增长时,节点重启回放日志会非常慢,且占用大量磁盘空间。我们需要引入快照(Snapshot)机制。

主动快照 vs 被动快照

  • 主动快照 :KVServer 发现日志太大,序列化自己的 Map,调用 rf.Snapshot()。Raft 截断日志。
  • 被动快照 :落后的 Follower 接收 Leader 发来的 InstallSnapshot RPC。

踩坑复盘:InstallSnapshot 的死锁与数据丢失

在最初的实现中,我遇到了一个诡异的 Bug:测试脚本报错 key 4 got wrong value,也就是数据丢失

错误现场

我的原始逻辑是:Raft 收到 InstallSnapshot RPC 后,立即更新 commitIndex 并截断日志,然后发消息给 KVServer。 然而,KVServer 在收到快照消息后,会回调 rf.CondInstallSnapshot 询问 Raft 是否可以安装。

Bug 逻辑:

  1. RPC 层InstallSnapshotcommitIndex 更新到了 100。
  2. 回调层CondInstallSnapshot 检查 if snapshotIndex(100) <= rf.commitIndex(100),判断快照过期(以为已经提交了),返回 false
  3. 结果 :KVServer 丢弃了快照数据。但 Raft 已经把对应的日志删了。数据永久丢失。
修复方案:两阶段提交

修复的核心在于解耦"接收"与"安装"

  1. InstallSnapshot (RPC) :只负责接收数据,验证 Term,不修改 Raft 的 Log 和 CommitIndex。它只是把快照数据封装进 ApplyMsg 发给 applyCh

  2. 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) ,向大规模存储进发!

相关推荐
codetown1 小时前
openai-go通过SOCKS5代理调用外网大模型
人工智能·后端
q***33372 小时前
Spring Boot项目接收前端参数的11种方式
前端·spring boot·后端
q***42822 小时前
SpringBoot Maven快速上手
spring boot·后端·maven
Victor3562 小时前
Redis(153)Redis的网络使用如何监控?
后端
码一行3 小时前
Eino AI 实战:解析 PDF 文件 & 实现 MCP Server
后端·go
Victor3563 小时前
Redis(152) Redis的CPU使用如何监控?
后端
P***84393 小时前
解决Spring Boot中Druid连接池“discard long time none received connection“警告
spring boot·后端·oracle
雨中散步撒哈拉3 小时前
17、做中学 | 初三下期 Golang文件操作
开发语言·后端·golang
倚肆3 小时前
Spring Boot CORS 配置详解:CorsConfigurationSource 全面指南
java·spring boot·后端