前面三篇干了啥
简单回顾一下,前三篇我们搞定了:
- 选举:能选出Leader了
- 心跳:Leader不会被踢下台了
- 日志复制:能把日志从Leader复制到Follower了
但说实话,到第3篇为止,整个系统还是个"玩具"。为什么?因为日志只是"记录"了,但从来没有真正"执行"过。
这一章到底在干什么?
我用银行转账类比一下,你就明白了。
第1-3章做的事:
你:我要给张三转100块
银行柜员:好的,我记下来了(写日志)
柜员喊同事:你们也记一下(复制日志)
同事:收到,记下了(复制成功)
然后...就没了。
钱没转,张三账户还是0。
第4章要做的事:
markdown
1. 主管检查:3个柜员,2个记录了,过半!确认这笔转账生效(commitIndex)
2. 后台系统:哦,已确认了,我去真的转账(apply到状态机)
3. 张三账户:0 → 100(数据真的改了!)
4. 而且这些记录全部写本子上(持久化),停电也不怕
说白了,这一章就是让日志从"记录"变成"真实数据",而且"重启不丢"。
具体来说,解决三个问题:
- 什么时候算"确认生效"? → commitIndex(过半提交)
- 怎么真正改数据? → apply线程 + 状态机
- 重启了怎么办? → 持久化(metadata + log + snapshot)
下面我们一个一个来。
代码还是老规矩,开源在Gitee:gitee.com/sh_wangwanb...
从银行转账说起
我一直觉得用银行转账类比Raft最直观。
你去银行说要给张三转100块:
- 柜员A记录:"转100给张三"(Leader写日志)
- 柜员A喊柜员B和C:"你们也记一下"(复制日志)
- B和C记完了,回复A:"记好了"(复制成功)
- 柜员A看了看,3个人里有2个记了,过半了(过半提交)
- A告诉后台系统:"可以真的转账了"(应用到状态机)
- 后台系统执行:张三账户+100(数据真正改变)
前三篇做到了第3步,这一篇要做第4、5、6步。
而且还有个问题:如果银行停电了,重启后怎么知道之前记了什么?所以还得有持久化。
过半了!commitIndex=1 L->>SM: apply日志[1]
执行"PUT name alice" SM->>SM: data["name"]="alice" L-->>C: 200 OK(真的写入了)
commitIndex:什么时候算"确认"
这是第一个关键点。
我们有个字段叫commitIndex,表示"已确认生效的最高日志位置"。Leader每次收到Follower的成功响应,就要重新计算commitIndex。
计算方法很直接:
- 收集所有节点的进度(Leader自己的lastLogIndex + 每个Follower的matchIndex)
- 排个序
- 取中位数
为什么是中位数?因为3个节点取中位数就是第2个,正好过半。5个节点取中位数是第3个,也是过半。
java
public void updateCommitIndex() {
List<Long> indices = new ArrayList<>();
indices.add(logStorage.getLastIndex()); // Leader自己
for (String peer : peers) {
indices.add(matchIndex.get(peer)); // 每个Follower的进度
}
Collections.sort(indices);
long majorityIndex = indices.get(indices.size() / 2); // 中位数
// 关键:只能提交当前term的日志
if (majorityIndex > commitIndex) {
LogEntry entry = logStorage.getEntry(majorityIndex);
if (entry != null && entry.getTerm() == currentTerm) {
commitIndex = majorityIndex;
logger.info("commitIndex更新: {} -> {}", commitIndex, majorityIndex);
}
}
}
这里有个特别重要的检查:entry.getTerm() == currentTerm。
用大白话说: Leader只能提交"自己当Leader这届"写的日志,上一届Leader写的日志不能直接提交。
为什么? 举个例子:
假设有3个节点A、B、C。
- 第1届:A当Leader,写了一条日志,复制给了B(2个节点有了,过半)
- A还没来得及"确认"这条日志,就挂了
- 第2届:C当选新Leader,也写了一条日志到自己这里
- C还没复制给别人,也挂了
- 第3届:A恢复了,重新当选Leader
现在A手里有"第1届的日志",而且当时已经过半了(A和B都有)。
问题来了: A能直接提交这条第1届的日志吗?
不能! 因为万一A又挂了,C可能当选,C会把A的那条日志覆盖掉。这样之前"已提交"的数据就丢了,出大问题。
正确做法: A先写一条"第3届的新日志",等这条新日志过半提交后,第1届的旧日志自动算提交了。这样就算A挂了,C也当选不了(因为C的日志比B旧)。
代码里的体现:
java
if (entry.getTerm() == currentTerm) { // 必须是当前这届的日志
commitIndex = majorityIndex; // 才能提交
}
这样就保证了:只提交"当前Leader这届"写的日志,旧日志会被"顺带"提交。
我们的代码有这个检查,所以逻辑是安全的。虽然没有专门的测试覆盖这个复杂场景(需要反复模拟节点挂掉和重启),但有这行检查在,就不会出问题。
lastApplied:真正执行到数据库
有了commitIndex,就知道哪些日志"确认生效"了。但还有个问题:谁来真正执行这些日志?
我们搞了个后台线程,专门干这个事:
java
private void startApplyThread() {
Thread applyThread = new Thread(() -> {
while (running) {
try {
applyCommittedLogs();
Thread.sleep(10); // 10ms检查一次
} catch (Exception e) {
logger.error("apply失败", e);
}
}
});
applyThread.setDaemon(true);
applyThread.start();
}
private void applyCommittedLogs() {
lock.lock();
try {
while (lastApplied < commitIndex) {
lastApplied++;
LogEntry entry = logStorage.getEntry(lastApplied);
if (entry != null) {
stateMachine.apply(entry.getCommand());
logger.info("应用日志[{}]: {}", lastApplied,
new String(entry.getCommand()));
}
}
} finally {
lock.unlock();
}
}
逻辑很简单:
- commitIndex是"可以执行的最高位置"
- lastApplied是"已经执行的最高位置"
- 循环把lastApplied追到commitIndex
必须加锁,保证串行执行。不能乱序,否则数据就乱了。
状态机就是个简单的KV存储:
java
public class KVStateMachine {
private final ConcurrentHashMap<String, String> data = new ConcurrentHashMap<>();
public void apply(byte[] command) {
String cmd = new String(command);
String[] parts = cmd.split(" ");
if ("PUT".equals(parts[0])) {
data.put(parts[1], parts[2]);
} else if ("DELETE".equals(parts[0])) {
data.remove(parts[1]);
}
}
public String get(String key) {
return data.get(key);
}
}
命令格式就是简单的字符串:"PUT name alice"、"DELETE name"。生产环境肯定不能这么简单,但学习够用了。
四个索引的关系
到这里,我们有4个索引了,经常搞混。画个图理清楚:
已应用"| A1["用户可见"] L5 -->|"commitIndex=5
已确认"| A2["不会丢失"] L6 -->|"matchIndex[F1]=6
某节点进度"| A3["复制状态"] L7 -->|"lastLogIndex=7
最新日志"| A4["待复制"] style L1 fill:#cccccc style L2 fill:#cccccc style L3 fill:#99ff99 style L4 fill:#ffff99 style L5 fill:#ffff99 style L6 fill:#ffcccc style L7 fill:#ffcccc style A1 fill:#eee style A2 fill:#eee style A3 fill:#eee style A4 fill:#eee
- lastApplied=3:1-3号日志已经执行了,用户能看到效果
- commitIndex=5:1-5号日志已经过半确认,不会丢失,等待执行
- matchIndex[F1]=6:已知Follower-1复制到了第6号日志
- lastLogIndex=7:Leader最后一条日志是第7号
关系:lastApplied ≤ commitIndex ≤ matchIndex[某peer] ≤ lastLogIndex
完整的写入流程
现在把整个流程串起来,看一次PUT请求是怎么完成的:
body: alice RC->>RC: 检查是Leader Note over RC: 1. 写日志 RC->>RN: 构造LogEntry
[index=5, term=3, "PUT name alice"] RN->>FS: append(entry) FS->>FS: 写入log.wal + fsync Note over RN,F2: 2. 并行复制 par 复制给Follower RN->>F1: AppendEntries[5] F1->>F1: 一致性检查OK,写入 F1-->>RN: success=true and RN->>F2: AppendEntries[5] F2->>F2: 一致性检查OK,写入 F2-->>RN: success=true end Note over RN: 3. 过半提交 RN->>RN: matchIndex: [5,5,5]
排序取中位数=5
commitIndex: 4→5 Note over RC: 4. 等待提交 loop 轮询 RC->>RN: commitIndex >= 5? alt 已提交 RN-->>RC: 是 else 未提交 RN-->>RC: 否,继续等 end end Note over AT,SM: 5. Apply线程发现可执行 AT->>AT: lastApplied=4 < commitIndex=5 AT->>RN: getEntry(5) RN-->>AT: LogEntry[5] AT->>SM: apply("PUT name alice") SM->>SM: data["name"] = "alice" AT->>AT: lastApplied=5 RC-->>C: 200 OK
客户端的代码很简单:
java
@PostMapping("/kv/{key}")
public ResponseEntity<String> put(@PathVariable String key,
@RequestBody String value) {
if (state != LEADER) {
return ResponseEntity.status(503)
.body("Not leader, try: " + leaderId);
}
// 1. 构造日志
String command = "PUT " + key + " " + value;
LogEntry entry = new LogEntry(
logStorage.getLastIndex() + 1,
currentTerm,
command.getBytes()
);
// 2. 追加到本地
logStorage.append(entry);
// 3. 触发复制
for (String peer : peers) {
sendAppendEntries(peer);
}
// 4. 等待提交(轮询,5秒超时)
long startTime = System.currentTimeMillis();
while (commitIndex < entry.getIndex()) {
if (System.currentTimeMillis() - startTime > 5000) {
return ResponseEntity.status(500).body("Timeout");
}
Thread.sleep(50);
}
return ResponseEntity.ok("OK");
}
这个等待提交的轮询,其实可以用CompletableFuture优化,但我觉得简单的轮询更容易理解。生产环境肯定不能这么搞,会阻塞线程。
持久化:重启不能丢数据
到这里,功能基本完整了。但还有个致命问题:重启了怎么办?
内存全清空了,currentTerm、votedFor、日志、状态机数据,全没了。
所以必须持久化。我们持久化3样东西:
1. 元数据:metadata.json
json
{
"currentTerm": 360743,
"votedFor": "node-2"
}
currentTerm必须持久化,否则重启后term归0,选举就乱了。votedFor也必须持久化,否则一个term可能投多次票。
每次term变化、投票时,立即写metadata.json:
java
public void saveTerm(long term) {
try {
Map<String, Object> metadata = new HashMap<>();
metadata.put("currentTerm", term);
metadata.put("votedFor", votedFor);
// 写到临时文件,然后替换,保证原子性
File tempFile = new File(metadataFile.getPath() + ".tmp");
objectMapper.writeValue(tempFile, metadata);
// fsync
FileChannel channel = new FileOutputStream(tempFile).getChannel();
channel.force(true);
channel.close();
// 替换
Files.move(tempFile.toPath(), metadataFile.toPath(),
StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
logger.error("保存metadata失败", e);
}
}
这里用了个技巧:先写临时文件,fsync后再替换。保证要么写完整,要么不写,不会出现写一半的情况。
2. 日志:log.wal
采用单文件WAL(Write-Ahead Log),每行一条日志:
ini
1,360743,UFVUIG5hbWUgYWxpY2U=
2,360743,UFVUIGNpdHkgaGFuZ3pob3U=
格式:index,term,Base64(command)
为什么用Base64?因为command可能有换行符,直接存会乱。
java
public void append(LogEntry entry) {
try {
String line = String.format("%d,%d,%s\n",
entry.getIndex(),
entry.getTerm(),
Base64.getEncoder().encodeToString(entry.getCommand())
);
Files.write(walFile.toPath(), line.getBytes(),
StandardOpenOption.CREATE,
StandardOpenOption.APPEND);
// fsync
FileChannel channel = new FileOutputStream(walFile, true).getChannel();
channel.force(true);
channel.close();
} catch (IOException e) {
logger.error("写WAL失败", e);
}
}
每次append都fsync,保证落盘。性能不是最优,但简单可靠。
启动时,扫描整个WAL文件恢复:
java
public void loadFromWAL() {
if (!walFile.exists()) return;
try {
List<String> lines = Files.readAllLines(walFile.toPath());
for (String line : lines) {
String[] parts = line.split(",");
long index = Long.parseLong(parts[0]);
long term = Long.parseLong(parts[1]);
byte[] command = Base64.getDecoder().decode(parts[2]);
LogEntry entry = new LogEntry(index, term, command);
logs.put(index, entry);
lastIndex = Math.max(lastIndex, index);
lastTerm = term;
}
logger.info("从WAL恢复{}条日志", logs.size());
} catch (IOException e) {
logger.error("加载WAL失败", e);
}
}
3. 快照(这块还在优化)
状态机数据也得持久化。我们搞了个简单的快照机制,定期把KV数据存到snapshot.json:
json
{
"lastApplied": 100,
"data": {
"name": "alice",
"city": "hangzhou"
}
}
启动时先加载快照,然后从lastApplied+1开始重放日志。
这块代码还不够优化,后面准备参考RocksDB的checkpoint机制。不过现在这个版本够用了。
启动恢复的顺序
这个顺序很重要,搞错了会出问题:
metadata和log必须先加载,否则选举会出问题。snapshot可以慢一点,反正Apply线程会从lastApplied继续。
跑起来看看
测试脚本test-commit.sh做了这些事:
- 启动集群,找到Leader
- 写入数据:
PUT name=alice - 读取验证
- 检查持久化文件(metadata.json、log.wal)
- 停止所有节点
- 重启集群
- 写入新数据:
PUT city=hangzhou - 验证两个数据都在
实际运行的输出:
bash
$ ./test-commit.sh
=== 步骤1: 启动集群 ===
三个节点已启动
=== 步骤2: 写入数据到Leader ===
OK
=== 步骤3: 读取验证 ===
alice
=== 步骤4: 检查持久化文件 ===
/tmp/raft-data/node-2/metadata.json
{"votedFor":"node-2","currentTerm":360743}
/tmp/raft-data/node-2/log.wal
1,360743,UFVUIG5hbWUgYWxpY2U=
=== 步骤5: 停止集群 ===
所有节点已停止
=== 步骤7: 重启集群 ===
三个节点已启动
=== 步骤10: 再次写入并验证 ===
OK
hangzhou
重启后term从360743变成了360746,说明发生了选举。但数据没丢,持久化生效了。
几个要注意的地方
1. commitIndex不能随便更新
必须检查entry.getTerm() == currentTerm,这个前面讲过了。我当时漏了这个检查,调了一天才发现。
2. apply必须串行化
不能并发apply,否则顺序就乱了。加锁是最简单的方式。
3. 等待提交要有超时
不能死等,万一网络分区了,Leader变成了孤岛,commitIndex永远不会推进。5秒超时比较合理。
4. fsync很重要
写metadata和log后必须fsync,否则断电会丢数据。我测试的时候没fsync,重启后经常发现metadata是旧的。
5. 未提交的日志允许丢失
测试脚本里有个现象:杀Leader后,如果某个写入还没提交(commitIndex没推进),新Leader上读不到。
这不是bug,这是设计。Raft只保证"已提交的不丢",未提交的丢了也没关系。
对比生产环境
这个Simple Raft和生产环境的Raft(比如Nacos用的JRaft)差距还是挺大的。
我们这个版本:
- 单文件WAL,启动要全扫描
- 轮询等待提交,阻塞线程
- 简单的快照,没有增量
- 没有batch,一条一条复制
生产环境:
- RocksDB存日志,支持随机读
- CompletableFuture回调,非阻塞
- 增量快照,只存变化的部分
- Pipeline batch,一次复制多条
但对于学习来说,简单版本反而更容易理解。等把核心逻辑搞透了,再去看JRaft的优化,会觉得"哦原来是这么回事"。
一些坑
写这块代码的时候踩了不少坑,记录一下:
坑1:matchIndex初始化错了
一开始我把matchIndex初始化成lastLogIndex,结果Leader刚当选就误以为所有日志都复制了,直接提交。然后Follower懵了:"我啥也没收到啊"。
后来改成初始化为0,逐步推进,就对了。
坑2:apply时没加锁
多个日志并发apply,顺序就乱了。比如先DELETE name,后PUT name alice,结果name丢了。
加了个大锁,简单粗暴但有效。
坑3:忘了fsync
测试的时候经常Ctrl+C停掉程序,重启后发现metadata是旧的。后来加了fsync才好。
生产环境不能直接kill -9,得graceful shutdown,先fsync再退出。
坑4:WAL格式没考虑换行
command里如果有换行符,直接存WAL会乱。后来改成Base64编码,这个问题就没了。
代码结构
这次改动的文件不少:
bash
src/main/java/com/surfing/raft/
├── node/
│ └── RaftNode.java # 新增commitIndex、lastApplied、matchIndex
├── storage/
│ ├── FileLogStorage.java # 新增WAL持久化
│ ├── MetadataStorage.java # 新增metadata持久化
│ └── SnapshotManager.java # 新增快照管理
├── statemachine/
│ └── KVStateMachine.java # 新增KV状态机
├── service/
│ └── RaftService.java # 改动:复制成功后更新commitIndex
└── controller/
└── RaftController.java # 新增PUT/GET接口
核心逻辑都在RaftNode里,其他类都是辅助的。
写在最后
这一篇是整个系列最难的一篇。commitIndex、lastApplied、matchIndex这几个索引的关系,我当时理解了好久。Figure 8场景更是看了好几遍论文才搞明白。
如果你也在学Raft,遇到困惑很正常。建议多画图,多跑测试,看着日志输出去理解。代码已经开源了,欢迎clone下来玩玩。
有问题可以在评论区留言,我看到会回复。也欢迎聊聊你们生产环境是怎么用Raft的,遇到过什么坑。我这个Simple Raft肯定还有不少问题,大家一起讨论讨论。
另外,如果你觉得这个系列对你有帮助,点个赞吧:gitee.com/sh_wangwanb... 分支ch4
下一篇见。