游戏后端分布式学习——一致性协议Raft

以下先按照mmo游戏进行分布式学习,后续章节都是如此。

参考资料

raft中文论文

概念

一致性算法允许一组机器像一个整体一样工作,即使其中一些机器出现故障也能够继续工作下去。

相关算法

  • 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

  1. RequestVote(选举用)

Candidate 发给其他人,参数:

  • term:候选人的 term

  • candidateId

  • lastLogIndex / lastLogTerm:候选人最后一条日志的索引和 term(用来比谁日志更全,这个后面 Safety 要用)

返回:

  • 是否同意(voteGranted)

  • 当前 term(候选人发现自己落后就退位)

  1. AppendEntries(日志复制 + 心跳,复用)

Leader 发给 Follower,参数:

  • term、leaderId

  • prevLogIndex / prevLogTerm:关键!​ 新日志条目前一条的 index 和 term,用来做一致性检查

  • entries\[\]:要追加的日志(心跳时为空)

  • leaderCommit:Leader 已提交的日志索引,告诉 Follower"你也可以提交到这儿了"

返回:

  • success:如果 Follower 的 prevLogIndex 处 term 不匹配 → false(触发 Leader 回退重试)

  • term

Leader选举流程

  1. Follower 超时(Election Timeout,150-300ms 随机)没收到心跳 → 转 Candidate
  2. currentTerm + 1,投自己一票,发 RequestVote 给所有人
  3. 收到多数派同意 → 转 Leader,立即发空 AppendEntries(心跳)压住其他人
  4. 收到别人心跳且对方 term >= 自己 → 转 Follower
  5. 选举超时没结果(分裂票)→ 重新来过(随机超时是关键,避免活锁)

⭐ 随机超时是 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:

  1. Leader 把命令追加到自己日志里(还没提交)
  2. 发 AppendEntries 给所有 Follower
  3. 多数派返回成功 → Leader 把这条日志标记为 committed
  4. Leader 执行这条命令到自己的状态机,返回客户端
  5. 后续 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 造成伤害。需要保证:

  1. 所有节点看到的 Boss 剩余血量完全一致(不能 A 显示 80%,B 显示 75%)。

  2. 伤害计算的顺序必须全局唯一(比如玩家 X 先攻击,玩家 Y 后攻击,不能颠倒)。

  3. 即使某个节点宕机,恢复后也能从其他节点同步正确的状态。

这就是典型的复制状态机(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. 玩家发起攻击(场景服视角)

玩家在场景服 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"}
  1. 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"}
  1. 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 从某处开始跟我岔了"。

  1. Leader 发 AppendEntries 时带了两个关键参数

    AppendEntries RPC:

    • prevLogIndex (比如 3)
    • prevLogTerm (比如 2)
    • entries[] (从 index6 开始的新日志)

Leader 的潜台词是:"我认为咱俩的日志在 index3 处是一致的(我这边 index3 的 term 是 2),请你检查一下,如果对得上,后面这几条我给你。

  1. Follower 收到后做一致性检查

Follower 会做两步:

  • 第一步:我的日志长度够不够 prevLogIndex?不够 → 直接 success=false。

  • 第二步:我 prevLogIndex 这个位置存在,但term 对不上​ → success=false。

只有两步都过,Follower 才 success=true,并把 entries 追加进去。

  1. 冲突判定的具体逻辑

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 回去。

  1. 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,不用一格一格退。但这是优化,不是协议核心。

处理冲突

  1. Leader 发送 AppendEntries

    Leader 选择 prevLogIndex=3, prevLogTerm=2(期望 Follower 的 index3 匹配),然后发送 entries = index4(2), index5(3), index6(3), index7(3)

  2. 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 完全一致。

  1. 如果 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(两阶段):

  1. 先切到"旧配置 + 新配置"共同决策(需要两个多数派都同意)

  2. 再切到纯新配置

💡 游戏合服场景:老服 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()
相关推荐
遇乐的果园1 小时前
前端学习笔记-vue状态管理优化
前端·笔记·学习
从零开始的代码生活_1 小时前
C++ 继承详解:访问控制、对象模型、菱形继承与设计取舍
开发语言·c++·后端·学习·算法
可乐奶茶sky2 小时前
AI Agent 学习
人工智能·学习
茯苓gao2 小时前
嵌入式开发笔记:EtherCAT协议从硬件到软件完整配置指南——从零搭建一套EtherCAT通信系统
笔记·嵌入式硬件·学习
RD_daoyi4 小时前
外链权重暴跌至13%,品牌提及反超传统链接——2026年不做Digital PR,你的独立站等于隐形
运维·网络·学习·机器学习·搜索引擎
chase。4 小时前
【学习笔记】PointWorld:迈向通用机器人操控的3D世界模型
笔记·学习·机器人
AOwhisky5 小时前
Python 学习笔记(第十三期)——运维自动化(下·前篇):远程命令执行——paramiko基础篇
运维·python·学习·云原生·自动化·运维开发·paramiko
前端世界5 小时前
MySQL 基础入门(学习第4天)——约束与 DML 操作详解
数据库·学习·mysql
dear_bi_MyOnly5 小时前
【SpringBoot配置文件】
java·spring boot·后端·学习·spring·java-ee·学习方法
AOwhisky6 小时前
Python 学习笔记(第十四期)——运维自动化(下·中篇):远程文件传输——paramiko进阶篇
运维·python·学习·云原生·自动化·文件传输·paramiko