作者背景: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读写:
看起来挺美好的,MySQL的MVCC会帮我们处理并发问题。但等等,MVCC只能保证数据库层面的事务隔离,它保证不了这些:
业务顺序的一致性
我举个例子。客户端A注册了一个服务实例instance-1,客户端B马上又注销了这个实例。这两个请求分别打到了不同的Nacos节点:
虽然MySQL能保证这两个写操作不会冲突,但推送通知的顺序可能乱!订阅者可能先收到"删除"通知,再收到"新增"通知,导致最终状态不对。
内存状态的分裂
更要命的是,Nacos的每个节点都有内存缓存。没有Leader协调的话:
css
节点1内存: {serviceA: [instance1, instance2]}
节点2内存: {serviceA: [instance1, instance3]}
节点3内存: {serviceA: [instance2, instance3]}
虽然MySQL里的数据是对的,但三个节点给出的查询结果都不一样。客户端查到哪个节点算哪个,这不就乱套了吗?
原来Raft解决的是这个
我后来想明白了,Raft选主不是为了解决数据读写的问题,而是为了保证所有节点按照相同的顺序执行相同的操作。
有了Leader之后,流程变成这样:
所有的写操作都通过Leader,Leader保证:
- 所有操作都有全局唯一的顺序
- 所有节点按相同顺序执行
- 内存状态保持一致
这就是分布式系统里常说的状态机复制。
那如果MySQL用串行化隔离级别呢?
我当时还想了个歪招:如果MySQL设置成SERIALIZABLE隔离级别,那是不是就可以不用Raft了?
后来发现还是不行。SERIALIZABLE只能保证MySQL内部的事务串行化,但保证不了:
- Nacos各节点的内存状态一致
- 推送通知的顺序正确
- 分布式环境下的操作顺序
说白了,MySQL是存储层,Raft是协调层,两个根本不是一回事。
选出Leader后就完事了?还有多线程问题
这里我又发现一个有意思的事。Raft通过选举保证了只有一个Leader在写,但Leader自己内部还是有并发问题啊!
想象一下,多个客户端同时向Leader发请求:
这些请求可能同时到达,Leader内部肯定有多线程处理。怎么保证它们的执行顺序呢?
这就是日志的作用了!我之前一直没想明白为什么Raft需要日志,后来才恍然大悟。
日志解决并发控制
Leader把所有写操作都串行化地追加到日志里:
虽然请求是并发接收的,但写日志的时候会串行化:
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); // 严格按顺序执行
}
}
}
这样一来:
- 多线程并发接收请求(高吞吐)
- 日志队列串行化(保证顺序)
- 单线程应用到状态机(保证一致性)
日志就是把时间维度上的并发,转化为空间维度上的顺序。
突然想通的一件事
我研究到这里的时候突然发现,这套机制和我熟悉的其他系统很像啊:
PostgreSQL的WAL:
多个事务并发 → 串行写WAL → 按序应用到数据页
RocketMQ的CommitLog:
多个生产者并发 → 串行写CommitLog → 按序消费
Raft的日志:
多个请求并发 → 串行写日志 → 按序应用到状态机
它们本质上都在解决同一个问题:把无序的并发操作,变成有序的可重放序列。
这也是为什么这些系统都叫"日志",而不是叫"数据库"或者"缓存"。日志天然就是一个有序的、只能追加的结构。
主挂了怎么办?
我当时还想到一个问题:如果Leader刚写入日志还没来得及复制,就挂了怎么办?
后来发现Raft早就想好了。Leader写入日志后,不会立即返回成功,而是要:
只有过半数节点确认后,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这个基石,你就能构建各种上层应用:
- 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系列,从最简单的版本开始,一点点迭代:
- 第一版:基本的选举和心跳
- 第二版:日志复制和提交
- 第三版:持久化和恢复
- 第四版:日志压缩
- 第五版:用它实现分布式锁
代码会放到Gitee上,会以Nacos为基础,手写核心代码。
这个过程中肯定会遇到各种坑,到时候再记录下来。我觉得这种"边写边学,把踩过的坑记录下来"的方式,应该会比纯理论讲解更有意思。
而且说实话,市面上Raft的文章很多,但大多都是翻译论文,或者纯讲理论。我想结合自己在生产环境维护Nacos、RocketMQ、PostgreSQL的经验,写点不一样的东西。
比如:
- Raft和PostgreSQL的流复制有什么区别?
- Raft和RocketMQ的主从同步有什么不同?
- 什么场景适合用Raft,什么场景用最终一致性就够了?
这些都是实际工作中会遇到的问题。
好了,今天就先到这里。算是给Simple Raft系列开个头吧。
后面的文章会慢慢写,不着急,想清楚了再动手。毕竟我觉得理解原理比急着写代码更重要。
有兴趣的朋友可以关注一下,咱们一起把Raft这个分布式基石搞明白。