分布式协议(Raft)

148 阅读7分钟

基本概念

Raft协议是一种专为分布式系统设计的共识算法,旨在简化理解和实现(相对于Paxos),通过明确的领导选举、日志复制和安全性机制,确保多个节点间的数据一致性(强一致性, 主节点唯一写入)。

组成部分

我们把Raft的主要内容分解成领导选举日志复制和安全性几个部分。

1. 核心概念

  • 节点角色

    • Leader(领导者) :处理写请求,负责日志复制与管理、周期性发送心跳给跟随者。
    • Follower(跟随者) :接收Leader的指令(心跳、复制),响应选举请求。
    • Candidate(候选人) :在选举期间暂时存在的角色,尝试竞选Leader。
  • 任期(Term)

    • 全局递增的编号(如Term 1→2→3),用于标识不同的选举周期,每个Term最多一个Leader。
    • 用于标识Leader的合法性,防止旧Leader干扰新任期。
  • 日志(Log)

    • 由连续日志条目组成,每个条目包含命令、Term和唯一索引。
    • 日志一致性通过Leader复制到多数节点来保证。

选举流程

  1. 超时变为候选人:假设某集群3个节点,初始化所有节点的状态都是Follower,此时每个Follower都会初始化一个election timeout(选举超时定时器)和自身的任期(term=0),超时时间是随机的(150-300ms);
  2. 发起选举请求:由于没有领导者发送心跳包,一段时间后(选举超时时间),追随者会转变为候选人,增加自己的任期,给自己先投1票,同时向其他节点发起投票;请求投票 RPC 包含候选人的任期号、自身的日志信息等,用于告知其他节点自己正在竞选领导者;
  3. 其他节点响应
    • 追随者:追随者收到请求投票 RPC 后,会检查候选人的任期号和日志信息。

      • 如果候选人的任期号大于自己的任期号,并且追随者在当前任期内还没有投过票,且候选人的日志至少和自己一样新,追随者会给候选人投票,并重置自己的选举超时时间。
      • 否则,追随者会拒绝投票。
    • 其他候选人:如果其他候选人收到请求投票 RPC,且发现请求者的任期号大于自己的任期号,会将自己的状态转变为追随者,并更新自己的任期号。否则,拒绝投票。

  4. 统计投票结果
  • 候选人会等待投票请求的响应:
    • 如果候选人获得了超过集群节点总数一半的投票(即多数派投票),则赢得选举,成为新的领导者。
    • 如果等待过程中,候选人收到了来自其他节点的心跳包,且该节点的任期号不小于自己的任期号,说明已经有其他节点成为了领导者,候选人会将自己的状态转变为追随者,并重置自己的超时时间。
    • 如果没有候选人获得多数派投票,选举失败,所有候选人会重置选举超时时间,重新发起选举。
  1. 领导者上任
  • 新当选的领导者会立即向所有追随者发送心跳包(AppendEntries RPC),以宣告自己的领导地位,并开始正常的日志复制和客户端请求处理工作。
    • 追随者收到领导者的心跳包后,会确认领导者的身份,并开始响应领导者的请求。

Python伪代码

import time
import random

# 节点类
class Node:
    def __init__(self, node_id, all_nodes):
        self.node_id = node_id
        self.all_nodes = all_nodes
        self.role = 'Follower'
        self.term = 0
        self.vote_count = 0
        self.voted_for = None
        self.election_timeout = random.randint(150, 300) / 1000  # 选举超时时间(秒)
        self.last_heartbeat_time = time.time()

    def start_election(self):
        self.role = 'Candidate'
        self.term += 1
        self.vote_count = 1  # 自己投自己一票
        self.voted_for = self.node_id
        print(f"Node {self.node_id} becomes a candidate in term {self.term}")

        # 向其他节点发送请求投票 RPC
        for node in self.all_nodes:
            if node != self.node_id:
                response = self.send_request_vote(node)
                if response:
                    self.vote_count += 1

        # 统计投票结果
        if self.vote_count > len(self.all_nodes) // 2:
            self.role = 'Leader'
            print(f"Node {self.node_id} becomes the leader in term {self.term}")
            # 发送心跳告知其他节点
            self.send_heartbeats()
        else:
            # 本次投票未获得半数以上,重新发起投票
            # 需校验其他节点是否已成为主节点了
            if self.role == 'Candidate':
                # 重置超时时间
                self.election_timeout = random.randint(150, 300) / 1000
                self.start_election()

    def send_request_vote(self, target_node):
        # 模拟发送请求投票 RPC
        print(f"Node {self.node_id} sends RequestVote RPC to Node {target_node} in term {self.term}")
        # 模拟其他节点响应
        return random.choice([True, False])
        
    def receive_request_vote(self, vote_node):
        # 收到投票请求
        if self.role = 'Follower':
            if self.term == vote_node.term:
                if self.vote_count == 0# 注:除了校验任期和是否已投票外,需校验日志的进度要大于自己;
                  self.vote_count = 1
                  self.term = vote_node.term
                  return true
            else if self.term < vote_node.term
                  self.vote_count = 1
                  self.term = vote_node.term
                  return true
            return false
        else if self.role = 'Candidate':
            if self.term < vote_node.term:
                  self.role == 'Follower'
                  self.vote_count = 1
                  self.term = vote_node.term
                  return true
            return false
            

    def send_heartbeats(self):
        # 领导者发送心跳包
        while self.role == 'Leader':
            for node in self.all_nodes:
                if node != self.node_id:
                    print(f"Node {self.node_id} sends heartbeat to Node {node} in term {self.term}")
            time.sleep(0.1)  # 每秒发送一次心跳包

    def receive_heartbeat(self, term):
        if term >= self.term:
            self.role = 'Follower'
            self.term = term
            self.vote_count = 0 # 投票次数要清0
            self.last_heartbeat_time = time.time()
            print(f"Node {self.node_id} becomes a follower in term {term}")

    def run(self):
        while True:
            if self.role == 'Follower':
                if time.time() - self.last_heartbeat_time > self.election_timeout:
                    self.start_election()
                else:
                    # 重置超时
                    self.election_timeout = random.randint(150, 300) / 1000
            time.sleep(0.01)
            

# 初始化节点
nodes = [Node(i, [0, 1, 2]) for i in range(3)]

# 启动节点
for node in nodes:
    import threading
    threading.Thread(target=node.run).start()

问题:为何每个节点的超时时间是随机的?

为了提升选举效率,避免同时存在多个候选人,使得超时时间最小的节点优先开始选举。

问题:为什么建议集群的部署是奇数?

选举的结果是至少取得半数以上(N/2+1),如果是偶数,那么同时出现多个候选人的时候,出现平票的概率会更大。

日志复制流程

基本概念

  • 日志条目(Log Entry) :客户端的每个请求会被封装成一个日志条目,包含了客户端请求的命令、该日志条目对应的任期号(Term)等信息。
  • 日志索引(Log Index) :每个日志条目在日志中的位置,从 1 开始递增。

1. 客户端请求

  • 客户端向领导者(Leader)发送请求,领导者接收到请求后,将该请求封装成一个新的日志条目,并追加到自己的本地日志中。此时,该日志条目处于未提交(Uncommitted)状态。

2. 领导者复制日志

  • 领导者通过 AppendEntries RPC(远程过程调用)将新的日志条目复制到其他追随者(Follower)节点。AppendEntries RPC 包含以下关键信息:

    • 任期号(Term) :领导者当前的任期号。
    • 前一个日志条目的索引和任期号:用于确保追随者的日志与领导者的日志在该位置之前是一致的。
    • 新的日志条目:需要复制到追随者节点的日志条目。
    • 领导者的提交索引(Commit Index) :表示领导者已经提交的最大日志索引。

3. 追随者处理 AppendEntries RPC

  • 追随者接收到 AppendEntries RPC 后,会进行以下检查:

    • 任期号检查:如果追随者发现领导者的任期号小于自己的任期号,会拒绝该 RPC 并返回错误信息;如果任期号大于自己的任期号,追随者会更新自己的任期号并将自己的角色转变为追随者。
    • 日志一致性检查:追随者会检查前一个日志条目的索引和任期号是否与自己的日志匹配。如果不匹配,追随者会拒绝该 RPC 并告知领导者需要从哪个位置开始同步日志。
    • 追加日志条目:如果上述检查都通过,追随者会将新的日志条目追加到自己的本地日志中,并返回成功响应给领导者。

4. 领导者确认复制结果

  • 领导者收到追随者的响应后,会统计成功复制日志条目的节点数量。
  • 当超过半数的节点(包括领导者自己)成功复制了该日志条目时,领导者会将该日志条目标记为已提交(Committed),并更新自己的提交索引。

5. 提交已提交的日志条目

  • 领导者会再次通过 AppendEntries RPC 将提交索引的更新信息发送给追随者,告知追随者哪些日志条目已经可以应用到状态机中。
  • 追随者接收到更新后的提交索引后,会将本地日志中所有索引小于等于提交索引的未提交日志条目标记为已提交,并将这些日志条目对应的命令应用到自己的状态机中,从而更新本地数据。

6. 客户端响应

  • 当领导者将日志条目标记为已提交后,会向客户端返回请求处理结果,表示该请求已经成功处理。