过去大半个月,我一直在啃分布式系统理论,从复制状态机到两阶段提交,再到 Paxos 和 Raft 的论文,笔记记了不少,但心里总觉得不踏实。理论里那些“多数派保证”“任期单调递增”“日志只追加不回退”,读的时候每条都像是显然的,可真要让我解释它们是怎么在故障环境下互相咬合起来的,我又说不利索。所以这个系列到了第 11 篇,我决定换一种方式:用 Go 从零写一个迷你 Raft 共识实现。这篇不聊宏大架构,就聊我动手过程中怎么把论文里的规则翻译成 struct、channel、timer,以及我在测试时被哪些并发问题折腾到怀疑人生。如果你已经理解 Raft 的基本流程但还没亲手写过,这篇文章应该能帮你少踩一半的坑;如果你刚知道共识算法这个词,建议先补一下 Raft 的选举和日志复制流程,再回来看实践细节会更顺。
1. 为什么我会把“从零实现 Raft”当成理论学习的试金石
1.1 看论文觉得自己懂了,动手才发现全是细节
Raft 论文在分布式系统里算是好读的,它把问题拆成领导选举、日志复制、安全性三个模块,还画了很多状态图。但我真正开始设计代码时才意识到,图里的每一个箭头背后都隐藏着一堆状态转换条件。比如一个 Follower 收到任期更大的 RequestVote,它要更新 currentTerm、清空自己本轮投票记录、重置选举超时,然后才能决定是否投赞成票;这几个动作的顺序如果错了,轻则选不出主,重则出现两个 Leader 把系统搞坏。
学习理论时我们会把这些条件当成“理所当然”,但写代码时根本没有理所当然一说,每个分支都必须明确落点。这也是我强烈建议每个学分布式系统的人亲手写一遍 Raft 的原因:它会把所有“感觉自己懂了”的模糊地带全部暴露出来,像一个试金石一样检验你的理解是不是只停留在纸面上。
1.2 迷你版不等于降级版,它依然是完整的 Raft 内核
我这次的目标不是做一个生产级实现,生产级 Raft 涉及的日志压缩、快照、成员变更、批量流水线优化,加起来代码量会非常恐怖,第一次上手就奔着这些去,很容易迷失在工程细节里,反而看不清算法本身。
所以我先砍掉所有非核心功能,只保留一个内聚的“Raft 内核”:若干个节点通过不可靠的通信通道互相收发消息,能自动选举出唯一 Leader,能把客户端请求作为日志条目复制到多数派并提交。这个内核大约一千行 Go 代码,不需要任何第三方库,跑在一个 Go 模块里,用 channel 模拟节点间通信,就能非常直观地观察到三个节点在丢消息、延迟、宕机恢复场景下的行为。
我希望达到的验证标准也很明确:持续随机故障几分钟,任意时刻最多只有一个 Leader;已经提交的日志条目永远不会消失;故障恢复后集群能继续选主并同步日志。这三个性质验证通过,才说明我对 Raft 的理解真正落地了。
1.3 动手前的最小环境准备
代码用的是 Go 1.20 以上版本,标准库足够。我推荐一开始就把项目放在 go mod init raft-mini 这样的模块里,因为后面写测试会用到 go test 跑并发场景,没模块会很别扭。整个实现只需要三个文件:raft.go 放节点状态机和协议处理,transport.go 放不可靠通信层,raft_test.go 放故障注入测试。这样的划分是我试过几轮之后觉得最适合教学和调试的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手之前先给迷你版画好“安全红线”
2.1 三条铁律必须保留:多数派、任期、日志匹配
砍功能可以,但 Raft 的安全性质一条都不能松。我在写代码前给自己立了三条红线,每条都对应论文里的一个安全性保证。
第一,所有决策都必须以多数派为准。选举要拿到多数派投票,日志复制要得到多数派确认才算提交。这个原则在实现里要非常较真,不能因为“看起来大多数都成功了”就提前提交。
第二,任期号是全局逻辑时钟,必须单调递增,而且节点一旦发现更大的任期号,就要立刻摆脱自己当前的角色执念:Leader 降级成 Follower,Candidate 也要停止选举。这个“立刻”我在前面几个版本里做得不够利落,后面栽了跟头,第六节会详细讲。
第三,日志只能按照匹配检查来追加和截断。Leader 发给每个 Follower 的消息里带着前一条日志的索引和任期,Follower 必须确认自己那一条日志和 Leader 一致,才允许把新日志接上去;不一致就拒绝。这样能保证一旦一个日志条目被存储到某个节点上,它前面的所有日志在集群范围内都不会产生分叉。
2.2 可以推迟的部分:持久化、快照、成员变更、批量优化
我的迷你版里没有做持久化,日志直接放在内存 slice 里,节点重启就当失忆处理。这在真实系统里是不可接受的,但对验证共识算法来说还算合理。同理,日志压缩和快照、成员变更这类工程模块,我也全部屏蔽掉,只留出接口上的注释位。
我说这些不是鼓励你偷懒,而是想强调第一版实现的核心任务是“把脑裂问题彻底搞明白”。Raft 真正困难的地方不是那些工程功能,而是几个节点之间在消息延迟、丢包、分区时如何保持一致性。如果我一开始就把持久化、快照、批量追加全堆进去,遇到 bug 时根本分不清是算法理解错了还是工程代码写错了。
2.3 用进程内 channel 模拟多机通信,别急着上 gRPC
这里分享一个非常实用的选型经验:第一次实现 Raft 时不要直接上网络 RPC,先在单个进程里用 channel 模拟节点间通信。原因有三个。
第一,channel 是 Go 内置的并发原语,写起来快,不需要处理序列化、连接管理这些问题,能让你把全部注意力放在协议状态上。第二,进程内通信天然方便你“做手脚”,我可以随时在传输层加随机延时、随机丢包,甚至把一个节点彻底隔离,这对测试 Raft 的故障恢复能力非常关键。第三,当你把协议逻辑跑通之后,把 channel 换成真正的 gRPC 调用只是把 send 函数的内部实现替换掉,协议处理代码完全不用动。
我之前见过不少同学一上来就写 HTTP 版 Raft,结果花了大半时间在调试网络库,真正的选举逻辑反而没写明白。如果目标是验证算法,进程内模拟是性价比最高的路径。
3. 核心数据结构先行:节点的状态、消息和定时器怎么建模
3.1 三种角色与关键字段的 Go 表达
Raft 节点在任意时刻处于 Follower、Candidate、Leader 中的一种状态。Go 里直接用枚举类型表达,再把这个节点需要维护的持久性和易失性状态放到一个 struct 里。下面这个结构体是我们迷你版的地基。
go复制type Role int
const (
RoleFollower Role = iota
RoleCandidate
RoleLeader
)
type LogEntry struct {
Term int
Command []
