以下先按照mmo游戏进行分布式学习,后续章节都是如此。
参考资料
概念
一致性算法允许一组机器像一个整体一样工作,即使其中一些机器出现故障也能够继续工作下去。
相关算法
- Paxos:复杂,难理解
- Raft:解决Paxos难懂,难实现
解决什么问题
核心等价问题:多个节点,每个节点维护一个状态机,要保证------所有节点按相同顺序执行相同命令,最终状态一致。这就是 Replicated State Machine(复制状态机)模型。
MMO 映射:每个场景服节点维护一份「跨服 boss 血量 + 参战名单」,所有节点必须按相同顺序扣血,否则 A 节点显示 boss 死了 B 节点还活着------这就是一致性要解决的问题。
为什么 MMO 需要 Raft?
-
世界服/场景服的主节点选举:跨服战、全服 BOSS 需要一个唯一的协调者。
-
全局配置中心的高可用:比如活动开关、合服状态,不能因为一台配置中心挂掉就停服。
-
Skynet 自身缺乏选主机制:每个节点上的 service 独立运行,你需要外部一致性存储来做 leader 决策。
Raft子问题

三种角色
Follower ←→ Candidate → Leader
↑ ↑ |
└-----------┴------------┘
超时没收到心跳就自荐
- 跟随者(Follower):被动接收来自 Leader 的心跳或来自 Candidate 的投票请求。如果长时间收不到心跳(超时),就变成 Candidate。
python
# 在 run() 主循环中,Follower 状态下:
if self.state == FOLLOWER:
elapsed = time.time() - self.last_heartbeat_time
if elapsed >= self.election_timeout:
self.start_election()
def start_election(self):
self.state = CANDIDATE # 1. 改变状态
self.current_term += 1 # 2. 自增任期
self.voted_for = self.node_id # 3. 投票给自己
self.votes_received = 1 # 4. 自己的一票计入
self.last_heartbeat_time = time.time() # 重置心跳计时器(不重要)
self.leader_id = None # 清除旧的 leader 记录
# 5. 广播 RequestVote
msg = {'type': MSG_REQUEST_VOTE, 'term': self.current_term, ...}
self.broadcast(msg)
self.election_timeout = self._random_timeout() # 为下次选举准备新的随机超时
- 候选者(Candidate):发起一轮选举:自增任期、投票给自己、请求其他节点投票。如果获得多数票,变成 Leader;如果选举超时(没拿到多数票),重新发起选举(即保持在 Candidate 状态并重试);如果发现更高任期,退回 Follower。
python
def handle_vote_response(self, msg):
if self.state != CANDIDATE:
return
# 如果对方的任期更大,退回 Follower
if msg['term'] > self.current_term:
self.current_term = msg['term']
self.state = FOLLOWER # 退回 Follower
self.voted_for = None
self.leader_id = None
return
if msg['vote_granted']:
self.votes_received += 1
# 获得多数票 → 成为 Leader
if self.votes_received >= len(NODE_IDS)//2 + 1:
self.state = LEADER
self.leader_id = self.node_id
self.send_heartbeats() # 立即发送心跳
# 当 Candidate 收到别人的 RequestVote,且对方任期更大,退回Follower:
def handle_request_vote(self, msg):
if msg['term'] > self.current_term:
self.current_term = msg['term']
self.state = FOLLOWER # 退回
self.voted_for = None
self.leader_id = None
- 领导者(Leader):唯一处理客户端请求的节点,定期发送心跳给所有 Follower。如果发现自己的任期比别人的小(收到更高任期的消息),退回 Follower。
python
def handle_append_entries(self, msg):
if msg['term'] > self.current_term:
self.current_term = msg['term']
self.state = FOLLOWER # 退回
self.voted_for = None
self.leader_id = None
⭐ 关键点:任意时刻最多一个 Leader,选举获胜条件是拿到多数派(quorum,n/2+1) 投票。
⭐Leader 只会收到两种消息:来自客户端的请求(本例中没有)、来自其他节点的 AppendEntries(心跳)或 RequestVote。只要发现对方的 term 更大,就必须立即退回 Follower,这是 Raft 的安全保障。
转换图
┌──────────────────────────────────────────┐
│ │
▼ │
┌─────────────┐ ┌─────────────┐
│ │ 超时未收到心跳 │ │
│ Follower │ ──────────────────────────► │ Candidate │
│ │ │ │
└──────┬──────┘ └──────┬──────┘
▲ │
│ │
│ 发现更高任期 │ 获得多数票
│ 或收到合法心跳 │
│ │
│ ▼
│ ┌─────────────┐
│ │ │
└──────────────────────────────────────│ Leader │
│ │
└─────────────┘
│
│
发现更高任期 ────┘
(例如收到任期更大的投票请求)
-
Follower → Candidate:选举超时(election_timeout)到期且没有收到任何 Leader 的心跳或有效的投票请求。
-
Candidate → Leader:获得超过半数节点的投票(包括自己的一票)。
-
Candidate → Follower:在选举过程中收到了任期更高的消息(无论是心跳、投票请求还是投票响应)。
-
Leader → Follower:收到了任期更高的消息(例如另一个节点声称自己是任期更高的 Leader,或收到了任期更大的投票请求)。
任期------ Raft 的"逻辑时钟"
-
currentTerm 是每个节点维护的整数,单调递增:
-
每次选举,Candidate 先把 currentTerm + 1
-
所有 RPC 都带 term,发现对方 term 更大就立刻更新自己的 term 并转 Follower
-
term 用来检测过期 Leader(旧 term 的 Leader 发出的日志会被新 term 拒绝)
💡 游戏映射:term 就像"第几代世界服主节点",老主节点就算网络分区恢复了,看到 term 比自己大也得乖乖退位。
两个核心 RPC
- RequestVote(选举用)
Candidate 发给其他人,参数:
-
term:候选人的 term
-
candidateId
-
lastLogIndex / lastLogTerm:候选人最后一条日志的索引和 term(用来比谁日志更全,这个后面 Safety 要用)
返回:
-
是否同意(voteGranted)
-
当前 term(候选人发现自己落后就退位)
- AppendEntries(日志复制 + 心跳,复用)
Leader 发给 Follower,参数:
-
term、leaderId
-
prevLogIndex / prevLogTerm:关键! 新日志条目前一条的 index 和 term,用来做一致性检查
-
entries\[\]:要追加的日志(心跳时为空)
-
leaderCommit:Leader 已提交的日志索引,告诉 Follower"你也可以提交到这儿了"
返回:
-
success:如果 Follower 的 prevLogIndex 处 term 不匹配 → false(触发 Leader 回退重试)
-
term
Leader选举流程
- Follower 超时(Election Timeout,150-300ms 随机)没收到心跳 → 转 Candidate
- currentTerm + 1,投自己一票,发 RequestVote 给所有人
- 收到多数派同意 → 转 Leader,立即发空 AppendEntries(心跳)压住其他人
- 收到别人心跳且对方 term >= 自己 → 转 Follower
- 选举超时没结果(分裂票)→ 重新来过(随机超时是关键,避免活锁)
⭐ 随机超时是 Raft 的精髓之一,游戏里你做选主也能抄这个思路。
举例说明
假设三个节点:A、B、C,初始都是 Follower,任期均为 0。
时间轴:
t0: 所有节点启动,各自生成随机超时(例如 A=180ms, B=250ms, C=220ms)
t0 ~ t0+180ms: 没有任何心跳,所有节点安静等待
t0+180ms: A 的超时最先到达
A → Candidate: term=1, 投自己一票, votes=1
A 广播 RequestVote(term=1) 给 B 和 C
t0+181ms: B 收到 A 的 RequestVote
B 的 term=0 < 1 → B 更新 term=1, 转为 Follower (本来就是), 投票给 A
B 回复 VoteResponse(term=1, granted=true)
B 重置自己的选举超时
t0+182ms: C 收到 A 的 RequestVote
C 同样 term=0 < 1 → 更新 term=1, 投票给 A, 回复 granted
C 重置超时
t0+183ms: A 收到 B 的投票响应 (granted)
A.votes=2 ≥ 2 → A 成为 Leader
A 立即发送心跳给 B 和 C
t0+184ms: B 和 C 收到心跳
B 和 C 重置自己的选举超时,确认 Leader 为 A
从此以后,只要 A 一直发心跳,B 和 C 就不会再发起选举。
特殊情况:平局
如果 A 和 B 几乎同时超时(例如 A 超时 150ms,B 超时 152ms,差距很小):
t0+150ms: A 超时,成为 Candidate(term=1),广播 RequestVote
t0+152ms: B 超时,成为 Candidate(term=1),广播 RequestVote
t0+153ms: A 收到 B 的 RequestVote
A 发现 B 的 term=1 等于自己的 term=1,但 A 已经投给自己,所以拒绝投票给 B
A 回复 VoteResponse(granted=false)
t0+154ms: B 收到 A 的 RequestVote
B 同样拒绝投票给 A
t0+155ms: C 收到 A 的 RequestVote
C 的 term=0 < 1 → 投票给 A (先到先得)
C 回复 granted
t0+156ms: C 收到 B 的 RequestVote
C 已经投过票(给 A),所以拒绝 B
t0+157ms: A 收到 C 的投票 (granted)
A.votes = 2 (自己 + C) → A 成为 Leader
B 只收到自己的 1 票 → 仍然 Candidate
t0+158ms: B 收到 A 的心跳 (term=1)
B 发现 A 的 term=1 等于自己的 term,但 A 已经是 Leader → B 退回 Follower
如果 C 先收到 B 的请求,那么 B 会成为 Leader。关键在于谁先拿到多数票。
Log Replication 流程
在分布式系统中,多个节点需要协同工作,但网络可能延迟、节点可能宕机。Log Replication 的核心目标是:让所有节点按照完全相同的顺序执行完全相同的命令,从而使它们的状态机最终保持一致。
客户端发命令给 Leader:
- Leader 把命令追加到自己日志里(还没提交)
- 发 AppendEntries 给所有 Follower
- 多数派返回成功 → Leader 把这条日志标记为 committed
- Leader 执行这条命令到自己的状态机,返回客户端
- 后续 AppendEntries 里带 leaderCommit,Follower 跟着提交、执行
日志结构每条长这样:
{logIndex, term, command}
-
logIndex:插槽号,单调递增
-
term:那条日志被创建时的 term
-
command:状态机指令(比如"玩家 A 对 boss 造成 100 伤害")
⭐ Raft 保证:如果两个节点在某个 logIndex 上的 term 相同 → 这条日志及之前所有日志完全一致。这是后面 Safety 的基石。
结合游戏场景理解
假设你的 MMO 游戏有 3 个场景服节点(Node A、B、C),它们共同维护一只跨服世界 Boss。玩家来自不同服务器,同时对 Boss 造成伤害。需要保证:
-
所有节点看到的 Boss 剩余血量完全一致(不能 A 显示 80%,B 显示 75%)。
-
伤害计算的顺序必须全局唯一(比如玩家 X 先攻击,玩家 Y 后攻击,不能颠倒)。
-
即使某个节点宕机,恢复后也能从其他节点同步正确的状态。
这就是典型的复制状态机(Replicated State Machine) 问题。Raft 的 Log Replication 就是用来解决这类问题的。
在 Raft 中,所有客户端请求(比如"玩家 X 对 Boss 造成 500 点伤害")都由 Leader 统一接收。Leader 将这些请求包装成日志条目(Log Entry),每条日志包含:
-
索引(index):日志在序列中的位置(单调递增)。
-
任期(term):该日志被创建时的 Leader 任期。
-
命令(command):具体的状态机操作,例如 {"type": "damage", "player_id": 12345, "boss_id": 1, "amount": 500}。
然后 Leader 通过 AppendEntries RPC 把这些日志条目复制到所有 Follower 节点。
完整流程
- 玩家发起攻击(场景服视角)
玩家在场景服 1 中攻击世界 Boss。
python
# 场景服1 接收到玩家攻击请求
def on_player_attack(player_id, boss_id, damage):
# 构造 Raft 写请求(不是直接写数据库)
request = {
"op": "damage",
"boss_id": boss_id,
"player_id": player_id,
"amount": damage,
"timestamp": now()
}
# 发送给 Raft 集群的 Leader(场景服需要知道谁是 Leader,可通过服务发现或重定向)
response = send_to_raft_leader(request)
if response.status == "OK":
# 告诉玩家攻击成功
return {"hp": response.current_hp}
else:
# 重试或报错
return {"error": "try again"}
- Raft Leader 处理写请求
Raft 集群的 Leader(假设是 Raft-A)收到请求后,执行标准的 Log Replication 流程:
python
# Raft-A (Leader) 处理写请求
def handle_write_request(request):
# 1. 将请求包装成日志条目
entry = LogEntry(
index = len(self.log) + 1,
term = self.current_term,
command = request
)
self.log.append(entry) # 未提交
# 2. 并行向 Follower (Raft-B, Raft-C) 发送 AppendEntries
for follower in [B, C]:
send_append_entries(follower, entries=[entry])
# 3. 等待多数派确认(包括自己)
success_count = 1
for reply in collect_replies(timeout=100ms):
if reply.success:
success_count += 1
if success_count >= 2: # 3节点多数派=2
# 4. 提交日志
self.commit_index = entry.index
# 5. 应用到状态机
self.apply_command(entry.command)
# 6. 返回结果给场景服(附带当前 Boss 血量)
return {"status": "OK", "current_hp": self.boss_hp}
else:
# 未达多数派,不提交,返回重试
return {"status": "RETRY"}
- Follower 复制并应用
Follower(Raft-B、Raft-C)收到 AppendEntries 后:
python
def handle_append_entries(args):
# 一致性检查、追加日志(略)
# 更新 commit_index 后,将新提交的日志应用到自己的状态机
while self.last_applied < self.commit_index:
self.last_applied += 1
entry = self.log[self.last_applied - 1]
self.apply_command(entry.command)
Safety ------ Raft 最难的一块
我学习的过程中,以为日志复制是为了保证任何一个节点被选中Leader后,数据和原Leader节点一致。
但日志复制不是为了"让新 Leader 数据正确",而是为了实现 复制状态机(Replicated State Machine)------即让集群中所有节点(包括未来的 Leader)按完全相同的顺序执行完全相同的命令,从而保证状态一致。
而保证任何一个节点被选为 Leader,数据一致,其实是选举限制的作用,而日志复制时选举限制的前提。
选举限制(Election Restriction)
Candidate 要想当选,它的日志必须至少和多数派里任意一个 Follower 一样新。
判断"谁日志更新":lastLogTerm 大的更新;term 相同比 lastLogIndex 长的更新。
👉 这就保证只有包含所有已提交日志的节点才能当选 Leader,不会选出个"数据不全的新 Leader"把已提交日志覆盖掉。
Leader 完整性
某 term 里某条日志被提交了 → 之后所有更高 term 的 Leader 都必须包含这条日志。
论文用反证法证明的,这个定理是 Raft 的命门。
状态机器安全
所有节点对同一个 logIndex 执行的状态机命令必须完全相同。
由上面的保证推导而来:committed 的日志不会变,所有节点按相同 index 顺序执行 → 状态一致。
日志冲突处理
现实情况:Follower 和 Leader 日志可能不一致,比如这样👇
Leader 日志:[1(1), 2(1), 3(2), 4(2), 5(3), 6(3), 7(3)]
Follower 日志:[1(1), 2(1), 3(2), 4(3), 5(3)](index4 term=3 与 Leader 的 term=2 冲突)
发现冲突
Leader 并不是主动"检查"Follower 的日志对不对,而是 Follower 自己在一致性检查时发现"对不上",通过返回 success=false 告诉 Leader"咱们这儿对不齐"。 Leader 收到 false 才知道"哦,这个 Follower 从某处开始跟我岔了"。
-
Leader 发 AppendEntries 时带了两个关键参数
AppendEntries RPC:
- prevLogIndex (比如 3)
- prevLogTerm (比如 2)
- entries[] (从 index6 开始的新日志)
Leader 的潜台词是:"我认为咱俩的日志在 index3 处是一致的(我这边 index3 的 term 是 2),请你检查一下,如果对得上,后面这几条我给你。
- Follower 收到后做一致性检查
Follower 会做两步:
-
第一步:我的日志长度够不够 prevLogIndex?不够 → 直接 success=false。
-
第二步:我 prevLogIndex 这个位置存在,但term 对不上 → success=false。
只有两步都过,Follower 才 success=true,并把 entries 追加进去。
- 冲突判定的具体逻辑
Leader 的 index4:term=2
Follower 的 index4:term=3
Leader 发 AppendEntries 时,假设 prevLogIndex=3, prevLogTerm=2,entries 从 index4 开始 index4(term=2), index5(term=3)...。
Follower 检查:
- index3 存在且 term=2 ✅(prevLogIndex 这一关过了)
然后看 entries 第一条要覆盖 index4:Follower 自己 index4 的 term=3 ≠ Leader 给的 term=2 → 冲突
处理:删掉自己 index4 及之后,换成 Leader 的
**这里注意:**prevLogIndex 那关是"对齐锚点",entries 追加时才是真正发现 term 冲突的地方。如果 prevLogIndex 本身就对不上(比如 Follower 日志更短,根本没有 index3),那连 entries 都不会处理,直接 false 回去。
- Leader 怎么知道"错了"、错在哪
原始实现:
python
if not response.success:
next_index[folower] -= 1 # 往前退一格,下次再试
这就是论文里说的"回溯算法"------效率不高,但实现简单,且冲突是少数情况,不影响常态性能。
优化: 让 Follower 多报点信息
为了减少回溯次数,Follower 返回 false 时可以多带两个字段:
-
conflict_term:Follower 在那个冲突 index 上的 term
-
conflict_index:该 term 在 Follower 日志里第一次出现的位置
Leader 拿到后可以直接把 nextIndex 跳到 conflict_index,不用一格一格退。但这是优化,不是协议核心。
处理冲突
-
Leader 发送 AppendEntries
Leader 选择 prevLogIndex=3, prevLogTerm=2(期望 Follower 的 index3 匹配),然后发送 entries = index4(2), index5(3), index6(3), index7(3)。
-
Follower 检查并处理冲突
-
检查 prevLogIndex:Follower 的 index3 存在且 term=2 ✅ 匹配。
-
遍历 entries:
-
第一条 entry:index4(2)。Follower 发现自己的 index4 已存在,但 term=3 ≠ 2 → 冲突。
-
处理:删除自己从 index4 开始的所有日志(即删除 index4 和 index5)。
-
然后将 entries 全部追加到日志末尾。
最终 Follower 日志变为:1(1), 2(1), 3(2), 4(2), 5(3), 6(3), 7(3),与 Leader 完全一致。
-
- 如果 prevLogIndex 处就不匹配(Follower 日志更短)
-
Leader 首次尝试 prevLogIndex=5, prevLogTerm=3 → Follower 没有 index5 → 返回 false。
-
Leader 将 nextIndex 减 1(变为 5),重试 prevLogIndex=4, prevLogTerm=2 → Follower 没有 index4 → 返回 false。
-
继续递减,直到 prevLogIndex=3, prevLogTerm=2 → Follower 有 index3 且 term 匹配 → 成功,然后追加从 index4 开始的全部日志。
集群成员变更
直接切 (S1,S2,S3) → (S1,S2,S3,S4,S5) 可能脑裂(两边各选各的 Leader)。
Raft 解法:Joint Consensus(两阶段):
-
先切到"旧配置 + 新配置"共同决策(需要两个多数派都同意)
-
再切到纯新配置
💡 游戏合服场景:老服 3 节点 → 新服 5 节点,用 Joint Consensus 可以避免合服过程中选出两个世界 boss 主节点。
日志压缩 / Snapshot
日志无限增长不行,Raft 用 Snapshot:
-
状态机把自己当前状态序列化存盘
-
日志里 ≤ snapshot 的条目可以砍掉
-
Follower 落后太多时,Leader 直接发 InstallSnapshot RPC 给它
⭐ MMO 映射:玩家数据每天凌晨做一次快照落 MySQL,Redis 里只留热数据 + 操作日志,本质就是 Snapshot + WAL 的思路。
完整示例代码
python
#!/usr/bin/env python3
"""
最简 Raft 实现,专注于领导者选举和心跳机制。
三个节点:node0、node1、node2。
每个节点在自己的线程中运行,通过队列通信。
"""
import threading
import time
import random
import sys
from collections import defaultdict
from queue import Queue
# ---------- 消息类型 ----------
MSG_REQUEST_VOTE = "RequestVote" # 请求投票
MSG_VOTE_RESPONSE = "VoteResponse" # 投票响应
MSG_APPEND_ENTRIES = "AppendEntries" # 附加日志(也用作心跳)
# ---------- 节点状态 ----------
FOLLOWER = "follower" # 跟随者
CANDIDATE = "candidate" # 候选者
LEADER = "leader" # 领导者
# ---------- 配置参数 ----------
NODE_IDS = [0, 1, 2] # 三个节点
HEARTBEAT_INTERVAL = 0.08 # 心跳间隔 80 毫秒
ELECTION_TIMEOUT_MIN = 0.15 # 选举超时下限 150 毫秒
ELECTION_TIMEOUT_MAX = 0.30 # 选举超时上限 300 毫秒
class RaftNode:
"""Raft 节点类"""
def __init__(self, node_id, peers, inbox):
self.node_id = node_id # 本节点编号
self.peers = peers # 其他节点编号列表
self.inbox = inbox # 接收消息的队列
self.outboxes = {} # 目标节点编号 -> 队列
# 持久化状态(理论上存储在稳定存储中)
self.current_term = 0 # 当前任期
self.voted_for = None # 在当前任期投给了谁
self.log = [] # 日志(此处简化,不存储实际命令)
# 易失状态
self.state = FOLLOWER # 初始状态为跟随者
self.leader_id = None # 当前已知的领导节点
self.last_heartbeat_time = time.time() # 上次收到心跳的时间
self.election_timeout = self._random_timeout() # 本次选举超时时长
# 候选者专用
self.votes_received = 0 # 已获得的票数
# 打印时的锁(避免输出混乱)
self.lock = threading.Lock()
def _random_timeout(self):
"""生成一个随机的选举超时时间(150~300ms)"""
return random.uniform(ELECTION_TIMEOUT_MIN, ELECTION_TIMEOUT_MAX)
def set_outbox(self, peer_id, queue):
"""设置发送给指定节点的队列"""
self.outboxes[peer_id] = queue
def send(self, target_id, msg):
"""向目标节点发送消息(放入其收件箱)"""
self.outboxes[target_id].put(msg)
def broadcast(self, msg):
"""向所有其他节点广播消息"""
for pid in self.peers:
self.send(pid, msg)
def log_info(self, msg):
"""打印带有时间戳和节点信息的日志"""
ts = time.strftime("%H:%M:%S", time.localtime())
with self.lock:
print(f"[{ts}] Node{self.node_id} ({self.state}, term={self.current_term}): {msg}", flush=True)
# ---------- 消息处理器 ----------
def handle_request_vote(self, msg):
"""
处理 RequestVote 消息。
msg 格式:
{
'type': MSG_REQUEST_VOTE,
'term': 候选者的任期,
'candidate_id': 候选者编号,
'last_log_index': 候选者最后一条日志的索引,
'last_log_term': 候选者最后一条日志的任期
}
"""
candidate_term = msg['term']
candidate_id = msg['candidate_id']
# 如果候选者的任期小于自己的任期,拒绝投票
if candidate_term < self.current_term:
response = {
'type': MSG_VOTE_RESPONSE,
'term': self.current_term,
'vote_granted': False,
'voter_id': self.node_id,
'candidate_id': candidate_id
}
self.send(candidate_id, response)
return
# 如果候选者的任期大于自己的任期,转为跟随者并更新任期
if candidate_term > self.current_term:
self.current_term = candidate_term
self.state = FOLLOWER
self.voted_for = None
self.leader_id = None
# 检查是否已经在本任期投过票
if self.voted_for is not None and self.voted_for != candidate_id:
response = {
'type': MSG_VOTE_RESPONSE,
'term': self.current_term,
'vote_granted': False,
'voter_id': self.node_id,
'candidate_id': candidate_id
}
self.send(candidate_id, response)
return
# 为简化,假设候选者的日志足够新(跳过日志比较)
self.voted_for = candidate_id
self.last_heartbeat_time = time.time() # 重置选举计时器
response = {
'type': MSG_VOTE_RESPONSE,
'term': self.current_term,
'vote_granted': True,
'voter_id': self.node_id,
'candidate_id': candidate_id
}
self.send(candidate_id, response)
self.log_info(f"投票给 Node{candidate_id}(任期 {self.current_term})")
def handle_vote_response(self, msg):
"""
处理 VoteResponse 消息。
msg 格式:
{
'type': MSG_VOTE_RESPONSE,
'term': 响应者的任期,
'vote_granted': 是否同意投票,
'voter_id': 投票者编号,
'candidate_id': 候选者编号
}
"""
# 只有候选者才关心投票响应
if self.state != CANDIDATE:
return
# 如果响应者的任期更大,则自己转为跟随者
if msg['term'] > self.current_term:
self.current_term = msg['term']
self.state = FOLLOWER
self.voted_for = None
self.leader_id = None
self.log_info(f"发现更高任期 {msg['term']},转为跟随者")
return
if msg['vote_granted']:
self.votes_received += 1
# 获得多数票(3节点中至少2票)则成为领导者
if self.votes_received >= len(NODE_IDS) // 2 + 1:
self.state = LEADER
self.leader_id = self.node_id
self.log_info(f"成为领导者(任期 {self.current_term})")
# 立即发送心跳
self.send_heartbeats()
def handle_append_entries(self, msg):
"""
处理 AppendEntries 消息(包括心跳)。
msg 格式:
{
'type': MSG_APPEND_ENTRIES,
'term': 领导者的任期,
'leader_id': 领导者编号,
'entries': 日志条目列表(心跳时为空),
'leader_commit': 领导者已提交的日志索引
}
"""
leader_term = msg['term']
leader_id = msg['leader_id']
# 如果领导者的任期小于自己的任期,拒绝
if leader_term < self.current_term:
response = {
'type': MSG_APPEND_ENTRIES,
'term': self.current_term,
'success': False
}
self.send(leader_id, response)
return
# 如果领导者的任期大于自己的任期,更新并转为跟随者
if leader_term > self.current_term:
self.current_term = leader_term
self.state = FOLLOWER
self.voted_for = None
# 接受心跳:重置选举计时器,确认领导者
self.leader_id = leader_id
self.last_heartbeat_time = time.time()
self.log_info(f"收到来自 Leader{leader_id} 的心跳(任期 {leader_term})")
# 回复成功(忽略日志一致性检查)
response = {
'type': MSG_APPEND_ENTRIES,
'term': self.current_term,
'success': True
}
self.send(leader_id, response)
def send_heartbeats(self):
"""领导者周期性地发送心跳"""
msg = {
'type': MSG_APPEND_ENTRIES,
'term': self.current_term,
'leader_id': self.node_id,
'entries': [],
'leader_commit': 0
}
self.broadcast(msg)
# ---------- 主循环 ----------
def run(self):
"""节点的主循环:处理消息、检查超时、执行状态动作"""
self.log_info("启动。")
while True:
# --- 处理所有待处理的消息(非阻塞) ---
while not self.inbox.empty():
msg = self.inbox.get_nowait()
if msg['type'] == MSG_REQUEST_VOTE:
self.handle_request_vote(msg)
elif msg['type'] == MSG_VOTE_RESPONSE:
self.handle_vote_response(msg)
elif msg['type'] == MSG_APPEND_ENTRIES:
self.handle_append_entries(msg)
else:
self.log_info(f"未知消息类型: {msg['type']}")
# --- 根据当前状态执行相应动作 ---
if self.state == FOLLOWER:
# 检查选举超时
elapsed = time.time() - self.last_heartbeat_time
if elapsed >= self.election_timeout:
self.start_election()
elif self.state == CANDIDATE:
# 候选者:如果超过选举超时仍未成为领导者,则重新开始选举
# 简单起见,我们在下一次循环中通过再次检查超时来实现
# 实际上我们需要一个单独的选举计时器,但这里我们利用
# 每次循环都会检查超时,如果超时则重新发起选举
# 但我们没有存储选举开始时间,所以用一个简单方法:
# 如果距离上次开始选举的时间超过 election_timeout,则重启
# 这里为了代码简洁,我们让候选者什么都不做,等待下一次循环
# 但为了防止死循环,我们引入一个计数器:如果连续多次未成功,
# 就重新开始选举。更简单的方法是:在 start_election 中记录时间,
# 并在每次循环中检查。为了不过度复杂,我们使用一个临时方案:
# 如果候选者状态持续超过 ELECTION_TIMEOUT_MAX,就重新选举。
# 这里我们不实现复杂的重试,而是依赖主循环的 sleep 和下次检查。
# 实际运行时,由于随机超时,很少会出现无限循环。
pass
elif self.state == LEADER:
# 领导者:定期发送心跳(这里在主循环中每隔 HEARTBEAT_INTERVAL 发送一次)
# 但由于主循环有 sleep,我们可以在每次循环中都发送,但那样太快了。
# 更好的做法是用一个单独的线程发送心跳,但为了简单,我们在每次循环中
# 检查时间,如果距离上次心跳超过 HEARTBEAT_INTERVAL 则发送。
# 这里我们简化:每次循环都发送(频率过高),但配合下面的 sleep 0.005 秒
# 实际发送间隔约为 5ms,远小于 80ms,可以接受。
# 更精确的做法是使用一个变量记录上次心跳时间。
# 我们在这里不做精确控制,因为演示目的足够了。
self.send_heartbeats()
# 短暂休眠,避免忙等待
time.sleep(0.005)
def start_election(self):
"""开始一轮新的选举"""
self.state = CANDIDATE
self.current_term += 1
self.voted_for = self.node_id
self.votes_received = 1 # 自己投给自己
self.last_heartbeat_time = time.time() # 重置计时器(不是必须)
self.leader_id = None
self.log_info(f"开始选举(任期 {self.current_term})")
# 向所有其他节点发送 RequestVote
msg = {
'type': MSG_REQUEST_VOTE,
'term': self.current_term,
'candidate_id': self.node_id,
'last_log_index': len(self.log),
'last_log_term': self.log[-1]['term'] if self.log else 0
}
self.broadcast(msg)
# 重置选举超时,为下一次可能的选举做准备
self.election_timeout = self._random_timeout()
def create_node(node_id, inbox):
"""创建一个 Raft 节点实例"""
peers = [pid for pid in NODE_IDS if pid != node_id]
node = RaftNode(node_id, peers, inbox)
return node
def main():
"""主函数:创建节点、分配队列、启动线程、运行一段时间后打印状态"""
# 为每个节点创建收件箱队列
inboxes = {pid: Queue() for pid in NODE_IDS}
# 创建节点并分配出件箱(每个节点持有其他节点的收件箱引用)
nodes = {}
for pid in NODE_IDS:
node = create_node(pid, inboxes[pid])
for peer_id in NODE_IDS:
if peer_id != pid:
node.set_outbox(peer_id, inboxes[peer_id])
nodes[pid] = node
# 在每个线程中启动节点
threads = []
for pid, node in nodes.items():
t = threading.Thread(target=node.run, daemon=True)
threads.append(t)
t.start()
# 让它们运行一段时间(10秒)
time.sleep(10)
# 打印最终状态
print("\n=== 最终状态 ===")
for pid, node in nodes.items():
print(f"Node{pid}: 状态={node.state}, 任期={node.current_term}, 领导者={node.leader_id}")
if __name__ == "__main__":
main()