本系列目录
MIT 6.824 学习笔记
文件系统与持久化
这篇文档讲 Lab 2C(Raft 持久化)和 Lab 3B(快照)需要的文件系统知识。
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(¤tTerm) != nil ||
d.Decode(&votedFor) != nil ||
d.Decode(&log) != nil {
panic("readPersist: decode error")
}
rf.currentTerm = currentTerm
rf.votedFor = votedFor
rf.log = log
}go注意:Encode 和 Decode 的顺序必须完全一致。
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 日志越来越长,持久化和恢复会越来越慢。快照是解决这个问题的方法:
- 把当前状态机的状态保存成一个快照
- 删除快照之前的所有日志条目
- 重启时,先恢复快照,再重放快照之后的日志
Lab 3B 里的快照接口:
// 上层应用(KV 服务器)触发快照
rf.Snapshot(index int, snapshot []byte)
// Raft 保存快照和状态
rf.persister.SaveStateAndSnapshot(raftState, snapshot)go快速检验#
- Raft 需要持久化哪三个字段?为什么这三个字段必须持久化?
- 为什么写文件要用”先写临时文件,再 rename”的方式?
- 如果
persist()调用太频繁,会有什么性能问题?(提示:每次写磁盘都有开销)
参考答案
1. currentTerm(防止重启后用旧 term 参与选举,破坏安全性)、votedFor(防止同一 term 投两票,导致双 Leader)、log(防止已提交的日志丢失,状态机无法恢复)。commitIndex 和 lastApplied 不需要持久化,重启后从日志重建即可。
2. 防止读到写了一半的文件。直接写目标文件时,如果进程在写到一半时崩溃,文件内容不完整,读取方会得到损坏的数据。rename 是原子操作(POSIX 保证),要么旧文件存在,要么新文件完整存在,不会出现中间状态。
3. 每次 persist() 都要把状态序列化并写磁盘,磁盘 I/O 是毫秒级的,比内存操作慢 1000 倍以上。如果每修改一个字段就调用,会严重降低吞吐量。实践中:在一次完整操作结束时调用一次,而不是每修改一个字段就调用。