准备手写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这个分布式基石搞明白。

相关推荐
IT_陈寒19 分钟前
JavaScript的闭包把我坑惨了,说好的内存会自动回收呢?
前端·人工智能·后端
CaffeinePro1 小时前
Pydantic深度使用:数据校验、枚举、ORM映射
后端·fastapi
Chenyiax2 小时前
从 Chat 到 Responses:OpenAI API 抽象为什么变了?
后端
MariaH2 小时前
Koa和Express的区别
后端
MariaH2 小时前
Koa框架的使用
后端
luckdewei3 小时前
那个用 passlib 做认证的新同事,上线第一天就把用户密码写进了日志
后端
ping某4 小时前
为什么 Nginx 明明监听了 80,转发后端时却用了 4xxxx 端口?
后端·nginx
JustHappy4 小时前
我汇总了身边朋友的经历才发现,其实第一份实习是最难找的......
前端·后端·面试
uhakadotcom4 小时前
在python 的 工程化架构中 ,什么是 薄包装器层?
后端·面试·github
用户1474853079749 小时前
CodeX使用Skill生成游戏美术和音乐资源,一分钟入门
后端