Simple Raft 收官:从玩具到生产,还有多远的路

7 阅读7分钟

写在前面

终于可以写这篇收官了。

说实话,Raft 日志复制那篇(第三篇)发完之后,我拖了很久才敢写第四篇。不是懒,是真的觉得难。commitIndex、lastApplied、matchIndex 这几个索引的关系,我反复看别人的博客、翻 JRaft 的源码、自己写代码调试,才慢慢搞明白。

说实话,Raft 论文我没有逐字逐句啃过,那个写法太学术了。我主要靠参考Nacos,手写代码,遇到问题再回去谷歌和AI。Figure 8 那个场景,就是调试的时候才真正理解的——漏了一个检查,数据就乱了,然后倒回去查,才明白为什么要那么设计。

中间有好几次想放弃,想着"算了,写到日志复制就行了,后面太难了"。

但最后还是硬着头皮写完了。现在回头看,挺庆幸当时没放弃。

这篇是整个系列的收官。不写新代码了,聊聊这个"玩具"离生产环境还有多远,也聊聊我自己在这个过程中学到了什么。

先回顾一下我们干了啥

四篇文章,从零搭了一个能跑的 Raft:

graph LR
    A[第1篇<br/>选举] --> B[第2篇<br/>心跳]
    B --> C[第3篇<br/>日志复制]
    C --> D[第4篇<br/>提交与持久化]
    
    A1[能选出Leader] --> A
    B1[Leader不会被踢] --> B
    C1[日志能同步] --> C
    D1[数据不丢失] --> D

代码开源在 Gitee:gitee.com/sh_wangwanb…

现在这个 Simple Raft 能干什么呢?

  • 三个节点组成集群,能自动选举出 Leader
  • Leader 挂了,能重新选举
  • 写入的数据能复制到多数节点
  • 重启不丢数据

听起来还不错?但说实话,离生产环境还差得远。

和生产级实现的差距

我后来花了些时间看 JRaft(Nacos 用的那个)和 etcd 的 raft 实现,发现差距主要在这几个地方。

功能上的差距

成员变更

Simple Raft 的集群节点是写死的,要加节点或者减节点,得停机改配置重启。

生产环境不可能这么搞。JRaft 支持动态成员变更,在线加减节点,业务无感知。这块涉及到 Joint Consensus 或者单步变更,论文里有讲,但我没实现。主要是这个场景在学习阶段用不上,投入产出比不高。

日志压缩

我们的 WAL 是单文件,只增不减。跑久了文件会越来越大,启动要全扫描,越来越慢。

生产环境会定期做快照,然后把快照之前的日志删掉。JRaft 还支持增量快照和并行传输,这块优化很深。

读优化

Simple Raft 所有读写都走 Leader。但其实很多场景读请求量远大于写请求量,全压在 Leader 上扛不住。

生产环境有 ReadIndex 和 LeaseRead 两种优化方案,可以让 Follower 也能处理读请求。这块我还没研究透,后面有时间可能会单独写一篇。

graph TB
    subgraph "Simple Raft"
        C1[客户端] --> L1[Leader]
        L1 --> F1[Follower]
        L1 --> F2[Follower]
    end
    
    subgraph "生产级实现"
        C2[客户端读] --> L2[Leader]
        C3[客户端读] --> F3[Follower]
        C4[客户端读] --> F4[Follower]
        C5[客户端写] --> L2
        L2 --> F3
        L2 --> F4
    end

性能上的差距

这块差距更大。

我们是一条一条复制,生产环境是 batch + pipeline。

Simple Raft 每写一条日志,就发一次 AppendEntries。生产环境会攒一批一起发,而且不等上一批确认就发下一批(pipeline),吞吐量差了一个数量级。

我们每次 append 都 fsync,生产环境用 group commit。

fsync 是很慢的操作,我们每写一条就 fsync 一次。生产环境会把多个写请求合并,一起 fsync,摊薄开销。

我们用 HashMap 存日志,生产环境用 RocksDB。

内存里存日志,重启要全量加载。RocksDB 支持随机读,启动快,而且内存占用可控。

JRaft 官方有 benchmark 数据,三节点集群能跑到几万 QPS。我们这个 Simple Raft,估计几百 QPS 就顶天了。

但这不重要,我们的目的是学习,不是造生产级轮子。

为什么还是值得写

有人可能会问:既然和生产环境差这么多,写这个有什么意义?

我觉得意义在于把抽象的概念变成能跑的代码

网上讲 Raft 的文章很多,但看文章和写代码是两回事。很多细节文章里一笔带过,真写起来才发现到处是坑。

比如 matchIndex 的初始化。看别人的代码都是初始化成 0,我当时没细想为什么,随手初始化成了 lastLogIndex,结果 Leader 刚当选就以为所有日志都复制了,直接提交。Follower 懵了:我啥也没收到啊。

这种坑,不写代码根本不会遇到。

还有 Figure 8 那个场景,网上很多文章都提到,我当时看了好几篇,觉得自己懂了。但真到写代码的时候,才发现那个 entry.getTerm() == currentTerm 的检查有多重要。漏了这个检查,数据一致性就没了。

sequenceDiagram
    participant A as 节点A
    participant B as 节点B
    participant C as 节点C
    
    Note over A,C: 第1届:A当Leader
    A->>A: 写日志[1] term=1
    A->>B: 复制日志[1]
    B->>B: 收到,存下
    Note over A: A挂了,还没提交
    
    Note over A,C: 第2届:C当Leader
    C->>C: 写日志[1] term=2
    Note over C: C也挂了
    
    Note over A,C: 第3届:A恢复,重新当Leader
    Note over A: A能直接提交term=1的日志吗?
    Note over A: 不能!必须先写一条term=3的日志
    Note over A: 等term=3的日志提交后
    Note over A: term=1的日志才算"顺带"提交

写完这个系列之后,我再去看 Nacos 的选举日志,就能看懂了。以前看到 "term changed to 360743" 这种日志,完全不知道什么意思。现在一看就知道:哦,发生了选举,term 涨了。

这就是"拆机式学习"的价值。不是为了造轮子,是为了理解轮子。

Raft 在分布式系统中的地位

写完这个系列,我对 Raft 的认识也更深了一层。

Raft 解决的是分布式共识问题:怎么让多个节点对一系列操作达成一致。这个问题是分布式系统的基石。

你看现在主流的分布式系统,底层几乎都有 Raft 的影子:

graph TB
    R[Raft协议] --> N[Nacos]
    R --> E[etcd]
    R --> T[TiKV]
    R --> C[CockroachDB]
    R --> K[Kafka KRaft]
    
    N --> N1[服务注册发现]
    E --> E1[K8s配置存储]
    T --> T1[分布式数据库]
    C --> C1[分布式数据库]
    K --> K1[消息队列元数据]
  • Nacos 用 JRaft 做配置中心的数据一致性
  • etcd 用自己的 raft 实现,是 K8s 的底座
  • TiKV 用 Raft 做分布式存储引擎
  • CockroachDB 用 Raft 做分布式事务
  • Kafka 新版本用 KRaft 替代 ZooKeeper

可以说,不理解 Raft,就很难真正理解这些系统的设计。

当然,Raft 不是银弹。它保证的是强一致性,代价是性能和可用性。有些场景不需要强一致,用最终一致性方案(比如 Gossip)反而更合适。

但作为分布式系统的基础知识,Raft 是绑定要学的。

后续计划

这个系列虽然收官了,但 Raft 相关的内容还会继续写。

近期会出一篇 Raft 面试相关的内容。 面试分布式系统岗位,Raft 几乎是必考的。选举流程、脑裂怎么处理、日志复制的一致性保证,这些问题怎么答才能让面试官满意,我整理一下。

还想写一篇 Raft 在 Nacos 中的应用。 Nacos 用的是 JRaft,它在 Nacos 里具体怎么用的,配置数据怎么同步的,选举怎么触发的。结合源码分析一下,应该会很有意思。

Simple Raft 的代码会继续留在 Gitee,有兴趣的可以 fork 去玩。如果后面有时间,可能会加 ReadIndex 优化,但不保证。

写在最后

回顾这个系列,最大的感受是:难的东西,慢慢啃,总能啃下来。

Raft 我最早是 2022 年接触的,当时看了几篇博客觉得自己懂了。后来想写代码实现,发现根本写不出来,又放下了。今年重新捡起来,一篇一篇写,一个坑一个坑踩,终于算是搞明白了。

中间有好几次想放弃。特别是第四篇,commitIndex 那块逻辑,我调了整整两天才跑通。当时真的很崩溃,觉得自己是不是不适合搞这个。

但现在回头看,那些崩溃的时刻,恰恰是成长最快的时刻。

如果你也在学 Raft,遇到困难很正常。建议多画图,多跑测试,看着日志输出去理解。代码已经开源了,clone 下来跑一跑,比光看论文有用得多。


这个系列就到这里了。感谢一路陪伴的读者,你们的点赞和评论是我继续写下去的动力。

最后,想问问大家几个问题:

  1. 你们生产环境用的是什么共识协议?Raft、Paxos、还是 ZAB?遇到过什么坑?
  2. 对于 Raft 面试题,有没有什么问题是你觉得特别难答的?
  3. Nacos 那篇你们感兴趣吗?想看源码分析还是想看使用踩坑?

评论区聊聊,我看到都会回复。

代码地址:gitee.com/sh_wangwanb…

如果觉得这个系列对你有帮助,点个 Star 吧,这对我很重要。

下个系列见。