FXJ Wiki

Back

1. 为什么需要持久化#

分布式系统里,节点随时可能崩溃重启。如果状态只存在内存里,重启后就全丢了。

Raft 需要持久化的状态(来自论文 Figure 2):

  • currentTerm:当前任期号
  • votedFor:本任期投票给了谁
  • log[]:日志条目

这三个字段必须在每次修改后立即写入磁盘。如果不持久化,节点重启后可能:

  • 忘记自己投过票,导致同一任期投两次票(破坏安全性)
  • 丢失已提交的日志条目

2. Lab 里的持久化接口#

Lab 提供了 Persister 对象,你不需要直接操作文件,而是通过它来保存和读取状态:

// 保存状态
rf.persister.SaveRaftState(data []byte)

// 读取状态
data := rf.persister.ReadRaftState()
go

你需要把 Raft 的状态序列化成 []byte,然后保存。


3. 用 labgob 序列化#

Lab 提供了 labgob 包来做序列化(类似 JSON,但更高效):

import "../labgob"

func (rf *Raft) persist() {
    w := new(bytes.Buffer)
    e := labgob.NewEncoder(w)
    e.Encode(rf.currentTerm)
    e.Encode(rf.votedFor)
    e.Encode(rf.log)
    data := w.Bytes()
    rf.persister.SaveRaftState(data)
}

func (rf *Raft) readPersist(data []byte) {
    if data == nil || len(data) < 1 {
        return  // 第一次启动,没有持久化数据
    }
    r := bytes.NewBuffer(data)
    d := labgob.NewDecoder(r)
    var currentTerm int
    var votedFor int
    var log []LogEntry
    if d.Decode(&currentTerm) != nil ||
       d.Decode(&votedFor) != nil ||
       d.Decode(&log) != nil {
        panic("readPersist: decode error")
    }
    rf.currentTerm = currentTerm
    rf.votedFor = votedFor
    rf.log = log
}
go

注意EncodeDecode 的顺序必须完全一致。


4. 什么时候调用 persist()#

每次修改需要持久化的字段后,立即调用 persist()

// 更新 currentTerm 时
rf.currentTerm = newTerm
rf.votedFor = -1
rf.persist()  // 立即持久化

// 追加日志时
rf.log = append(rf.log, entry)
rf.persist()  // 立即持久化
go

常见错误:修改了字段,但忘记调用 persist()。这会导致节点重启后状态不一致,测试偶发失败。


5. 原子写入:为什么要用临时文件#

在 Lab 1 里,Worker 写输出文件时要用原子写入:

// 错误:直接写目标文件
f, _ := os.Create("mr-out-0")
// 如果这里崩溃了,文件是损坏的

// 正确:先写临时文件,再原子重命名
tmpFile, _ := os.CreateTemp("", "mr-tmp-*")
// 写入数据...
tmpFile.Close()
os.Rename(tmpFile.Name(), "mr-out-0")
// Rename 是原子操作,要么成功要么失败,不会有中间状态
go

为什么 Rename 是原子的:在 Unix 系统上,rename 系统调用是原子的。如果进程在 rename 之前崩溃,临时文件存在但目标文件不存在;如果在 rename 之后崩溃,目标文件已经是完整的。不会出现”写了一半”的情况。


6. 快照(Snapshot)#

当 Raft 日志越来越长,持久化和恢复会越来越慢。快照是解决这个问题的方法:

  1. 把当前状态机的状态保存成一个快照
  2. 删除快照之前的所有日志条目
  3. 重启时,先恢复快照,再重放快照之后的日志

Lab 3B 里的快照接口

// 上层应用(KV 服务器)触发快照
rf.Snapshot(index int, snapshot []byte)

// Raft 保存快照和状态
rf.persister.SaveStateAndSnapshot(raftState, snapshot)
go

快速检验#

  1. Raft 需要持久化哪三个字段?为什么这三个字段必须持久化?
  2. 为什么写文件要用”先写临时文件,再 rename”的方式?
  3. 如果 persist() 调用太频繁,会有什么性能问题?(提示:每次写磁盘都有开销)
参考答案

1. currentTerm(防止重启后用旧 term 参与选举,破坏安全性)、votedFor(防止同一 term 投两票,导致双 Leader)、log(防止已提交的日志丢失,状态机无法恢复)。commitIndexlastApplied 不需要持久化,重启后从日志重建即可。

2. 防止读到写了一半的文件。直接写目标文件时,如果进程在写到一半时崩溃,文件内容不完整,读取方会得到损坏的数据。rename 是原子操作(POSIX 保证),要么旧文件存在,要么新文件完整存在,不会出现中间状态。

3. 每次 persist() 都要把状态序列化并写磁盘,磁盘 I/O 是毫秒级的,比内存操作慢 1000 倍以上。如果每修改一个字段就调用,会严重降低吞吐量。实践中:在一次完整操作结束时调用一次,而不是每修改一个字段就调用。