hashicorp raft源码分析(一、项目介绍与Leder选举实现)
hashicorp raft源码分析(一、项目介绍与Leder选举实现)
本文基于 hashicorp/raft
v1.7.3
版本进行源码分析API手册:https://pkg.go.dev/github.com/hashicorp/raft
源码地址:hashicorp/raft
raft论文中文解读:https://github.com/maemual/raft-zh_cn/blob/master/raft-zh_cn.md
在阅读文章前需要有一定的 raft 基础, 不然直接看源码会一头雾水.
一、项目背景:什么是 Raft?
在聊代码结构前,先简单回顾 Raft 算法的基本思想:
Raft 是一种分布式一致性算法(Consensus Algorithm),用于在分布式系统(如集群、多节点存储系统)中保证数据一致性和高可用性。
相比 Paxos 更
易理解、易实现
,Raft 将一致性问题分解为:
- Leader 选举(Leader Election):集群中选出一个 Leader 节点负责日志复制。
- 日志复制(Log Replication):Leader 接收客户端写请求,记录到自己的日志,并同步到 Follower 节点。
- 安全性(Safety):确保所有节点最终状态一致,防止脑裂、日志冲突。
Hashicorp 的 Raft 是 Raft 论文(In Search of an Understandable Consensus Algorithm)的Go语言实现,被广泛应用在:
- Hashicorp 的 Consul、Vault、Nomad
- 类似 etcd(另一个 Raft 实现)
二、项目结构
1 |
|
关键子目录/文件说明:
raft.go
:核心!实现了Raft算法的主要逻辑,包括:- 节点状态切换(成为候选人、领导者、跟随者)
- 心跳机制(Leader发送心跳维持领导权)
- 选举定时器(触发选举)
- 日志复制(Leader向Follower同步日志)
- 状态机应用(
FSM.Apply()
)
log.go
&log_store.go
: Raft日志的存储与管理:Log
结构体(Index、Term、Command)LogStore
接口(持久化日志的Append()
、Get()
等)- 默认内存实现(
inmem_store.go
)和BoltDB实现(raftboltdb/
)用于持久化
fsm.go
:有限状态机接口,使用者需实现:Apply(logEntry []byte) interface{}
(应用日志到状态机)Snapshot() (FSMSnapshot, error)
(创建快照)Restore(snapshot io.ReadCloser) error
(从快照恢复)
snapshot.go
&file_snapshot.go
: 快照的创建、传输、恢复逻辑:SnapshotSink
(写快照到文件)SnapshotStore
接口(支持文件系统、内存快照)
transport.go
&net_transport.go
:网络通信层Transport
接口(AppendEntries()
、RequestVote()
等RPC)- 基于TCP的默认实现(
NetTransport
),封装了encoding/gob
序列化
future.go
: 异步操作机制:- 提交日志(
raft.Apply()
)时返回Future
对象 - 可阻塞等待操作结果(成功/失败)
- 提交日志(
整体结构特点:
- 接口与实现分离:如
LogStore
、SnapshotStore
、Transport
等关键组件均为接口,方便替换存储引擎(内存/BoltDB)或网络层(TCP、自定义) - 分层清晰:
- 最底层:
StableStore
(任期、投票持久化)、LogStore
(日志存储) - 中间层:
Raft
状态机(选举、日志复制) - 上层:
FSM
(业务状态机,由用户实现)
- 最底层:
- 大量使用Go的
goroutine
+channel
:- 后台心跳/选举定时器
- 处理RPC调用
- 日志异步复制
三、Leader选举
在raft算法中,典型的领导者选举在本质上是节点状态的变更。具体到raft源码中,领导者选举的入口函数就是run()
,在raft.go中以一个单独的协程运行,来实现节点状态的变更
在下面的实现代码中,可以看到Follower
、Candidate
和Leader
3 种节点状态都有分别对应的功能函数
1 |
|
1.1 follower跟随者运行逻辑
主要处理逻辑:
接收 RPC 请求 (
<-r.rpcCh
): 这是 Follower 接收外部通信的主要方式。当收到 RPC 时,将其交给r.processRPC
方法处理。Follower 主要处理以下几种 RPC:AppendEntriesRequest
: 接收 Leader 发来的日志条目同步请求或心跳信号。RequestVoteRequest
: 接收 Candidate 发来的投票请求。RequestPreVoteRequest
: 接收 Candidate 发来的预投票请求(如果启用了 Pre-Vote 优化)。InstallSnapshotRequest
: 接收 Leader 发来的快照传输请求。TimeoutNowRequest
: 接收强制立即超时的请求。
接收配置变更请求 (
<-r.configurationChangeCh
): Follower 节点不能发起配置变更。因此,收到此类请求时,直接回复ErrNotLeader
错误。接收日志应用请求 (
<-r.applyCh
): Follower 节点不能直接处理外部的日志应用请求。日志的应用是由 Leader 驱动的commitIndex
前进后,由 Raft 内部机制完成的。因此,收到此类请求时,也直接回复ErrNotLeader
错误。心跳定时器超时 (
<-heartbeatTimer
): 这是 Follower 检测 Leader 是否存活的关键机制。定时器超时后,首先重新启动一个新的随机化心跳定时器。
然后,检查距离最后一次与 Leader 成功通信的时间 (
r.LastContact()
) 是否超过了心跳超时时间。如果最近与 Leader 有过联系 (时间差小于超时时间): 说明 Leader 仍然活跃,这只是一个正常的定时器事件,Follower 继续保持 Follower 状态,循环继续。
如果最近与 Leader 没有联系 (时间差大于等于超时时间):
说明 Leader 可能已经失效或网络有问题,Follower 认为 Leader 失联。
清空当前已知的 Leader 信息。
进行选举资格检查:
在转换为 Candidate 发起选举之前,Follower 会检查自己是否有资格参与选举:
- 检查是否有已知的配置 (
r.configurations.latestIndex == 0
)。如果没有,无法选举,记录警告并继续等待。 - 检查当前节点是否在稳定的配置中拥有投票权 (
!hasVote(r.configurations.latest, r.localID)
)。如果配置已稳定 (latestIndex == committedIndex
) 但自己没有投票权,则不能发起选举,记录警告并继续等待。
- 检查是否有已知的配置 (
如果通过选举资格检查 (有已知配置且自己有投票权):
- 记录警告日志,表明心跳超时并即将开始选举。
- 更新状态指标。
- 将节点状态设置为 Candidate (
r.setState(Candidate)
)。 - 退出
runFollower
函数 (return
)。这将导致外部调用者(通常是 Raft 的主协程)检测到状态变化,并调用处理 Candidate 状态的函数 (runCandidate
),开始新的选举流程。
接收关闭信号 (
<-r.shutdownCh
): 当 Raft 节点被要求关闭时,收到此信号,函数直接返回,结束主循环和 Follower 状态的运行。
相关源码:
1 |
|
processRPC 处理请求逻辑
1 |
|
hashicorp raft server/client 是使用 msgpack on tcp 实现的 rpc 服务, 关于 hashcrop raft transport server/client 的实现原理没什么可深入的, 请直接看代码实现. msgpack rpc 的协议报文格式如下.
appendEntries 同步日志
appendEntries
函数是 Raft 协议中 Follower 节点处理 Leader 发来的 AppendEntriesRequest
的核心方法。它的主要职责是根据 Leader 的请求更新 Follower 的状态、日志和提交索引。
核心流程:
- 初始化响应: 创建一个
AppendEntriesResponse
并设置默认值(通常是失败),包含当前节点的 Term 和最后一个日志索引。使用defer
确保函数退出时发送响应。 - Term 检查:
- 如果 Leader 的 Term 小于当前节点的 Term,则拒绝请求并返回(Follower 的 Term 在响应中)。
- 如果 Leader 的 Term 大于当前节点的 Term,或者当前节点不是 Follower (且不是领导权转移中的 Candidate),则更新当前节点的 Term 为 Leader 的 Term,并转换为 Follower 状态。
- 记录 Leader 信息: 保存 Leader 的地址和 ID。
- 日志一致性检查:
- 如果请求包含
PrevLogEntry
(> 0),则获取当前节点日志中相同索引条目的 Term。 - 将获取到的 Term 与 Leader 请求中的
PrevLogTerm
进行比较。 - 如果不匹配,说明日志发生分歧,拒绝请求,设置
NoRetryBackoff
为 true,并返回。 - 如果无法获取到
PrevLogEntry
索引处的日志(例如索引越界或存储错误),也拒绝请求,设置NoRetryBackoff
为 true,并返回。
- 如果请求包含
- 处理新日志条目:
- 如果请求包含
Entries
,遍历 Leader 发来的条目。 - 查找与当前节点日志发生冲突或 Leader 独有的新条目的起始点。
- 如果发现冲突(相同索引但 Term 不同),删除当前节点从冲突点开始的所有后续日志条目。如果在删除范围内的配置变更日志被移除,则回退最新的配置信息。
- 将 Leader 发来的从冲突点或当前节点最后一个日志索引之后开始的条目视为新的,并将其追加到日志存储中。
- 处理新追加的日志条目中的配置变更类型。
- 更新当前节点的最后一个日志索引和 Term。
- 如果请求包含
- 更新提交索引:
- 如果 Leader 的
LeaderCommitIndex
大于当前节点的CommitIndex
,则更新当前节点的CommitIndex
为min(LeaderCommitIndex, 当前节点的最后一个日志索引)
。 - 如果最新的配置变更日志索引小于等于新的提交索引,则将最新的配置提升为已提交配置。
- 将已提交但尚未应用的日志条目应用到状态机,
processLogs
应用日志。
- 如果 Leader 的
- 设置成功并更新最后联系时间: 如果所有检查和操作都成功完成,将响应的
Success
字段设置为 true,并更新记录最后一次与 Leader 成功联系的时间(用于重置选举定时器)。
简单说 appendEntries()
同步日志是 leader 和 follower 不断调整位置再同步数据的过程.
1 |
|
处理 RequestVoteRequest 投票请求
requestVote
函数处理来自其他 Raft 节点(候选者)的 RequestVote RPC 请求。其核心逻辑是根据 Raft 协议的选举规则决定是否授予投票。
核心流程:
前置检查:
- 检查候选者是否在当前配置中(如果提供了 ID)。如果不在且配置不为空,拒绝投票。
- 检查当前节点是否已知有其他领导者。如果已知且请求不是领导权转移,拒绝投票。
任期处理:
- 如果请求的任期小于当前任期,忽略该请求(不授予投票)。
- 如果请求的任期大于当前任期,更新当前节点的任期为请求任期,并转变为跟随者状态。更新响应中的任期。
投票者检查: 检查候选者是否是当前配置中的投票者(如果提供了 ID)。如果不是投票者且配置不为空,拒绝投票。
重复投票检查: 从持久化存储中获取上次投票的任期和候选者。如果在当前请求的任期内已经投过票,并且上次投票的候选者就是本次请求的候选者,则再次授予投票(处理幂等性);否则(投给了其他候选者或任期不同),不授予投票。
日志匹配检查:
检查候选者的日志是否至少和本地日志一样新。
- 如果本地日志的最后任期大于候选者的,拒绝投票。
- 如果本地日志的最后任期与候选者的相同,但本地日志的最后索引大于候选者的,拒绝投票。
授予投票:
如果通过了所有前面的检查,表示可以授予投票。
- 关键步骤: 在授予投票 之前,将本次投票的任期和候选者 ID 持久化到稳定存储中,确保安全性。
- 设置响应中的
Granted
字段为 true。 - 更新本地的最后联系时间。
返回: 函数结束,延迟函数发送包含投票结果的响应。
总的来说,requestVote
函数实现了 Raft 协议中跟随者(或其他状态节点)响应候选者投票请求的核心逻辑,包括任期处理、日志匹配检查、投票持久化以及处理配置变更和领导权转移等特殊情况。
1 |
|
1.2 candidate 候选者运行逻辑
主要流程步骤:
- 任期更新与初始化:
- 节点将当前任期(Term)加一,进入新的任期进行选举。
- 记录日志和更新监控指标,表明进入 Candidate 状态。
- 计算赢得选举所需的多数派票数 (
votesNeeded
)。
- 选举阶段选择(预投票 vs. 正式投票):
- 根据配置 (
preVoteDisabled
) 和是否为领导权转移 (candidateFromLeadershipTransfer
) 决定是先进行 预投票 (Pre-Vote) 还是直接进行 **正式投票 (RequestVote)**。 - 如果启用预投票且非领导权转移,调用
preElectSelf()
发起预投票 RPC,监听prevoteCh
。预投票不会改变任期,用于试探是否能获得多数支持。 - 否则,调用
electSelf()
发起正式投票 RPC,监听voteCh
。正式投票会携带新的任期号。
- 根据配置 (
- 设置选举超时:
- 设置一个随机化的选举超时定时器 (
electionTimer
),防止多个 Candidate 同时超时并竞选,减少冲突。
- 设置一个随机化的选举超时定时器 (
- 主循环与事件处理:
- 进入一个循环,持续监听各种事件,直到节点状态不再是 Candidate。
- 使用 select语句同时监听:
- Incoming RPCs (
r.rpcCh
): 处理来自其他节点的 RPC 请求(如 AppendEntries、RequestVote 等)。如果收到带有更高任期的 RPC,节点会立即转为 Follower 并更新任期,退出 Candidate 状态。 - 预投票结果 (
prevoteCh
):- 如果收到带有更高任期的预投票响应,转为 Follower 并更新任期,退出 Candidate 状态。
- 统计收到的预投票赞成/反对票数。
- 如果获得多数派预投票赞成,认为预投票成功,关闭预投票监听,发起正式投票 (
electSelf()
),重置选举定时器,开始等待正式投票结果。 - 如果被多数派预投票拒绝,预投票活动失败,继续等待当前的选举超时。
- 正式投票结果 (
voteCh
):- 如果收到带有更高任期的投票响应,转为 Follower 并更新任期,退出 Candidate 状态。
- 统计收到的正式投票赞成票数。
- 遍历配置中的 server 集合, 过滤出状态为 Voter 的 server, 最后通过
n/2 + 1
公式计算出法定投票数, 简单说就是绝大多数的节点数量.如果获得多数派正式投票赞成,赢得选举,转为 Leader 状态,设置自己为 Leader,退出 Candidate 状态。
- 配置变更请求 (
r.configurationChangeCh
): 由于 Candidate 不是 Leader,拒绝此类请求并返回错误。 - 选举超时 (
electionTimer
):如果在超时时间内未能赢得选举(无论是在预投票阶段等待,还是在正式投票阶段等待),则认为本次选举失败,函数return
。由于外部调用runCandidate
的逻辑通常在一个无限循环中,这将导致节点在新的任期中重新开始竞选过程。 - 节点关闭 (
r.shutdownCh
): 收到关闭信号,退出循环和函数。
- Incoming RPCs (
- 退出处理:
- 无论通过哪种方式退出
runCandidate
函数(成为 Follower、成为 Leader、shutdown、选举超时),都会执行defer
中设置的逻辑,将candidateFromLeadershipTransfer
标志重置为 false,防止其影响后续的选举行为。
- 无论通过哪种方式退出
1 |
|
1.3 leader 领导者运行逻辑
runLeader
为 leader 领导者的核心处理方法. 进入该函数说明当前节点为 leader.
startStopReplication
启动各个 follower 的 replication, 开启心跳 heartbeat 和同步 replicate 协程.- 构建一个 LogNoop 空日志, 然后通过
dispatchLogs
方法发给所有的 follower 副本. 这里的空日志用来向 follower 通知确认 leader, 并获取各 follower 的一些日志元信息. leaderLoop
为 leader 的主调度循环.响应 Follower 的复制进度、处理新的客户端请求、复制日志、管理集群配置变更、处理领导权转移、定期验证自身的 Leader 身份,并在需要时触发降级。
1 |
|
startStopReplication
启动日志复制机制:启动各个 follower 的 replication, 开启心跳 heartbeat 和同步日志.
为什么需要 leader 给 follower 发送心跳 ? raft 论文里有说明, 当 follower 在一段时间内收不到 leader 的心跳请求时, 则判定 leader 有异常, 切换到 candidate 进行选举 election.
关于replicate 如何进行日志复制,将在下一篇文章介绍
1 |
|
leaderLoop
leaderLoop
是 Leader 状态下的大脑,它不断监听和处理各种事件,核心职责包括:响应 Follower 的复制进度、处理新的客户端请求、复制日志、管理集群配置变更、处理领导权转移、定期验证自身的 Leader 身份,并在需要时触发降级。
初始化:
- 设置一个
stepDown
标志,用于标记 Leader 是否因配置变更(尤其是移除自身)等原因需要降级。 - 初始化 Leader Lease(租约)计时器,用于定期验证 Leader 身份。
- 设置一个
进入主循环:
- 节点进入一个无限循环,只要其状态保持为
Leader
就会持续运行。
- 节点进入一个无限循环,只要其状态保持为
事件驱动处理: 在循环中,通过
select
语句监听多个通道,处理来自外部或内部的各种事件:接收 RPC 请求 (
rpcCh
):处理来自其他节点的 RPC,包括:
- Follower 对 Leader 发送的 AppendEntries RPC 的响应(确认日志复制进度)。
- 来自其他节点的 RequestVote RPC(如果发现更高任期,Leader 会立即降级为 Follower)。
- 其他可能的 RPC。
内部降级信号 (
leaderState.stepDown
): 收到信号后,Leader 立即转为 Follower 状态,并退出循环。领导权转移请求 (
leadershipTransferCh
):- 处理手动触发的领导权转移请求。
- 检查是否已有转移正在进行。
- 选择或确认目标 Follower。
- 启动一个后台协程来执行转移逻辑(通常涉及等待日志同步并向目标 Follower 发送 TimeoutNow RPC)。
- 设置标志防止并行转移,并处理超时或 Leader 自身降级的情况。
日志提交通知 (
leaderState.commitCh
):- 当有日志条目被多数节点复制成功(达到可提交状态)时收到此通知。
- 更新 Leader 的
commitIndex
(已提交日志的最大索引)。 - 检查是否有新的配置变更日志被提交,如果 Leader 自己被移除,则设置
stepDown
标志。 - 从待提交队列 (
inflight
) 中找出所有已落后于commitIndex
的日志。 - 批量应用日志: 将这些已提交的日志批量应用到状态机 (FSM - State Machine),处理其副作用(如配置变更执行)。
- 清理已应用的日志。
- 如果
stepDown
标志被设置,根据配置选择关闭节点或降级为 Follower。
Leader 验证请求 (
verifyCh
):- 这是 Leader Lease 机制的一部分。Leader 定期向 Follower 发送验证请求。
- 处理来自验证请求的响应。如果未能获得多数节点的确认,说明可能已有新的 Leader 出现,当前 Leader 降级为 Follower。
用户快照恢复请求 (
userRestoreCh
): 处理从用户提供的快照恢复状态的请求。获取配置请求 (
configurationsCh
): 处理客户端获取当前集群配置的请求。配置变更请求 (
configurationChangeChIfStable()
):- 处理添加或移除节点等集群配置变更请求。
- 将配置变更作为特殊的日志条目追加到 Raft 日志中,并像普通日志一样复制和提交。
引导请求 (
bootstrapCh
): 拒绝运行时的引导请求,因为 Raft 只在初始状态允许引导。新日志条目请求 (
applyCh
):- 处理来自客户端的新的命令或数据请求。
- 批量提交优化: 尝试从通道中一次性读取多个待处理的客户端请求,进行批量处理。
- 如果
stepDown
标志已设置,拒绝新的日志请求。 - 将这些新的请求封装成日志条目,追加到 Leader 的日志中。
- 分发日志: 将这些新日志条目分发(通过 AppendEntries RPC)给所有 Follower,驱动日志复制过程。
Leader Lease 计时器超时 (
lease
):- 定期触发 Leader Lease 检查。
- 调用
checkLeaderLease
方法验证 Leader 身份。 - 根据检查结果调整下一个租约检查的间隔,并重置计时器。
- 如果租约验证失败,也可能触发降级。
通知通道 (
leaderNotifyCh
,followerNotifyCh
): 用于唤醒等待特定事件的内部协程。关机信号 (
shutdownCh
): 收到信号后,Leader 退出循环并停止运行。
退出: 循环在以下情况下退出:
- Leader 状态改变为 Follower(因发现更高任期、收到降级信号、Leader Lease 验证失败或自身被移除)。
- 节点收到关机信号。
1 |
|
dispatchLogs 日志调度派发
dispatchLogs
用来记录本地日志以及派发日志给所有的 follower. 数据来源主要通过leedloop中的applyCh实现新的日志信息
1 |
|
- 日志持久化:
- 将日志写入本地磁盘,确保日志的持久化。如果写入失败,Leader 节点会降级为 Follower 节点,并通知调用者操作失败。
- 状态更新:
- commitment.match 来计算各个 server 的 matchIndex, 计算出 commit 提交索引.
- 更新 Leader 节点的匹配索引(
match index
),表示本地节点已成功存储日志。 - 更新 Leader 节点的最后日志索引和任期信息。
- 触发日志复制:
- 异步通知所有 Follower 节点的复制器,触发日志复制流程,确保日志被同步到集群中的其他节点。
1 |
|
四、问题
- 问题 1:Raft 如何处理网络分区?
- 解答:Raft 通过领导人选举和多数派机制来处理网络分区。
- 分区形成: 当网络分区发生时,集群可能分裂成多个部分,每个部分都无法与多数节点通信。
- 选举: 在每个分区中,如果 Follower 节点在选举超时时间内没有收到 Leader 的心跳,它们会发起选举。
- 多数派: 只有包含多数节点的分区才能选出新的 Leader。少数派分区中的节点无法赢得选举,因为它们无法获得多数票。
- 旧 Leader: 如果旧 Leader 位于少数派分区,它会因为无法与多数节点通信而退位成 Follower。
- 数据一致性: Raft 保证,即使在网络分区的情况下,也只有一个分区能够提交新的日志条目,从而保证数据一致性。
- 分区恢复: 当网络分区恢复后,少数派分区中的节点会重新加入集群,并从新 Leader 那里同步最新的日志。
- 解答:Raft 通过领导人选举和多数派机制来处理网络分区。
- 问题 2:Raft 如何保证数据一致性?
- 解答:Raft 通过以下机制保证数据一致性:
- 强领导者: 只有 Leader 才能接受客户端请求并生成新的日志条目。
- 日志复制: Leader 将日志条目复制到所有 Follower。只有当多数 Follower 确认收到日志条目后,Leader 才会提交该条目。
- 仅追加日志: 日志条目只能追加到日志末尾,不能修改或删除。
- 选举限制: 只有拥有最新日志的节点才能成为 Leader。
- 提交限制: 只有 Leader 才能推进
commitIndex
,且commitIndex
只会单调递增。 - 状态机: 所有节点按照相同的顺序应用已提交的日志条目到状态机,保证状态机状态一致。
- 解答:Raft 通过以下机制保证数据一致性:
- 问题 3:Raft 中的
commitIndex
和lastApplied
有什么区别?- 解答:
commitIndex
: 表示已知已提交的最高日志条目的索引。这意味着索引小于或等于commitIndex
的所有日志条目都已安全地复制到多数节点,可以应用到状态机。lastApplied
: 表示已应用到状态机的最高日志条目的索引。每个节点独立维护自己的lastApplied
。- 关系:
lastApplied
通常小于或等于commitIndex
。当lastApplied
小于commitIndex
时,表示节点正在将已提交的日志条目应用到状态机。当两者相等时,表示状态机是最新的。
- 解答:
- 问题 4:Raft 如何处理客户端请求的幂等性?
- 解答:Raft 本身不直接处理客户端请求的幂等性。幂等性通常需要在客户端或应用层实现。一种常见的做法是:
- 客户端生成唯一 ID: 客户端为每个请求生成一个唯一的 ID(例如 UUID)。
- 服务器跟踪 ID: 服务器跟踪已处理的请求 ID。如果收到具有相同 ID 的重复请求,服务器可以直接返回之前的结果,而无需重新执行操作。
- 状态机: 在应用层,状态机可以记录已执行的请求 ID,以避免重复执行。
- 解答:Raft 本身不直接处理客户端请求的幂等性。幂等性通常需要在客户端或应用层实现。一种常见的做法是: