作者背景:20年IT一线开发经验,15年架构师经验。擅长分布式、微服务、搜索引擎,手写过各类中间件。曾对Jacoco、夜莺等进行二次开发,负责过200多个节点的大规模集群,涉及K8s、微服务监控、多个中间件的运维。自研过加解密中间件,现服务于一家金融公司,担任架构师。
完整代码已开源: gitee.com/sh_wangwanb... 本章代码分支:ch2
上一篇讲了Raft的核心思想,这篇开始写代码。
从理论到实现是最难跨越的鸿沟。Nacos的JRaft有5万行代码,直接看会很困惑。
所以这次我决定自己动手写一个最简化的Raft,从最基础的选举开始。不求完美,先跑通再说。
一、MVP的边界:只做选举
实现完整的Raft容易半途而废,更好的方式是从最基础的选举开始。
这次我们只做这三件事:
- 选举出一个Leader
- Leader定时发心跳维持地位
- 持久化term和votedFor
暂时不做:
- 日志复制(下一篇再搞)
- 快照
- 配置变更
- ReadIndex
为什么?因为选举是地基,必须先打牢。日志复制是80%的工作量,放后面慢慢啃。
二、架构设计:能简单就不复杂
技术选型上选择了最简单的方案:用HTTP而不是gRPC,因为HTTP够用,出问题还能用curl调试。
bash
simple-raft/
├── surfing-raft-core # 核心算法(纯Java)
│ ├── RaftNode # 状态机核心
│ ├── rpc/ # RPC消息定义
│ └── storage/ # 持久化
└── surfing-raft-node # 节点服务(Spring Boot)
├── RaftService # 定时调度
├── RaftController # HTTP接口
└── RaftRpcClient # HTTP客户端
为什么分两层?
- core层:纯算法逻辑,不依赖框架,方便单元测试
- node层:处理定时任务、HTTP通信这些工程问题
算法和工程分离的好处是:核心逻辑不会被框架的注解搞得一团糟。
模块调用关系:
REST API] Service[RaftService
定时调度+RPC编排] Client[RaftRpcClient
HTTP客户端] end subgraph "surfing-raft-core(算法层)" Node[RaftNode
核心状态机] Storage[FileStatePersistence
持久化] RPC[RPC消息
Request/Response] end Controller -->|转发RPC| Service Service -->|调用算法| Node Service -->|发送RPC| Client Client -->|HTTP POST| Controller Node -->|读写| Storage Service -->|构造消息| RPC style Node fill:#ffe6e6 style Storage fill:#e6f3ff style Service fill:#e6ffe6
数据流示例(收到投票请求):
RequestVoteRequest Controller->>Service: handleRequestVote(request) Service->>Node: handleRequestVote(request) rect rgb(255, 240, 240) Note over Node: 加锁处理 Node->>Node: 检查term Node->>Node: 决定是否投票 alt 同意投票 Node->>Storage: save(term, votedFor) Storage-->>Node: 持久化成功 Node->>Node: resetElectionTimeout() end end Node-->>Service: RequestVoteResponse Service-->>Controller: RequestVoteResponse Controller-->>Peer: HTTP 200
返回响应
三、核心逻辑:少即是多
3.1 持久化:只存两个字段
Raft论文明确说了,必须持久化的只有两个:
java
public class PersistentState {
private long currentTerm; // 当前任期
private String votedFor; // 投给谁了
}
为什么不存state(LEADER/FOLLOWER)?
重启后默认都是Follower,通过心跳或选举自然就恢复了。能不存就不存,这是分布式系统的基本原则。
持久化状态越多,系统升级时的兼容性问题就越复杂。
3.2 原子性写入:保证数据完整
java
public void save(PersistentState state) {
// 先写临时文件
File tempFile = new File(stateFilePath + ".tmp");
objectMapper.writeValue(tempFile, state);
// 原子性重命名
Files.move(tempFile.toPath(), new File(stateFilePath).toPath(),
StandardCopyOption.ATOMIC_MOVE);
}
这种"写临时文件+原子重命名"的模式在很多存储系统中都有使用,比如PostgreSQL的WAL机制。
为什么要这样做?
先写临时文件,成功后再原子性重命名。这样即使写入过程中宕机,要么新文件成功,要么旧文件还在,不会出现文件损坏的情况。
3.3 选举逻辑:term是核心
java
public void becomeCandidate() {
lock.lock();
try {
state = CANDIDATE;
currentTerm++; // 关键:任期递增
votedFor = nodeId; // 投给自己
votesReceived = 1; // 自己算一票
savePersistentState(); // 立即持久化!
resetElectionTimeout();
} finally {
lock.unlock();
}
}
这里有几个需要注意的点:
注意1:term必须先递增再发投票请求 如果发完请求才递增term,其他节点会因为term过旧而拒绝投票。
注意2:投票后必须立即持久化 如果不持久化就重启,节点可能忘记已投票,在同一term投多次票,导致两个Leader。这是Raft安全性的根基。
注意3:votesReceived要包含自己 候选人自己也算一票。如果忘记这点,3节点集群会需要3票才能当选,永远选不出Leader。
3.4 投票规则:同一任期只投一次
java
public RequestVoteResponse handleRequestVote(RequestVoteRequest request) {
lock.lock();
try {
// 拒绝过期请求
if (request.getTerm() < currentTerm) {
return new RequestVoteResponse(currentTerm, false);
}
// 发现更高term,立即降级
if (request.getTerm() > currentTerm) {
becomeFollower(request.getTerm());
}
// 检查是否已投票
boolean canVote = (votedFor == null ||
votedFor.equals(request.getCandidateId()));
if (canVote) {
votedFor = request.getCandidateId();
savePersistentState(); // 又是立即持久化
resetElectionTimeout();
return new RequestVoteResponse(currentTerm, true);
}
return new RequestVoteResponse(currentTerm, false);
} finally {
lock.unlock();
}
}
这里体现了Raft的精髓:term是逻辑时钟。
任何时候收到更高的term,不管你是Leader还是Candidate,立即认怂变Follower。这保证了集群在最高term下只有一个合法Leader。
3.5 心跳机制:Leader的生命线
java
private void sendHeartbeats() {
if (raftNode.getState() != NodeState.LEADER) {
return; // 只有Leader发心跳
}
AppendEntriesRequest request = new AppendEntriesRequest(
raftNode.getCurrentTerm(),
raftNode.getNodeId(),
0, 0, 0 // 暂时没有日志,这些字段都是0
);
// 并行发送给所有Peer
for (String peer : raftNode.getPeers()) {
executor.submit(() -> {
AppendEntriesResponse response = rpcClient.appendEntries(peer, request);
if (response != null && response.getTerm() > raftNode.getCurrentTerm()) {
// 发现更高term,Leader降级
raftNode.becomeFollower(response.getTerm());
}
});
}
}
为什么心跳间隔是50ms?
选举超时是150-300ms,心跳间隔应该远小于它,一般是1/3。这样即使偶尔丢包,也不会触发选举。
如果心跳间隔设置得过长(比如100ms心跳配120ms超时),网络稍有抖动就会频繁选举,集群难以稳定。
为什么并行发送?
串行发送的话,3个节点就要150ms(50ms * 3),早就超时了。并行发送能保证心跳及时送达。
注意:不要用CompletableFuture.allOf()等待所有响应。 一个节点慢了,整个心跳就慢了。正确做法是发完就完事,响应异步处理。
3.6 完整的选举流程
用时序图看得更清楚:
(Follower) participant N2 as Node-2
(Follower) participant N3 as Node-3
(Follower) Note over N1,N3: 所有节点启动,初始状态都是Follower
term=0, votedFor=null Note over N1: 选举超时(200ms)
没收到心跳 rect rgb(255, 240, 240) Note over N1: 开始选举 N1->>N1: becomeCandidate()
term: 0→1
votedFor: node-1
votes: 1 N1->>N1: 持久化(term=1, votedFor=node-1) end par 并发发送投票请求 N1->>N2: RequestVote
{term:1, candidateId:node-1} N1->>N3: RequestVote
{term:1, candidateId:node-1} end rect rgb(240, 255, 240) Note over N2: 处理投票请求 N2->>N2: term: 0→1
votedFor: node-1 N2->>N2: 持久化(term=1, votedFor=node-1) N2->>N2: 重置选举超时 N2-->>N1: VoteGranted: true end rect rgb(240, 255, 240) Note over N3: 处理投票请求 N3->>N3: term: 0→1
votedFor: node-1 N3->>N3: 持久化(term=1, votedFor=node-1) N3->>N3: 重置选举超时 N3-->>N1: VoteGranted: true end rect rgb(240, 240, 255) Note over N1: 收到多数派投票 N1->>N1: votes: 3/3 >= 2
becomeLeader() Note over N1: 成为Leader end loop 每50ms发送心跳 N1->>N2: AppendEntries
{term:1, leaderId:node-1} N1->>N3: AppendEntries
{term:1, leaderId:node-1} N2-->>N1: Success: true N3-->>N1: Success: true end Note over N1,N3: 集群稳定
Node-1是Leader, term=1
关键时刻:
- 0-200ms: 所有节点等待,Node-1先超时
- 200ms: Node-1发起选举,term变成1
- 200-250ms: 投票请求在网络中传输(假设延迟50ms)
- 250ms: Node-2和Node-3收到请求,投票给Node-1
- 250-300ms: 投票响应返回
- 300ms: Node-1收到多数票,成为Leader
- 300ms后: Leader每50ms发一次心跳
3.7 Leader失联后的重新选举
(Leader) participant F1 as Node-2
(Follower) participant F2 as Node-3
(Follower) Note over L,F2: 稳定状态: Node-1是Leader, term=1 loop Leader正常发送心跳 L->>F1: AppendEntries{term:1} L->>F2: AppendEntries{term:1} F1-->>L: Success F2-->>L: Success end rect rgb(255, 200, 200) Note over L: Leader宕机/网络断开 end Note over F1,F2: Followers等待心跳...
选举超时开始计时 Note over F1: 选举超时(250ms) rect rgb(255, 240, 240) F1->>F1: becomeCandidate()
term: 1→2
votedFor: node-2
votes: 1 F1->>F1: 持久化(term=2, votedFor=node-2) end F1->>F2: RequestVote
{term:2, candidateId:node-2} rect rgb(240, 255, 240) F2->>F2: term: 1→2
votedFor: node-2 F2->>F2: 持久化(term=2, votedFor=node-2) F2-->>F1: VoteGranted: true end rect rgb(240, 240, 255) F1->>F1: votes: 2/2 >= 2
becomeLeader() Note over F1: Node-2成为新Leader end loop 新Leader发送心跳 F1->>F2: AppendEntries{term:2, leaderId:node-2} F2-->>F1: Success: true end Note over F1,F2: 集群恢复
Node-2是Leader, term=2 opt Leader恢复后 L->>F1: AppendEntries{term:1} rect rgb(255, 240, 240) Note over F1: 发现旧term F1-->>L: {term:2, success:false} end rect rgb(255, 200, 200) Note over L: 收到更高term L->>L: becomeFollower(2)
state: LEADER→FOLLOWER
持久化 end end
这个流程体现了Raft的两个关键特性:
- 任期单调递增:新Leader的term=2 > 旧Leader的term=1
- 旧Leader自动降级:收到更高term后,立即变成Follower
3.8 节点状态转换规则
Raft节点有3个状态:Follower、Candidate、Leader。状态转换的触发条件如下:
| 当前状态 | 触发条件 | 转换到 | 执行动作 |
|---|---|---|---|
| Follower | 选举超时,未收到心跳 | Candidate | term++,投票给自己,持久化 |
| Follower | 收到合法Leader心跳 | Follower | 重置选举超时 |
| Follower | 收到更高term | Follower | 更新term,持久化 |
| Candidate | 获得多数票 | Leader | 开始发送心跳 |
| Candidate | 选举超时,未获得多数票 | Candidate | term++,发起新一轮选举 |
| Candidate | 收到更高term | Follower | 更新term,持久化 |
| Candidate | 收到合法Leader心跳 | Follower | 更新term,重置选举超时 |
| Leader | 收到更高term | Follower | 立即降级,更新term,持久化 |
| Leader | 正常运行 | Leader | 定时发送心跳维持地位 |
关键规则:
- 任何状态收到更高term,都要更新term并转为Follower
- Follower和Candidate收到合法Leader心跳,立即转为Follower并重置选举超时
- Candidate获得多数票(包括自己),立即转为Leader
- Leader只有在收到更高term时才会降级
四、运行效果:眼见为实
Talk is cheap, show me the code. 不,show me the running cluster!
4.1 启动集群
bash
cd simple-raft
./start-cluster.sh
这个脚本会:
- 清理旧数据(
/tmp/raft-data) - 编译项目(Maven)
- 后台启动3个节点
yaml
清理旧数据...
编译项目...
[INFO] BUILD SUCCESS
启动节点 1...
节点 1 PID: 55160
启动节点 2...
节点 2 PID: 55166
启动节点 3...
节点 3 PID: 55170
三个节点已启动:
节点 1: http://localhost:8081 (PID: 55160)
节点 2: http://localhost:8082 (PID: 55166)
节点 3: http://localhost:8083 (PID: 55170)
启动后的内部流程:
start() Service->>Storage: 创建持久化 Service->>Node: 创建RaftNode Node->>Storage: loadPersistentState() alt 首次启动(文件不存在) Storage-->>Node: {term:0, votedFor:null} Note over Node: 初始化为Follower
term=0, votedFor=null else 重启(文件存在) Storage-->>Node: {term:5, votedFor:node-2} Note over Node: 恢复为Follower
term=5, votedFor=node-2 end Node->>Node: 生成随机选举超时
150-300ms Service->>Service: 启动定时任务 rect rgb(240, 255, 240) Note over Service: 定时任务1: 选举超时检查(50ms) loop 每50ms执行 Service->>Node: 检查是否超时 alt 超时且未收到心跳 Service->>Service: startElection() end end end rect rgb(240, 240, 255) Note over Service: 定时任务2: 心跳发送(50ms) loop 每50ms执行 alt 当前是Leader Service->>Service: sendHeartbeats() end end end Note over Spring,Storage: 节点启动完成,进入运行状态
3个节点的配置差异:
| 配置项 | Node-1 | Node-2 | Node-3 |
|---|---|---|---|
| server.port | 8081 | 8082 | 8083 |
| raft.node-id | node-1 | node-2 | node-3 |
| raft.peers | localhost:8082, localhost:8083 | localhost:8081, localhost:8083 | localhost:8081, localhost:8082 |
| raft.storage-dir | /tmp/raft-data/ node-1 | /tmp/raft-data/ node-2 | /tmp/raft-data/ node-3 |
持久化文件:
- Node-1:
/tmp/raft-data/node-1/node-1-state.json - Node-2:
/tmp/raft-data/node-2/node-2-state.json - Node-3:
/tmp/raft-data/node-3/node-3-state.json
文件内容示例:
json
{
"currentTerm": 1,
"votedFor": "node-1"
}
4.2 查看状态
bash
curl http://localhost:8081/raft/status | jq
json
{
"nodeId": "node-1",
"state": "LEADER",
"currentTerm": 1,
"leaderId": "node-1"
}
bash
curl http://localhost:8082/raft/status | jq
json
{
"nodeId": "node-2",
"state": "FOLLOWER",
"currentTerm": 1,
"leaderId": "node-1"
}
看到没?node-1是Leader,其他两个是Follower。集群稳定了。
4.3 测试Leader失联
bash
# 杀掉Leader
kill 55160
# 等2秒,让选举完成
sleep 2
# 查看新状态
curl http://localhost:8082/raft/status | jq
json
{
"nodeId": "node-2",
"state": "LEADER",
"currentTerm": 2,
"leaderId": "node-2"
}
node-2成为新Leader了,term从1变成2。这就是Raft的容错能力。
4.4 观察日志:选举的完整过程
我在代码里加了详细的日志,你可以实时看到选举过程:
bash
tail -f /tmp/raft-node-1.log
完整的日志输出(带时间戳和解读):
ini
2024-11-18 22:49:23.156 [main] INFO RaftNode - Node node-1 initialized as FOLLOWER
2024-11-18 22:49:23.158 [main] INFO FileStatePersistence - No persistent state file found, initializing with defaults
2024-11-18 22:49:23.159 [main] INFO RaftNode - Loaded persistent state: term=0, votedFor=null
2024-11-18 22:49:23.162 [main] INFO RaftService - RaftService started for node node-1
↑ 节点启动,初始化为Follower,term=0
yaml
2024-11-18 22:49:23.450 [pool-2-thread-1] INFO RaftNode - Election timeout reached, current state: FOLLOWER
2024-11-18 22:49:23.451 [pool-2-thread-1] INFO RaftNode - State changed: FOLLOWER -> CANDIDATE, term: 0 -> 1
2024-11-18 22:49:23.452 [pool-2-thread-1] INFO FileStatePersistence - Saved persistent state: term=1, votedFor=node-1
↑ 选举超时(~300ms),转为Candidate,term变成1,投票给自己并持久化
vbscript
2024-11-18 22:49:23.453 [pool-2-thread-1] INFO RaftService - Starting election for term 1
2024-11-18 22:49:23.454 [pool-3-thread-1] INFO RaftService - Sending vote request to localhost:8082
2024-11-18 22:49:23.454 [pool-3-thread-2] INFO RaftService - Sending vote request to localhost:8083
↑ 发起选举,并行向其他两个节点发送投票请求
ini
2024-11-18 22:49:23.512 [pool-3-thread-1] INFO RaftService - Received vote response from localhost:8082: granted=true, term=1
2024-11-18 22:49:23.513 [pool-3-thread-2] INFO RaftService - Received vote response from localhost:8083: granted=true, term=1
↑ 收到其他两个节点的投票,都是同意(granted=true)
yaml
2024-11-18 22:49:23.514 [pool-3-thread-1] INFO RaftNode - Received majority votes (3/3), becoming LEADER
2024-11-18 22:49:23.514 [pool-3-thread-1] INFO RaftNode - State changed: CANDIDATE -> LEADER
↑ 获得多数票(3票≥2票),成为Leader
ini
2024-11-18 22:49:23.565 [pool-2-thread-2] INFO RaftService - Sending heartbeats to 2 peers, term=1
2024-11-18 22:49:23.615 [pool-2-thread-2] INFO RaftService - Sending heartbeats to 2 peers, term=1
2024-11-18 22:49:23.665 [pool-2-thread-2] INFO RaftService - Sending heartbeats to 2 peers, term=1
↑ Leader每50ms发送一次心跳
同时看Node-2的日志(Follower视角):
bash
tail -f /tmp/raft-node-2.log
ini
2024-11-18 22:49:23.480 [http-nio-8082-exec-1] INFO RaftController - Received RequestVote: {term:1, candidateId:node-1}
2024-11-18 22:49:23.481 [http-nio-8082-exec-1] INFO RaftNode - Received RequestVote from node-1 for term 1
2024-11-18 22:49:23.482 [http-nio-8082-exec-1] INFO RaftNode - Granting vote to node-1 for term 1
2024-11-18 22:49:23.483 [http-nio-8082-exec-1] INFO FileStatePersistence - Saved persistent state: term=1, votedFor=node-1
↑ Follower收到投票请求,同意投票,持久化votedFor=node-1
ini
2024-11-18 22:49:23.570 [http-nio-8082-exec-2] INFO RaftController - Received AppendEntries: {term:1, leaderId:node-1}
2024-11-18 22:49:23.571 [http-nio-8082-exec-2] INFO RaftNode - Received heartbeat from Leader node-1, term=1
2024-11-18 22:49:23.571 [http-nio-8082-exec-2] INFO RaftNode - Resetting election timeout
↑ Follower收到Leader的心跳,重置选举超时
css
2024-11-18 22:49:23.620 [http-nio-8082-exec-3] INFO RaftController - Received AppendEntries: {term:1, leaderId:node-1}
2024-11-18 22:49:23.670 [http-nio-8082-exec-4] INFO RaftController - Received AppendEntries: {term:1, leaderId:node-1}
↑ 持续收到心跳,每50ms一次
日志时间线分析:
关键时间点:
- 23.156s: Node-1启动
- 23.450s: Node-1选举超时(等了294ms)
- 23.453s: 发起选举
- 23.480s: Node-2收到投票请求(网络延迟27ms)
- 23.512s: Node-1收到第一张投票(总延迟59ms)
- 23.514s: 获得多数票,成为Leader(总耗时64ms)
- 23.565s: 开始发送心跳
五、与Nacos JRaft的对比
Simple Raft只有500行代码,而JRaft有5万行,差距在哪?
我整理了个对比表:
| 特性 | Simple Raft | Nacos JRaft |
|---|---|---|
| 代码量 | ~500行 | ~5万行 |
| 选举机制 | 完全一致 | 完全一致 |
| 持久化 | 文件(JSON) | RocksDB |
| RPC | HTTP + JSON | gRPC/Bolt |
| 日志复制 | 未实现 | 完整实现 |
| 快照 | 未实现 | 完整实现 |
| 性能 | 够用(demo) | 生产级(万级TPS) |
| PreVote优化 | 无 | 有 |
| Pipeline复制 | 无 | 有 |
核心思想是一样的,差距在工程实现上。
JRaft做了大量优化:
- 批量复制日志
- Pipeline降低延迟
- RocksDB持久化
- 详尽的监控指标
但这些都不是Raft的核心。我们先把核心搞懂,优化以后再说。
六、常见的实现问题
问题1:并发安全
仅用volatile不够,currentTerm++和state=CANDIDATE不是原子操作,可能出现term已经+1但state还是FOLLOWER的情况。
需要用ReentrantLock保护所有状态变更操作。
问题2:选举超时未随机化
如果所有节点的选举超时都是固定值(比如200ms),会导致多个节点同时超时、同时发起选举、互相投票给自己,一直平票。
解决方案:设置随机的选举超时(150-300ms),让某个节点先发起选举。
问题3:心跳失败后Leader未降级
Leader和Follower断网后,Leader可能继续认为自己是Leader。
原因:心跳失败后只打日志,没有检查响应的term。正确做法:收到更高term,立即降级。
问题4:持久化时机错误
java
// 错误写法
votedFor = candidateId;
return new RequestVoteResponse(currentTerm, true);
savePersistentState(); // 永远执行不到
// 正确写法
votedFor = candidateId;
savePersistentState(); // 先持久化
return new RequestVoteResponse(currentTerm, true);
状态变更必须先持久化再返回,这是Raft安全性的基础。
七、当前实现的局限
通过这个MVP版本,我们跑通了Raft的Leader选举和心跳机制。3个节点能正常选举,Leader能维持统治,节点重启后能正确恢复。
但这只是Raft的入门。当前实现还缺少最核心的功能:
没有日志复制
现在的Leader只会发心跳,不会处理任何业务请求。选举只是解决了"谁来协调",还没有解决"怎么协调"。
回顾第一篇说的:Raft的核心是保证所有节点按相同顺序执行相同的操作。现在我们只有Leader,但没有"操作"和"顺序"。
没有状态机
日志复制后,需要应用到状态机。这才是Raft的最终目的:构建一个一致性的状态机。
没有过半提交机制
Leader如何知道日志已经被多数节点接收?什么时候可以告诉客户端"写入成功"?这些都需要实现。
日志一致性检查
如果Follower的日志和Leader不一致怎么办?如何通过prevLogIndex/prevLogTerm检查一致性?冲突了如何处理?
这些问题,都在下一篇解决。
下一篇将实现日志复制机制,包括:
- AppendEntries携带日志条目
- 日志一致性检查
- 过半确认与提交
- 简单的KV状态机
那时候才算真正理解了Raft的核心。
如果你也在学习Raft,或者对分布式共识有自己的理解,欢迎在评论区交流。有问题也可以直接留言,看到会回复。
代码已开源,欢迎Star:gitee.com/sh_wangwanb...