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

相关推荐
Victor3564 小时前
Netty(20)如何实现基于Netty的WebSocket服务器?
后端
缘不易4 小时前
Springboot 整合JustAuth实现gitee授权登录
spring boot·后端·gitee
Kiri霧4 小时前
Range循环和切片
前端·后端·学习·golang
WizLC4 小时前
【Java】各种IO流知识详解
java·开发语言·后端·spring·intellij idea
Victor3564 小时前
Netty(19)Netty的性能优化手段有哪些?
后端
爬山算法4 小时前
Netty(15)Netty的线程模型是什么?它有哪些线程池类型?
java·后端
白宇横流学长5 小时前
基于SpringBoot实现的冬奥会科普平台设计与实现【源码+文档】
java·spring boot·后端
Python编程学习圈6 小时前
Asciinema - 终端日志记录神器,开发者的福音
后端
bing.shao6 小时前
Golang 高并发秒杀系统踩坑
开发语言·后端·golang
壹方秘境6 小时前
一款方便Java开发者在IDEA中抓包分析调试接口的插件
后端