准备手写Simple Raft(四):日志终于能"生效"了

前面三篇干了啥

简单回顾一下,前三篇我们搞定了:

  • 选举:能选出Leader了
  • 心跳:Leader不会被踢下台了
  • 日志复制:能把日志从Leader复制到Follower了

但说实话,到第3篇为止,整个系统还是个"玩具"。为什么?因为日志只是"记录"了,但从来没有真正"执行"过。

这一章到底在干什么?

我用银行转账类比一下,你就明白了。

第1-3章做的事:

复制代码
你:我要给张三转100块
银行柜员:好的,我记下来了(写日志)
柜员喊同事:你们也记一下(复制日志)
同事:收到,记下了(复制成功)

然后...就没了。
钱没转,张三账户还是0。

第4章要做的事:

markdown 复制代码
1. 主管检查:3个柜员,2个记录了,过半!确认这笔转账生效(commitIndex)
2. 后台系统:哦,已确认了,我去真的转账(apply到状态机)
3. 张三账户:0 → 100(数据真的改了!)
4. 而且这些记录全部写本子上(持久化),停电也不怕

说白了,这一章就是让日志从"记录"变成"真实数据",而且"重启不丢"。

具体来说,解决三个问题:

  1. 什么时候算"确认生效"? → commitIndex(过半提交)
  2. 怎么真正改数据? → apply线程 + 状态机
  3. 重启了怎么办? → 持久化(metadata + log + snapshot)

下面我们一个一个来。

代码还是老规矩,开源在Gitee:gitee.com/sh_wangwanb...

从银行转账说起

我一直觉得用银行转账类比Raft最直观。

你去银行说要给张三转100块:

  1. 柜员A记录:"转100给张三"(Leader写日志)
  2. 柜员A喊柜员B和C:"你们也记一下"(复制日志)
  3. B和C记完了,回复A:"记好了"(复制成功)
  4. 柜员A看了看,3个人里有2个记了,过半了(过半提交)
  5. A告诉后台系统:"可以真的转账了"(应用到状态机)
  6. 后台系统执行:张三账户+100(数据真正改变)

前三篇做到了第3步,这一篇要做第4、5、6步。

而且还有个问题:如果银行停电了,重启后怎么知道之前记了什么?所以还得有持久化。

sequenceDiagram participant C as 客户端 participant L as Leader participant F1 as Follower-1 participant F2 as Follower-2 participant SM as 状态机(KV) Note over C,SM: 第1-3篇做到这里 C->>L: PUT name=alice L->>L: 写日志[index=1] L->>F1: 复制日志[1] L->>F2: 复制日志[1] F1-->>L: OK F2-->>L: OK Note over L: 第4篇从这里开始 L->>L: 统计:3个节点,2个OK
过半了!commitIndex=1 L->>SM: apply日志[1]
执行"PUT name alice" SM->>SM: data["name"]="alice" L-->>C: 200 OK(真的写入了)

commitIndex:什么时候算"确认"

这是第一个关键点。

我们有个字段叫commitIndex,表示"已确认生效的最高日志位置"。Leader每次收到Follower的成功响应,就要重新计算commitIndex。

计算方法很直接:

  1. 收集所有节点的进度(Leader自己的lastLogIndex + 每个Follower的matchIndex)
  2. 排个序
  3. 取中位数

为什么是中位数?因为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. 第1届:A当Leader,写了一条日志,复制给了B(2个节点有了,过半)
  2. A还没来得及"确认"这条日志,就挂了
  3. 第2届:C当选新Leader,也写了一条日志到自己这里
  4. C还没复制给别人,也挂了
  5. 第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个索引了,经常搞混。画个图理清楚:

graph LR subgraph "日志状态示例" L1["[1] term=1"] L2["[2] term=1"] L3["[3] term=2"] L4["[4] term=2"] L5["[5] term=2"] L6["[6] term=2"] L7["[7] term=3"] end L3 -->|"lastApplied=3
已应用"| 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请求是怎么完成的:

sequenceDiagram participant C as 客户端 participant RC as RaftController participant RN as RaftNode participant FS as FileLogStorage participant F1 as Follower-1 participant F2 as Follower-2 participant AT as Apply线程 participant SM as KVStateMachine C->>RC: POST /kv/name
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机制。不过现在这个版本够用了。

启动恢复的顺序

这个顺序很重要,搞错了会出问题:

graph TD A[启动] --> B[加载metadata.json] B --> C[恢复currentTerm和votedFor] C --> D[加载log.wal] D --> E[恢复所有日志到内存] E --> F[加载snapshot.json] F --> G[恢复lastApplied和KV数据] G --> H[启动选举定时器] H --> I[启动心跳定时器] I --> J[启动Apply线程] J --> K[正常运行] style B fill:#ffcccc style D fill:#ffcccc style F fill:#ffcccc

metadata和log必须先加载,否则选举会出问题。snapshot可以慢一点,反正Apply线程会从lastApplied继续。

跑起来看看

测试脚本test-commit.sh做了这些事:

  1. 启动集群,找到Leader
  2. 写入数据:PUT name=alice
  3. 读取验证
  4. 检查持久化文件(metadata.json、log.wal)
  5. 停止所有节点
  6. 重启集群
  7. 写入新数据:PUT city=hangzhou
  8. 验证两个数据都在

实际运行的输出:

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

下一篇见。

相关推荐
程序员西西41 分钟前
SpringBoot 隐式参数注入:告别重复代码,让 Controller 更优雅
java·后端
嘻哈baby41 分钟前
Ansible自动化运维:从入门到批量管理100台服务器
后端
用户3458482850542 分钟前
dict.fromkeys()和OrderedDict.fromkeys()的底层实现原理是什么?
后端
Cache技术分享43 分钟前
258. Java 集合 - 深入探究 NavigableMap:新增方法助力高效数据处理
前端·后端
做cv的小昊1 小时前
在NanoPC-T6开发板上通过USB串口通信实现光源控制功能
java·后端·嵌入式硬件·边缘计算·安卓·信息与通信·开发
用户69371750013841 小时前
21.Kotlin 接口:接口 (Interface):抽象方法、属性与默认实现
android·后端·kotlin
溪饱鱼1 小时前
主动与被动AI交互范式
前端·后端·aigc
写代码的皮筏艇1 小时前
Sequelize 详细指南
前端·后端
用户294655509191 小时前
游戏开发中的向量魔法
后端