本章导读
Raft 将共识问题分解为三个子问题:① Leader 选举——集群中选出一个领导者;② 日志复制——Leader 将操作日志同步给所有节点;③ 安全性——保证所有节点最终执行相同的操作序列。本章实现一个简化的 Raft 模拟器:5 个节点通过消息队列通信,模拟选举、心跳、日志复制的完整流程。
- 理解 Raft 的三种角色:Follower、Candidate、Leader
- 理解 Term(任期)的作用和递增规则
- 实现 Leader 选举(RequestVote RPC)
- 实现心跳(AppendEntries RPC 空载荷)
- 实现日志复制(AppendEntries 带日志条目)
- 实现提交(commitIndex)和状态机应用
58.1 Raft 核心概念
// Raft 算法核心概念
//
// ┌─────────────────────────────────────────────────┐
// │ 节点角色(三种,任何时刻每节点恰好一种) │
// │ • Follower — 被动响应 Leader/Candidate │
// │ • Candidate — 发起选举,争取成为 Leader │
// │ • Leader — 处理客户端请求,复制日志 │
// ├─────────────────────────────────────────────────┤
// │ Term(任期)— 逻辑时钟,单调递增 │
// │ • 每次选举开始新 Term │
// │ • 收到更高 Term → 立即退回 Follower │
// ├─────────────────────────────────────────────────┤
// │ 日志(Log)— 有序的操作记录 │
// │ • 每条: { term, command } │
// │ • Leader 追加 → 复制给 Follower → 多数确认 → 提交│
// ├─────────────────────────────────────────────────┤
// │ 两种 RPC: │
// │ • RequestVote — Candidate 请求投票 │
// │ • AppendEntries — Leader 发心跳/日志 │
// └─────────────────────────────────────────────────┘
Raft 的核心保证:任何时刻最多一个 Leader,且 Leader 拥有所有已提交的日志条目。
58.2 节点状态定义
// 节点角色常量
int FOLLOWER = 0
int CANDIDATE = 1
int LEADER = 2
class RaftNode
int id // 节点 ID (0-4)
int role // 当前角色
int currentTerm // 当前任期
int votedFor // 本任期投给了谁 (-1=未投)
int leaderId // 当前 Leader (-1=未知)
// 日志
list<LogEntry> log // 日志条目列表
int commitIndex // 已提交的最高索引
int lastApplied // 已应用的最高索引
// Leader 专用
list<int> nextIndex // 每个 Follower 的下一条日志索引
list<int> matchIndex // 每个 Follower 已匹配的最高索引
// 选举计时
int electionTimeout // 选举超时(随机 150-300ms)
int lastHeartbeat // 上次收到心跳的时间
// 消息队列(模拟网络)
list<Message> inbox // 收件箱
func init(int id, int nodeCount)
this.id = id
role = FOLLOWER
currentTerm = 0
votedFor = -1
leaderId = -1
log = list<LogEntry>()
commitIndex = 0
lastApplied = 0
nextIndex = list<int>()
matchIndex = list<int>()
inbox = list<Message>()
electionTimeout = 150 + math.random(0, 150)
lastHeartbeat = time.now()
int i = 0
while i < nodeCount
nextIndex.add(1)
matchIndex.add(0)
i += 1
end
end
end
每个节点维护自己的任期、日志、投票记录。nextIndex 和 matchIndex 只有 Leader 使用。
58.3 日志条目
// 日志条目
class LogEntry
int term // 写入时的任期
string command // 操作命令(如 "SET x 1")
func make(int term, string command) as LogEntry
LogEntry e = LogEntry()
e.term = term
e.command = command
return e
end
end
// 消息(模拟 RPC)
class Message
string type // "RequestVote" / "VoteReply" / "AppendEntries" / "AppendReply"
int from // 发送者 ID
int to // 接收者 ID
int term // 发送者的任期
// RequestVote 字段
int lastLogIndex
int lastLogTerm
// AppendEntries 字段
int prevLogIndex
int prevLogTerm
list<LogEntry> entries
int leaderCommit
// 回复字段
bool success
int matchIdx
end
// 辅助:获取节点最后一条日志的索引和任期
func lastLogInfo(RaftNode node) as array<int>
array<int> result = array<int>(2)
if node.log.len() == 0 then
result[0] = 0 // index
result[1] = 0 // term
else
result[0] = node.log.len()
result[1] = node.log[node.log.len() - 1].term
end
return result
end
58.4 Leader 选举
当 Follower 超时未收到心跳,转为 Candidate 发起选举:
// 开始选举
func startElection(RaftNode node, list<RaftNode> cluster)
node.role = CANDIDATE
node.currentTerm += 1
node.votedFor = node.id // 投给自己
node.leaderId = -1
node.lastHeartbeat = time.now()
node.electionTimeout = 150 + math.random(0, 150) // 重新随机
println("[Node " + node.id.toString() + "] 发起选举, Term=" + node.currentTerm.toString())
// 获取自己的最后日志信息
array<int> info = lastLogInfo(node)
// 向所有其他节点发送 RequestVote
int votes = 1 // 自己的一票
int i = 0
while i < cluster.len()
if i != node.id then
Message req = Message()
req.type = "RequestVote"
req.from = node.id
req.to = i
req.term = node.currentTerm
req.lastLogIndex = info[0]
req.lastLogTerm = info[1]
// 处理投票
Message reply = handleRequestVote(cluster[i], req)
if reply.success then
votes += 1
end
end
i += 1
end
// 判断是否获得多数票
int majority = cluster.len() / 2 + 1
if votes >= majority and node.role == CANDIDATE then
becomeLeader(node, cluster)
end
end
// 成为 Leader
func becomeLeader(RaftNode node, list<RaftNode> cluster)
node.role = LEADER
node.leaderId = node.id
println("[Node " + node.id.toString() + "] 当选 Leader! Term=" + node.currentTerm.toString())
// 初始化 nextIndex 和 matchIndex
int logLen = node.log.len() + 1
int i = 0
while i < cluster.len()
node.nextIndex[i] = logLen
node.matchIndex[i] = 0
i += 1
end
// 立即发送心跳(空 AppendEntries)
sendHeartbeats(node, cluster)
end
58.5 投票逻辑
节点收到 RequestVote 时的决策:
// 处理 RequestVote RPC
func handleRequestVote(RaftNode node, Message req) as Message
Message reply = Message()
reply.type = "VoteReply"
reply.from = node.id
reply.to = req.from
reply.term = node.currentTerm
reply.success = false
// 规则 1:如果请求的 Term 比自己的大,更新自己
if req.term > node.currentTerm then
node.currentTerm = req.term
node.role = FOLLOWER
node.votedFor = -1
reply.term = req.term
end
// 规则 2:Term 不匹配 → 拒绝
if req.term < node.currentTerm then
return reply
end
// 规则 3:本任期已投给别人 → 拒绝
if node.votedFor != -1 and node.votedFor != req.from then
return reply
end
// 规则 4:候选人的日志必须至少和自己一样新
array<int> myInfo = lastLogInfo(node)
int myLastTerm = myInfo[1]
int myLastIndex = myInfo[0]
bool logOk = false
if req.lastLogTerm > myLastTerm then
logOk = true
elif req.lastLogTerm == myLastTerm and req.lastLogIndex >= myLastIndex then
logOk = true
end
if logOk then
node.votedFor = req.from
reply.success = true
println(" [Node " + node.id.toString() + "] 投票给 Node " + req.from.toString())
end
return reply
end
投票的四个条件:① Term 有效;② 本任期未投票;③ 候选人日志足够新。这保证了只有拥有最新日志的节点才能当选。
58.6 心跳机制
Leader 定期发送空 AppendEntries(心跳)维持权威:
// Leader 发送心跳给所有 Follower
func sendHeartbeats(RaftNode node, list<RaftNode> cluster)
int i = 0
while i < cluster.len()
if i != node.id then
Message hb = Message()
hb.type = "AppendEntries"
hb.from = node.id
hb.to = i
hb.term = node.currentTerm
hb.prevLogIndex = node.nextIndex[i] - 1
hb.prevLogTerm = 0
hb.entries = list<LogEntry>() // 空 = 心跳
hb.leaderCommit = node.commitIndex
// 获取 prevLogTerm
if hb.prevLogIndex > 0 and hb.prevLogIndex <= node.log.len() then
hb.prevLogTerm = node.log[hb.prevLogIndex - 1].term
end
// 发送并处理回复
Message reply = handleAppendEntries(cluster[i], hb)
processAppendReply(node, i, reply)
end
i += 1
end
end
// Follower 处理心跳/日志
func handleAppendEntries(RaftNode node, Message req) as Message
Message reply = Message()
reply.type = "AppendReply"
reply.from = node.id
reply.to = req.from
reply.term = node.currentTerm
reply.success = false
// Term 检查
if req.term < node.currentTerm then
return reply // 过期 Leader,拒绝
end
// 认可 Leader
if req.term > node.currentTerm then
node.currentTerm = req.term
end
node.role = FOLLOWER
node.leaderId = req.from
node.votedFor = -1
node.lastHeartbeat = time.now() // 重置选举计时器
reply.term = node.currentTerm
// 日志一致性检查
if req.prevLogIndex > 0 then
if req.prevLogIndex > node.log.len() then
return reply // 日志不够长,拒绝
end
if node.log[req.prevLogIndex - 1].term != req.prevLogTerm then
// 日志冲突,删除冲突条目
while node.log.len() > req.prevLogIndex - 1
node.log.removeAt(node.log.len() - 1)
end
return reply
end
end
// 追加新条目
int i = 0
while i < req.entries.len()
int idx = req.prevLogIndex + i + 1
if idx <= node.log.len() then
// 已存在:检查冲突
if node.log[idx - 1].term != req.entries[i].term then
// 冲突:删除此位置及之后所有
while node.log.len() > idx - 1
node.log.removeAt(node.log.len() - 1)
end
node.log.add(req.entries[i])
end
else
// 不存在:追加
node.log.add(req.entries[i])
end
i += 1
end
// 更新 commitIndex
if req.leaderCommit > node.commitIndex then
int newCommit = req.leaderCommit
if newCommit > node.log.len() then
newCommit = node.log.len()
end
node.commitIndex = newCommit
end
reply.success = true
reply.matchIdx = node.log.len()
return reply
end
58.7 日志复制
客户端请求到达 Leader 后的处理流程:
// Leader 处理客户端命令
func leaderAppend(RaftNode node, list<RaftNode> cluster, string command)
if node.role != LEADER then
println(" 不是 Leader,请求转发给 Node " + node.leaderId.toString())
return
end
// 1. 追加到本地日志
LogEntry entry = LogEntry.make(node.currentTerm, command)
node.log.add(entry)
println("[Leader " + node.id.toString() + "] 追加日志: [" +
node.currentTerm.toString() + "] " + command)
// 2. 复制给所有 Follower
int i = 0
while i < cluster.len()
if i != node.id then
Message req = Message()
req.type = "AppendEntries"
req.from = node.id
req.to = i
req.term = node.currentTerm
req.prevLogIndex = node.nextIndex[i] - 1
req.prevLogTerm = 0
req.entries = list<LogEntry>()
req.entries.add(entry)
req.leaderCommit = node.commitIndex
if req.prevLogIndex > 0 and req.prevLogIndex <= node.log.len() then
req.prevLogTerm = node.log[req.prevLogIndex - 1].term
end
Message reply = handleAppendEntries(cluster[i], req)
processAppendReply(node, i, reply)
end
i += 1
end
// 3. 检查是否可以提交
advanceCommitIndex(node, cluster)
end
// 处理 AppendEntries 回复
func processAppendReply(RaftNode node, int peerId, Message reply)
if reply.term > node.currentTerm then
// 发现更高 Term,退回 Follower
node.currentTerm = reply.term
node.role = FOLLOWER
node.votedFor = -1
return
end
if reply.success then
node.nextIndex[peerId] = reply.matchIdx + 1
node.matchIndex[peerId] = reply.matchIdx
else
// 失败:回退 nextIndex 重试
if node.nextIndex[peerId] > 1 then
node.nextIndex[peerId] -= 1
end
end
end
58.8 提交与应用
// 推进 commitIndex:找到多数节点已复制的最高索引
func advanceCommitIndex(RaftNode node, list<RaftNode> cluster)
int n = node.log.len()
while n > node.commitIndex
// 统计有多少节点复制到了索引 n
int count = 1 // Leader 自己
int i = 0
while i < cluster.len()
if i != node.id and node.matchIndex[i] >= n then
count += 1
end
i += 1
end
// 多数确认 + 必须是当前 Term 的日志
int majority = cluster.len() / 2 + 1
if count >= majority and node.log[n - 1].term == node.currentTerm then
node.commitIndex = n
println("[Leader " + node.id.toString() + "] 提交索引 " + n.toString())
break
end
n -= 1
end
// 应用已提交的日志到状态机
applyCommitted(node)
end
// 应用已提交但未应用的日志
func applyCommitted(RaftNode node)
while node.lastApplied < node.commitIndex
node.lastApplied += 1
LogEntry entry = node.log[node.lastApplied - 1]
println(" [Node " + node.id.toString() + "] 应用: " + entry.command)
end
end
提交条件:① 多数节点(含 Leader)已复制;② 该条目属于当前 Term。提交后,所有节点最终都会应用相同的命令序列——这就是共识。
58.9 模拟网络
本章用同步调用模拟 RPC(教学清晰)。生产环境用 TCP/UDP:
// 模拟网络层
// 真实实现中,每个节点是一个线程/进程,通过 TCP 通信
// 本章简化为:直接函数调用(同步 RPC)
//
// 生产架构:
// ┌────────┐ TCP ┌────────┐ TCP ┌────────┐
// │ Node 0 │◄─────►│ Node 1 │◄─────►│ Node 2 │
// │ Leader │ │Follower│ │Follower│
// └────────┘ └────────┘ └────────┘
// ▲ ▲
// │ TCP TCP │
// ▼ ▼
// ┌────────┐ ┌────────┐
// │ Node 3 │ │ Node 4 │
// │Follower│ │Follower│
// └────────┘ └────────┘
//
// 模拟 vs 真实的区别:
// • 模拟:同步调用,无网络延迟,无丢包
// • 真实:异步 + 超时 + 重试 + 乱序
// • 核心逻辑完全相同!
// 模拟选举超时检查
func checkElectionTimeout(RaftNode node, list<RaftNode> cluster)
if node.role == LEADER then return end
int elapsed = time.now() - node.lastHeartbeat
if elapsed > node.electionTimeout then
println("[Node " + node.id.toString() + "] 选举超时 (" +
elapsed.toString() + "ms)")
startElection(node, cluster)
end
end
Raft 的难点在算法逻辑(选举规则、日志一致性),不在网络传输。同步模拟去掉了网络不确定性,让你专注于理解算法本身。掌握后,将函数调用替换为 TCP send/recv 就是真实的分布式 Raft。
58.10 完整模拟
组装所有组件,运行一次完整的 Raft 流程:
// ═══════════════════════════════════════
// raft_sim.xl — Raft 共识模拟
// ═══════════════════════════════════════
func main()
int NODE_COUNT = 5
list<RaftNode> cluster = list<RaftNode>()
// 初始化 5 个节点
int i = 0
while i < NODE_COUNT
RaftNode node = RaftNode()
node.init(i, NODE_COUNT)
cluster.add(node)
i += 1
end
println("═══ Raft 集群初始化: " + NODE_COUNT.toString() + " 个节点 ═══")
println("")
// ─── 阶段 1:选举 ───
println("─── 阶段 1: Leader 选举 ───")
// 模拟 Node 0 超时,发起选举
startElection(cluster[0], cluster)
println("")
// ─── 阶段 2:心跳 ───
println("─── 阶段 2: Leader 心跳 ───")
RaftNode leader = cluster[0]
sendHeartbeats(leader, cluster)
println("所有 Follower 收到心跳,选举计时器重置")
println("")
// ─── 阶段 3:日志复制 ───
println("─── 阶段 3: 日志复制 ───")
leaderAppend(leader, cluster, "SET x 1")
leaderAppend(leader, cluster, "SET y 2")
leaderAppend(leader, cluster, "SET x 10")
println("")
// ─── 阶段 4:验证状态 ───
println("─── 阶段 4: 集群状态 ───")
i = 0
while i < NODE_COUNT
RaftNode n = cluster[i]
string roleName = "Follower"
if n.role == CANDIDATE then roleName = "Candidate" end
if n.role == LEADER then roleName = "Leader" end
println("Node " + n.id.toString() + ": " + roleName +
" | Term=" + n.currentTerm.toString() +
" | Log=" + n.log.len().toString() +
" | Commit=" + n.commitIndex.toString() +
" | Applied=" + n.lastApplied.toString())
i += 1
end
println("")
// ─── 阶段 5:模拟 Leader 故障 + 重新选举 ───
println("─── 阶段 5: Leader 故障, 重新选举 ───")
println("[Node 0] 宕机!")
// Node 1 超时,发起新选举
cluster[1].lastHeartbeat = time.now() - 500 // 模拟超时
checkElectionTimeout(cluster[1], cluster)
println("")
// 新 Leader 继续服务
println("─── 阶段 6: 新 Leader 继续服务 ───")
RaftNode newLeader = cluster[1]
leaderAppend(newLeader, cluster, "SET z 99")
println("")
// 最终状态
println("═══ 最终状态 ═══")
i = 0
while i < NODE_COUNT
RaftNode n = cluster[i]
string roleName = "Follower"
if n.role == CANDIDATE then roleName = "Candidate" end
if n.role == LEADER then roleName = "Leader" end
println("Node " + n.id.toString() + ": " + roleName +
" | Term=" + n.currentTerm.toString() +
" | Log=" + n.log.len().toString() +
" | Commit=" + n.commitIndex.toString())
i += 1
end
end
main()
预期输出(简化):
═══ Raft 集群初始化: 5 个节点 ═══
─── 阶段 1: Leader 选举 ───
[Node 0] 发起选举, Term=1
[Node 1] 投票给 Node 0
[Node 2] 投票给 Node 0
[Node 3] 投票给 Node 0
[Node 4] 投票给 Node 0
[Node 0] 当选 Leader! Term=1
─── 阶段 3: 日志复制 ───
[Leader 0] 追加日志: [1] SET x 1
[Leader 0] 提交索引 1
[Node 0] 应用: SET x 1
[Node 1] 应用: SET x 1
...
─── 阶段 5: Leader 故障, 重新选举 ───
[Node 0] 宕机!
[Node 1] 选举超时 (500ms)
[Node 1] 发起选举, Term=2
[Node 1] 当选 Leader! Term=2
深入细节
1. 为什么选举超时是随机的?
如果所有节点超时相同,会同时发起选举,互相投票导致"分裂投票"(没人获得多数)。随机超时(150-300ms)使某个节点大概率率先超时、率先竞选、率先获得多数票。
2. 日志一致性如何保证?
AppendEntries 携带 prevLogIndex + prevLogTerm。Follower 检查该位置的日志是否匹配——不匹配则拒绝,Leader 回退 nextIndex 重试。这保证了日志匹配属性:如果两个节点在相同索引有相同 Term,则该索引及之前的所有日志都相同。
3. 为什么只能提交当前 Term 的日志?
Raft 论文 Figure 8 描述了一个场景:Leader 不能通过计数方式直接提交前任 Term 的日志。必须通过提交当前 Term 的新条目,间接提交之前的旧条目。这避免了已提交日志被覆盖。
4. 从模拟到生产
将本章的同步调用替换为:① TCP 连接 + 消息序列化(第 45 章);② 每节点一个线程(第 57 章);③ 定时器驱动心跳和选举超时;④ 持久化日志(第 54 章)。你就得到了一个真实的 Raft 实现。
⚠️ 常见错误
错误 1:收到高 Term 不退回 Follower
// ✗ 忽略 Term 检查
func handleMsg(RaftNode node, Message msg)
// 直接处理...
end
// ✓ 任何 RPC 回复中 Term > currentTerm → 立即退回
if msg.term > node.currentTerm then
node.currentTerm = msg.term
node.role = FOLLOWER
node.votedFor = -1
end
错误 2:Candidate 赢得选举后不重置 nextIndex
// ✗ nextIndex 残留旧值,日志复制从错误位置开始
// ✓ 当选后立即初始化 nextIndex[i] = 本地日志长度 + 1
错误 3:日志索引从 0 还是 1 开始混乱
// Raft 论文中日志索引从 1 开始
// log[0] 对应索引 1,log[len-1] 对应索引 len
// prevLogIndex=0 表示"之前没有日志"(哨兵)
// 务必统一!本章:索引 1-based,数组 0-based
错误 4:Follower 接受过期 Leader 的日志
// ✗ 不检查 Term 就追加
// ✓ AppendEntries 的 Term < currentTerm → 拒绝(return success=false)
本章小结
- 三种角色:Follower(被动)→ Candidate(选举)→ Leader(服务)
- Term:逻辑时钟,单调递增,用于识别过期信息
- 选举:超时 → 自增 Term → 请求投票 → 多数当选
- 心跳:Leader 定期发空 AppendEntries,阻止 Follower 超时
- 日志复制:Leader 追加 → 复制 → 多数确认 → 提交 → 应用
- 安全性:日志匹配属性 + 只提交当前 Term + 选举限制
- 全书收官:从 Hello World 到分布式共识,你已走完 xlang 的完整旅程
从第 1 章的 Hello World 到第 58 章的 Raft 共识,你经历了变量、控制流、函数、面向对象、泛型、并发、网络、文件 I/O,最终到达分布式系统。这不仅是 xlang 的旅程,更是计算机科学核心知识的完整脉络。继续实践,继续构建!
复习题
- Raft 为什么需要随机选举超时?如果所有节点用相同超时会怎样?
- 一个节点在什么条件下会给 Candidate 投票?列出所有条件。
- Leader 如何判断一条日志可以"提交"?为什么不能提交旧 Term 的日志?
- AppendEntries 的 prevLogIndex/prevLogTerm 起什么作用?不匹配时如何处理?
- 如果 5 节点集群中 3 个宕机,能否选出 Leader?为什么?
编程练习
将 Raft 日志持久化到文件(参考第 54 章 KV 存储)。节点重启后从文件恢复日志和 currentTerm。提示:每条日志一行 term\tcommand。
将模拟 RPC 替换为 TCP 通信(参考第 45、57 章)。每个节点是一个线程,监听自己的端口。消息用 JSON 序列化(参考第 52 章)。提示:消息格式 {"type":"RequestVote","term":1,...}\n。
当日志超过 100 条时,生成快照(保存状态机 + 最后日志索引),截断旧日志。新节点加入时直接发送快照而非全部日志。提示:快照 = commitIndex + 状态 dict 的序列化。