准备手写Simple Raft(一):想通Raft的核心问题

作者背景:20年IT一线开发经验,15年架构师经验。擅长分布式、微服务、搜索引擎,手写过各类中间件。曾对Jacoco、夜莺等进行二次开发,负责过200多个节点的大规模集群,涉及K8s、微服务监控、多个中间件的运维。自研过加解密中间件,现服务于一家金融公司,担任架构师。

摘要

本文是Simple Raft系列的第一篇,记录了我从一个简单疑问出发,逐步理解Raft核心机制的思考过程。主要解决了三个问题:为什么MySQL有MVCC还需要Raft选主?为什么选出Leader后还有并发问题?为什么日志是Raft的灵魂?通过对比PostgreSQL WAL、RocketMQ CommitLog等熟悉的系统,最终想通了Raft作为分布式基石的本质。后续将手写Simple Raft实现,从最简单的版本开始迭代,结合生产经验分享实战中的权衡和坑。


最近在研究Nacos的时候,突然想到一个问题:Nacos用Raft协议选主,可是选主的目的到底是啥?

你想啊,最终数据都是存到MySQL了,MySQL本身就有MVCC机制保证并发安全。那理论上哪个Nacos节点读写不都一样吗?为什么非要搞个Leader出来,多此一举?

我当时就是这么想的,觉得这设计有点多余。后来深入研究了一下,发现自己还是图样图森破了。

MySQL的MVCC解决不了的问题

假设现在有3个Nacos节点,都直接连MySQL读写:

graph LR A[客户端A] --> N1[Nacos节点1] B[客户端B] --> N2[Nacos节点2] C[客户端C] --> N3[Nacos节点3] N1 --> M[(MySQL)] N2 --> M N3 --> M

看起来挺美好的,MySQL的MVCC会帮我们处理并发问题。但等等,MVCC只能保证数据库层面的事务隔离,它保证不了这些:

业务顺序的一致性

我举个例子。客户端A注册了一个服务实例instance-1,客户端B马上又注销了这个实例。这两个请求分别打到了不同的Nacos节点:

sequenceDiagram participant A as 客户端A participant N1 as Nacos节点1 participant N2 as Nacos节点2 participant M as MySQL participant Sub as 订阅者 A->>N1: 注册instance-1 N1->>M: 写入MySQL N1->>Sub: 推送"新增"通知 Note over N2: 几乎同时 B->>N2: 注销instance-1 N2->>M: 写入MySQL N2->>Sub: 推送"删除"通知

虽然MySQL能保证这两个写操作不会冲突,但推送通知的顺序可能乱!订阅者可能先收到"删除"通知,再收到"新增"通知,导致最终状态不对。

内存状态的分裂

更要命的是,Nacos的每个节点都有内存缓存。没有Leader协调的话:

css 复制代码
节点1内存: {serviceA: [instance1, instance2]}
节点2内存: {serviceA: [instance1, instance3]}  
节点3内存: {serviceA: [instance2, instance3]}

虽然MySQL里的数据是对的,但三个节点给出的查询结果都不一样。客户端查到哪个节点算哪个,这不就乱套了吗?

原来Raft解决的是这个

我后来想明白了,Raft选主不是为了解决数据读写的问题,而是为了保证所有节点按照相同的顺序执行相同的操作

有了Leader之后,流程变成这样:

sequenceDiagram participant C as 客户端 participant F as Follower节点 participant L as Leader节点 participant M as MySQL C->>F: 写请求 F->>L: 转发给Leader L->>L: 追加到日志 L->>F: 复制日志 F->>L: 确认收到 Note over L: 过半确认 L->>M: 写入MySQL L->>C: 返回成功

所有的写操作都通过Leader,Leader保证:

  • 所有操作都有全局唯一的顺序
  • 所有节点按相同顺序执行
  • 内存状态保持一致

这就是分布式系统里常说的状态机复制

那如果MySQL用串行化隔离级别呢?

我当时还想了个歪招:如果MySQL设置成SERIALIZABLE隔离级别,那是不是就可以不用Raft了?

后来发现还是不行。SERIALIZABLE只能保证MySQL内部的事务串行化,但保证不了:

  • Nacos各节点的内存状态一致
  • 推送通知的顺序正确
  • 分布式环境下的操作顺序

说白了,MySQL是存储层,Raft是协调层,两个根本不是一回事。

选出Leader后就完事了?还有多线程问题

这里我又发现一个有意思的事。Raft通过选举保证了只有一个Leader在写,但Leader自己内部还是有并发问题啊!

想象一下,多个客户端同时向Leader发请求:

graph TD C1[客户端1] --> L[Leader] C2[客户端2] --> L C3[客户端3] --> L L --> T1[处理线程1] L --> T2[处理线程2] L --> T3[处理线程3]

这些请求可能同时到达,Leader内部肯定有多线程处理。怎么保证它们的执行顺序呢?

这就是日志的作用了!我之前一直没想明白为什么Raft需要日志,后来才恍然大悟。

日志解决并发控制

Leader把所有写操作都串行化地追加到日志里:

graph LR T1[线程1:请求A] --> Q[日志队列] T2[线程2:请求B] --> Q T3[线程3:请求C] --> Q Q --> A[单线程应用] A --> S[状态机]

虽然请求是并发接收的,但写日志的时候会串行化:

java 复制代码
class RaftLeader {
    private final BlockingQueue<LogEntry> logQueue;
    
    // 多线程并发调用
    public CompletableFuture<Boolean> propose(byte[] data) {
        LogEntry entry = new LogEntry(currentTerm, data);
        
        // 这里有锁保证串行化写入
        synchronized (this) {
            logQueue.offer(entry);
            long index = nextIndex++;
        }
        
        // 异步等待复制完成
        return waitForCommit(index);
    }
    
    // 单线程应用日志
    private void applyLog() {
        while (true) {
            LogEntry entry = logQueue.take();
            stateMachine.apply(entry);  // 严格按顺序执行
        }
    }
}

这样一来:

  1. 多线程并发接收请求(高吞吐)
  2. 日志队列串行化(保证顺序)
  3. 单线程应用到状态机(保证一致性)

日志就是把时间维度上的并发,转化为空间维度上的顺序。

突然想通的一件事

我研究到这里的时候突然发现,这套机制和我熟悉的其他系统很像啊:

PostgreSQL的WAL:

复制代码
多个事务并发 → 串行写WAL → 按序应用到数据页

RocketMQ的CommitLog:

复制代码
多个生产者并发 → 串行写CommitLog → 按序消费

Raft的日志:

复制代码
多个请求并发 → 串行写日志 → 按序应用到状态机

它们本质上都在解决同一个问题:把无序的并发操作,变成有序的可重放序列

这也是为什么这些系统都叫"日志",而不是叫"数据库"或者"缓存"。日志天然就是一个有序的、只能追加的结构。

主挂了怎么办?

我当时还想到一个问题:如果Leader刚写入日志还没来得及复制,就挂了怎么办?

后来发现Raft早就想好了。Leader写入日志后,不会立即返回成功,而是要:

sequenceDiagram participant L as Leader participant F1 as Follower1 participant F2 as Follower2 participant F3 as Follower3 participant C as 客户端 C->>L: 写请求 L->>L: 追加到日志(未提交) par 并行复制 L->>F1: 复制日志 L->>F2: 复制日志 L->>F3: 复制日志 end F1->>L: ACK F2->>L: ACK Note over L: 收到过半ACK L->>L: 标记为committed L->>C: 返回成功

只有过半数节点确认后,Leader才会告诉客户端"写入成功"。

这样就算Leader马上挂了,至少有3台机器(包括挂掉的Leader)有这条日志。新Leader选举的时候,一定会从这些有最新日志的节点中选出来。

那落后的节点能当Leader吗?

这是我最后一个疑惑。假设:

ini 复制代码
Leader(挂了):   [日志1,2,3,4,5]
Follower-A:     [日志1,2,3,4,5]  ← 最新
Follower-B:     [日志1,2,3,4,5]  ← 最新
Follower-C:     [日志1,2,3]      ← 落后
Follower-D:     [日志1,2,3]      ← 落后

C和D的选举超时时间是随机的,如果C先超时发起选举呢?它会不会当选?

答案是:不会!

因为Raft有个投票规则:

java 复制代码
boolean shouldVoteFor(Candidate candidate) {
    // 比较日志
    if (candidate.lastLogTerm < myLastLogTerm) {
        return false;  // 候选人日志更旧,拒绝
    }
    
    if (candidate.lastLogTerm == myLastLogTerm) {
        if (candidate.lastLogIndex < myLastLogIndex) {
            return false;  // 索引更小,拒绝
        }
    }
    
    return true;  // 日志至少和我一样新,同意
}

所以C发起选举的时候:

  • A看到C的日志只到3,自己到5 → 拒绝投票
  • B看到C的日志只到3,自己到5 → 拒绝投票
  • D看到C的日志和自己一样 → 同意投票

C只能拿到2票(自己+D),达不到过半,选举失败。

而如果A先发起选举,它的日志最新,能拿到至少3票,成功当选。

这就保证了新Leader一定有所有已提交的数据。

Raft:分布式系统的基石

研究到这里,我越来越觉得Raft是个很精妙的设计。它看起来不复杂,但解决了分布式系统最核心的问题:

  • 一致性:所有节点看到相同的数据
  • 顺序性:所有节点按相同顺序执行
  • 容错性:少数节点挂了系统仍可用
  • 并发控制:用日志串行化操作

有了Raft这个基石,你就能构建各种上层应用:

graph TD R[Raft协议] --> KV[KV存储] R --> Lock[分布式锁] R --> Config[配置中心] R --> Registry[服务注册] KV --> etcd Lock --> etcd Config --> Nacos Registry --> Consul
  • etcd就是Raft + KV存储 + Watch机制
  • Nacos配置中心就是Raft + 配置管理 + 推送
  • Consul就是Raft + 服务注册 + 健康检查

甚至可以用Raft自己实现分布式锁:

java 复制代码
class RaftLock {
    private RaftNode raft;
    
    public boolean tryLock(String key, String owner) {
        // 通过Raft复制加锁命令
        LockCommand cmd = new LockCommand(key, owner);
        return raft.propose(cmd);  // 过半确认才成功
    }
    
    public void unlock(String key, String owner) {
        UnlockCommand cmd = new UnlockCommand(key, owner);
        raft.propose(cmd);
    }
}

因为Raft保证了:

  • 只有一个节点能写入(Leader)
  • 所有节点看到相同的加锁顺序
  • 过半确认才返回成功

这就是一个可靠的分布式锁了。

接下来要做什么

我现在算是把Raft的核心原理想通了。但从理解到实现,还有不少距离。比如:

  • 日志压缩和快照怎么做?
  • 配置变更(动态增删节点)怎么处理?
  • 各种边界情况和网络异常怎么应对?
  • 怎么优化性能?

这些问题我还没完全研究透。

我打算写一个Simple Raft系列,从最简单的版本开始,一点点迭代:

  1. 第一版:基本的选举和心跳
  2. 第二版:日志复制和提交
  3. 第三版:持久化和恢复
  4. 第四版:日志压缩
  5. 第五版:用它实现分布式锁

代码会放到Gitee上,会以Nacos为基础,手写核心代码。

这个过程中肯定会遇到各种坑,到时候再记录下来。我觉得这种"边写边学,把踩过的坑记录下来"的方式,应该会比纯理论讲解更有意思。

而且说实话,市面上Raft的文章很多,但大多都是翻译论文,或者纯讲理论。我想结合自己在生产环境维护Nacos、RocketMQ、PostgreSQL的经验,写点不一样的东西。

比如:

  • Raft和PostgreSQL的流复制有什么区别?
  • Raft和RocketMQ的主从同步有什么不同?
  • 什么场景适合用Raft,什么场景用最终一致性就够了?

这些都是实际工作中会遇到的问题。

好了,今天就先到这里。算是给Simple Raft系列开个头吧。

后面的文章会慢慢写,不着急,想清楚了再动手。毕竟我觉得理解原理比急着写代码更重要。

有兴趣的朋友可以关注一下,咱们一起把Raft这个分布式基石搞明白。

相关推荐
00后程序员2 小时前
Charles抓包实战,开发者如何通过流量分析快速定位系统异常?
后端
用户68545375977692 小时前
为什么你的volatile总出bug?因为你没搞懂内存屏障这回事儿 🤯
后端
千百元2 小时前
kafka验证消息时报错
分布式·kafka
秧歌star5192 小时前
救命!Spring 启动又崩了?!循环依赖又踩坑
后端
程序员爱钓鱼2 小时前
Python编程实战:综合项目 —— Flask 迷你博客
后端·python·面试
程序员爱钓鱼2 小时前
Python编程实战:综合项目 —— 迷你爬虫项目
后端·python·面试
c***V3233 小时前
后端消息队列选型:RabbitMQ与Kafka
分布式·kafka·rabbitmq
Qiuner3 小时前
Spring Boot 进阶:application.properties 与 application.yml 的全方位对比与最佳实践
java·spring boot·后端