资讯详情

Raft共识算法原理与工程实践:从选举到KV存储落地

📅 2026/9/16 3:15:49 | 华诺云谱 👁 阅读
Raft共识算法原理与工程实践:从选举到KV存储落地
1. Raft 是什么为什么我需要它做后端开发的这几年我越来越觉得“分布式系统”是绕不开的坎。一开始做微服务以为只要把服务拆开、多部署几个实例就万事大吉结果一遇到节点宕机、网络分区各种怪问题全冒出来数据对不上、主备切换丢失数据、多个副本同时写把状态搞乱。后来项目里要上强一致性的 KV 存储调研了一圈最终选了 Raft 共识算法落地。今天这篇就来聊聊我对 Raft 的理解以及在实操中把它做成一个可用 KV 存储的完整过程。Raft 是 Raft 共识算法用来解决分布式系统中多个节点对某个状态达成一致的问题。说人话就是一群机器组成一个集群它们必须对“谁当领导”“日志长什么样”“数据是什么”这些问题持有相同看法。Raft 的优势在于把共识问题拆成了几个相对独立的子问题领导者选举、日志复制、安全性、成员变更每个子问题都有明确的算法描述。比起同时代的 PaxosRaft 的可理解性和可工程化程度高了一大截这也是它后来成为 etcd、Consul、TiKV 等知名项目底层引擎的原因。这篇文章适合三类人一是刚接触分布式系统、被各种一致性协议绕晕的学生和初级开发者二是要在项目里引入强一致存储或自己做分布式中间件的工程师三是对 Raft 有基本概念但没亲手实现过、没踩过坑想知道实际工程里怎么处理细节的同行。先抛个结论Raft 本身不难理解难点在实现细节。论文《In Search of an Understandable Consensus Algorithm》把所有核心规则写得清清楚楚但真要把论文变成能稳定跑在生产环境的代码中间隔着无数个“卧槽这里居然会这样”。下面我会把原理、代码、排查经验全部串起来讲。2. Raft 核心机制拆解2.1 领导者选举谁说了算Raft 集群里每个节点有三种身份领导者Leader、跟随者Follower、候选人Candidate。正常运行时只有一个领导者负责接收客户端请求、把日志分发给其他节点其他节点都是跟随者被动接收领导者的心跳和日志。领导者挂了跟随者通过选举选出新的领导者。这里的关键点是任期Term。任期是一个单调递增的数字每次选举开始任期加 1。节点之间通过任期号判断消息的新旧发送方的任期号比自己小直接拒绝比自己大立刻转为跟随者。这个机制保证网络分区或节点重启后旧领导者的消息不会影响新领导者。选举过程其实很朴素跟随者一段时间没收到领导者心跳选举超时通常 150-300ms 随机就变为候选人给自己投票然后向其他节点发请求投票 RPC。每个节点在一个任期内只投一票先到先得。候选人收到超过半数的选票就成了新领导开始发送心跳告诉其他人“我活着别选了。”比较隐蔽的坑有两个。第一选举超时要随机化否则多个节点同时超时同时发起选举票数分散谁也选不出来反复触发新任期集群一直处于无主状态。第二投票时要检查候选人的日志是否足够新先比任期再比日志索引不然一个落后很多的节点也可能被选成领导者导致日志被大规模覆盖数据丢失。2.2 日志复制数据怎么保持一致日志是 Raft 里最核心的抽象。每个节点维护一份日志条目列表每个条目包含任期号和操作命令。客户端写请求到达领导者领导者不直接应用命令而是先把命令追加到自己的日志里然后并行发送 AppendEntries RPC 给所有跟随者。跟随者收到日志后做一致性检查如果自己日志中和新日志冲突的条目任期号不同或者缺少对应条目就拒绝。领导者收到多数节点的成功响应后把这条日志标记为已提交committed然后把结果返回给客户端并在下一条心跳里通知所有节点提交这条日志。这就是 Raft 的“先复制、后提交”机制。这里必须强调一个概念已复制不等于已提交。只有多数节点确认写入的日志才叫已提交已提交的日志绝不会丢。反过来未提交的日志在领导者切换时可能被覆盖。理解这一点你才能解释为什么 Raft 对故障的容忍度是“少于半数节点挂掉”。三节点集群最多挂一个五节点集群最多挂两个因为只有多数派存在才能选举新的领导并继续提交日志。实现日志复制时要特别注意日志匹配属性如果两条日志条目的索引和任期相同那么这两条及之前的所有日志完全相同。这个属性是从“只有领导者能把日志发给别人”推导出来的也是整个安全性证明的基石。每次 AppendEntries 都带上前一条日志的索引和任期就是为了逐条校验一致性。2.3 安全性怎么保证不丢数据光有选举和日志复制Raft 还不能用。举个例子一个落后的节点当上领导它的日志里可能缺少一些已提交的条目如果它以自己为准覆盖别人的日志已提交的数据就丢了。Raft 用三条规则堵住这些漏洞。第一条是领导者必须包含所有已提交日志。选举投票时候选人必须把自己的最后一条日志任期和索引发给对方接收方只投给日志更新的候选人。第二条是只允许领导者覆盖冲突日志不允许跟随者反过来影响领导者。第三提交旧任期日志时不能直接提交必须等当前任期的日志先提交通过“当前任期日志已提交”来间接确认旧日志也提交了。工程实现里安全性问题的集中爆发点在领导者切换后。新领导者一上台立刻面对一堆日志不一致的跟随者需要用日志匹配机制逐个找到与自己的分叉点把分叉点之后的日志全部删掉替换成自己的日志。这个流程没有优化的时候是非常痛的如果分叉点很靠前新旧日志差别很大跟随者每轮只能回退一条恢复时间会非常长。之前做过压测模拟五节点集群里杀掉领导者再让新领导者和一个掉队很远的节点同时恢复极端情况下日志同步能拖到几十秒。后来参考 etcd 的做法引入快速回退优化AppendEntries 被拒绝后根据冲突信息直接回退到冲突任期的最早一条大大缩短了对齐时间。后面的 KV 存储实现里我就是这么做的。3. 用 Raft 实现一个 KV 存储3.1 整体架构设计理论上讲完直接上实践。我实现了一个基于 Raft 的 KV 存储功能包括 Get、Put、Delete支持集群节点动态配置。语言选的 Go因为 goroutine 和 channel 特别好表达 Raft 的并发模型而且 Go 生态里有很多可以参考的现成实现比如 etcd 的 raft、stathat 的 raft。整体架构分三层Raft 层负责领导者选举、日志复制、快照、成员变更不关心日志内容是什么。状态机层KvStore维护一个 map[string]string按顺序应用来自 Raft 的指令。应用层HTTP/gRPC 接口接收客户端请求转发给 Raft 层等待提交后返回结果。这种分层的好处是Raft 的日志复制机制和实际的存储逻辑完全解耦。以后想在 Raft 上跑配置管理系统、分布式队列只要换掉状态机层就行Raft 层不用动。通信我直接用了 gRPC定义了三个 RPCRequestVote、AppendEntries、InstallSnapshot。论文里的 RPC 是抽象的具体用什么通信框架无所谓关键是把字段传对。有人用 HTTP 实现也有人用 TCP 自定义协议本质都一样。数据目录我打成快照管理快照和日志的协调逻辑放在 Raft 层状态机只暴露 GetStateSnapshot、RestoreSnapshot 两个方法。快照生成时机日志超过 10000 条且距离上次快照超过 10 分钟。条件触发后把当前状态机的完整数据序列化写入新文件然后可以安全地丢弃旧日志。3.2 核心代码结构我的项目结构大致如下kvstore/ ├── raft/ │ ├── node.go // 节点状态、任期、投票逻辑 │ ├── log.go // 日志存储、快照管理 │ ├── election.go // 领导者选举 │ ├── replication.go // 日志复制与心跳 │ └── persistence.go // 持久化相关 ├── kv/ │ ├── store.go // KV 状态机 │ ├── commands.go // 指令序列化/反序列化 │ └── snapshot.go // 快照生成和恢复 ├── server/ │ ├── grpc_server.go // gRPC 服务 │ ├── client.go // 客户端 SDK │ └── config.go // 配置解析 └── main.go关键数据结构type ApplyMsg struct { CommandValid bool Command []byte CommandIndex int } type LogEntry struct { Term int Command []byte } type RaftNode struct { mu sync.Mutex peers []*grpc.ClientConn currentTerm int votedFor int state NodeState log []LogEntry commitIndex int lastApplied int nextIndex []int matchIndex []int applyCh chan ApplyMsg }状态机层实现的指令集type CommandType int const ( CmdPut CommandType iota CmdDelete CmdGet ) type Command struct { Type CommandType Key string Value string }有一点要注意Get 请求也要走 Raft 吗严格的线性一致性读答案是“要”。如果在领导者本地读可能在领导者已经和集群失去多数派联系脑裂场景时返回旧数据。最安全的做法是让 Get 请求也作为一条日志走一遍复制流程但这性能非常差。工程上常见折中方案是 ReadIndex领导者先确认自己仍然是领导者和多数派通信得到当前 commitIndex等状态机应用到这个 index 后再读本地数据。我用的是 ReadIndex代码里在读取前加了一次“领导者确认”的 RPC 调用不复制日志性能好得多。3.3 读写请求的完整链路写请求链路客户端发送 Put(key, value) 到任意节点。如果收到请求的是跟随者它会把请求转发给领导者并告诉客户端领导者的地址。领导者把 Put 命令封装成 LogEntry追加到本地日志并行发给所有跟随者。领导者收到大多数节点的 AppendEntries 成功响应后把这条日志标记为已提交。领导者把这条命令应用到状态机然后把结果返回给客户端。跟随者在心跳中收到提交信息应用日志到自己的状态机。读请求链路ReadIndex 方式客户端发送 Get(key) 到领导者。领导者记录当前的 commitIndex。领导者向所有跟随者发送心跳确认自己还是领导者。拿到大多数节点响应后等待状态机的 lastApplied 追上 commitIndex。从本地状态机读取数据并返回。写请求的时延主要由两步组成网络 RPC 往返和日志持久化。我这里日志落地用独立 goroutine 批量写入把同步 fsync 的代价平摊到多条日志上。实测三节点同机房写延迟中位数在 2ms 左右P99 低于 5ms读延迟等于一次心跳 RPC 加本地读取P99 低于 1ms。压测工具用的自研脚本每秒发 10 万次请求开了 100 个并发连接24 小时压测没有出现数据不一致。4. 实操经验部署和调试 Raft 集群4.1 集群搭建与配置要点推荐三节点起步不要上来搭五节点。三节点可以容忍一个节点故障足够验证多数派机制五节点虽然可用性更高但调试时候的日志输出量和管理复杂度都会翻倍新手很容易迷失。配置里我重点调了几个参数参数推荐值说明ElectionTimeout300-500ms随机化初始值根据网络 RTT 调整心跳间隔可以设为 10 倍以上HeartbeatInterval50-100ms太快会增加 CPU 和带宽太慢会导致频繁选举最大日志批量64 条/批提高吞吐减少 RPC 次数快照间隔10000 条日志或 10 分钟间隔太短导致频繁做快照太长导致重放日志时间过长启动节点的顺序也有讲究先把三个节点都启动等它们两两发现彼此并选出领导者再对外提供读写服务。如果你先启动一个节点就立刻开始写请求这台节点会是独立领导者之后再启动其他节点需要等很久才能重新收敛选举期间写请求会失败。网络这块节点之间的 gRPC 调用我只设置了 3 秒超时重试用指数退避避免连接不断失败时产生大量重试风暴。角色切换时客户端连接要重新发现领导者所以服务端在返回的错误里带上当前领导者地址客户端 SDK 收到 NotLeader 错误就更新本地路由表。集群的持久化一定要做。Raft 论文里明确说了必须持久化 currentTerm、votedFor、日志条目。不持久化会出什么事我演示过重启一个刚投过票的节点它忘了自己投给谁重新发起选举因为任期号变回 0整个集群的日志一致性直接被破坏。生产环境用 etcd 那套日志写在 WAL 文件里元数据每次修改都 fsync快照单独存储通过索引和日志关联。开发环境为了性能可以关掉 fsync但要知道这是拿安全换速度。4.2 常见问题与排查实录在实际调试和运行 Raft 集群时我遇到了不少问题挑几个典型的说。问题一选举频繁触发集群一直没领导者表现是节点日志里全是“become candidate, term X”和“received vote from X”的刷屏但没有任何节点能稳定成为领导者。排查路径先看选举超时设置。如果所有节点的 ElectionTimeout 都一样它们会同时超时、同时投票票数被摊薄。解决方案是给超时加随机偏移范围一般在 150ms 到 300ms。另一个可能原因是节点之间网络不通互相收不到心跳各自都以为自己是“唯一幸存者”反复发起选举。这时候用网络工具检查端口连通性然后在 Raft 层打印 Term 和 Vote 信息定位。问题二日志复制阻塞写请求超时AppendEntriesRPC 一直被拒绝日志里出现大量“rejected append entries, conflict term X, conflict index Y”。这种情况通常发生在某个节点掉线后恢复它的日志落后太多。如果没有快速回退优化领导者会从最后一条日志开始逐条往前试网络慢一点就能拖垮整个集群。我的做法是在 AppendEntries 的响应里带上冲突任期和该任期的第一条索引领导者收到拒绝后直接把 nextIndex 跳到那个位置一次对齐。如果双方日志差异很大可以先发快照避免从头同步海量日志。问题三读请求返回了旧数据最典型的场景是网络分区发生后旧领导者在少数派分区新领导者在多数派分区。少数派分区的客户端连上旧领导查询数据旧领导仍然能响应但它已经无法确认自己的数据是最新的。解决方案就是前面提到的 ReadIndex。一定要在所有对外提供读服务的接口上强制加“领导者确认”流程否则仅仅在本地直接读 map 就是拿一致性开玩笑。还有一个更容易忽略的点客户端把请求转发给领导者时如果领导者刚好在这时挂了请求在哪个节点重放、重放几次都需要幂等处理。我的 KV 实现里给每个客户端的写请求加了 requestId状态机里用 requestId 去重避免 Put 被应用两遍。问题四磁盘满了日志一直写不进去这个比较隐蔽线上测试怎么跑都没事直到某个节点磁盘告警才发现在日志落盘时没有检查错误导致节点已经不具备持久化能力。Raft 节点写日志失败必须立刻把自身转为 Follower 并停止接受请求不能悄无声息地“忽略错误继续跑”。一个无法持久化日志的节点重启后就会丢日志重新加入集群时会污染的选举过程。问题五gRPC 连接池失效早期实现里每个节点维护一个固定连接长时间不通信后连接被服务端断开发送请求时拿到一个 STATUS_UNAVAILABLE 错误但又没有自动重建。后来改成连接池 健康检查的方式每次发送 RPC 前先检查连接状态无效连接主动重建。这个改动解决了我 80% 的节点间通信“偶发失败”问题。4.3 性能与稳定性优化心得性能优化要基于真实链路逐段分析。我最开始实现的 Raft 集群在四节点压测下写吞吐非常低后来做 profile 发现主要瓶颈不是网络而是两个点日志批量太小导致领导者发送非常频繁每个日志条目都单独做 fsync磁盘 IO 成了硬瓶颈。针对第一个问题我把领导者的复制循环改成了批量发送累积到积压日志条数超过 64 条或时间超过 50ms 才触发一次 AppendEntries实测吞吐提升约 3 倍。积压日志的缓冲区要做背压控制否则消费者速度跟不上内存无限增长。针对第二个问题我引入了日志批量 fsync跟随者收到一批日志后先把数据写入 PageCache每隔几毫秒或攒够多少字节再刷盘。这个优化牺牲了一定的故障恢复窗口最多丢几毫秒日志但对于大部分业务场景是可接受的数据保护级别。如果业务要求真正做到“一条日志都不丢”必须同步 fsync 多副本冗余性能和安全性自己权衡。稳定性方面我专门写了一个故障注入测试脚本随机做这些事情随机 kill 某个节点随机暂停节点的网络模拟网络分区随机让节点磁盘写满或延迟随机对节点做大规模日志落后再恢复跑了几百次观察集群能否在预期时间内恢复一致性和可用性。这个脚本帮我找到了多个隐藏 bug比如一个跟随者从分区中恢复后会短暂保留一个旧领导者发送的高任期号导致集群不断触发新的选举。归根结底Raft 算法本身是经过证明的但工程实现里的每一个 bug 都可能打破算法的隐含假设。故障注入和混沌测试是在上线前唯一能给你底气的工具。注意如果你只是想把 Raft 用起来而不是自己实现一遍强烈建议直接用 etcd 的 raft 库或者 Hashicorp 的 Raft 库不要重复造轮子。自己实现 Raft 最大的价值是理解原理而不是为了生产环境省几个依赖。5. 更进一步Raft 和 Fabric 网络能产生什么联系项目中我研究过基于 Raft 的 KV 存储也看过超级账本 Fabric 网络的共识模块。Fabric 的排序服务在 2.x 版本中引入了 Raftetcdraft用来对交易排序并保证节点之间的账本状态一致性。这个案例能说明 Raft 在联盟链场景中的选型思路。Fabric 网络里的 Raft 服务不是直接处理智能合约的执行而是负责对交易提案进行排序、打包成区块再把区块分发给所有 peer 节点。每个运行 Raft 排序服务的节点都是 OSNOrdering Service Node它们之间通过 Raft 选举出一个领导者领导者接收客户端提交的交易通过日志复制让所有 OSN 就“交易的先后顺序”达成一致。排序服务产出区块的顺序一旦确定所有 peer 节点按相同顺序执行交易最终账本状态就一致。从这个设计里能看到 Raft 的两个明显优势首先它比 Kafka 排序服务少依赖一个外部协调系统不存在“需要额外维护 ZooKeeper 集群”的问题。其次Raft 的“多数派确认”天然适配联盟链“少于半数节点故障不影响可用性”的底线要求。比如一个三节点的 Orderer 集群一个节点宕机Raft 依旧能出块。我参考 Fabric 的做法在自研的 KV 存储里也加入了“多租户”的概念。每个租户对应一个独立的 Raft 状态机实例日志目录、快照、元数据互相隔离。这样某个租户因为自身压力大产生大量日志时不会阻塞其他租户的写入。实现上就是在状态机层加了一层租户 ID 的前缀指令序列化时把租户 ID 放在头部Raft 层照样透明处理。这个设计和 Fabric 里 peer 节点按 channel 切分账本、排序服务按 channel 切分共识组的思想是一致的。如果你对联盟链和分布式系统的结合感兴趣不用一头扎进加密算法和智能合约先理解排序服务里 Raft 的角色能让你对整个网络的运行模式有更踏实的感觉。共识算法在大多数区块链项目里的作用不是“创造信任”而是“在分布式节点之间提供一种可靠、可解释的顺序保证”。6. 个人实践总结Raft 这个算法最让我佩服的地方是它在工程落地方便性和理论严谨性之间找到了平衡点。论文不长伪代码也很清晰但每当你觉得“行了就这么实现吧”线上运行一段时候就会冒出一个超时、一个分区、一次磁盘抖动的极端情况把你的实现按在地上摩擦。想真正掌握 Raft唯一的路就是亲手写一遍写的过程中你会被迫想清楚日志是干什么的、任期为什么不能乱、多数派到底为什么能保障安全。我个人在实际操作中的体会是如果你在一个已有系统里引入 Raft不要先写代码先把你现在的状态机能力画出来。哪些操作是纯查询哪些会改变状态哪些请求需要在提交后返回哪些错误需要透明重试。把这些画清楚Raft 接入会顺利很多。这一步没想明白写完 Raft 层再去改业务接口会非常痛苦。最后再分享一个小技巧调试 Raft 集群时在日志里加一个强制关联字段每次 RPC 都带上任期的 Html 配色方案节点号、任期号、日志索引这三者组合起来当成一个“trace”。这样出问题时直接筛出相关节点按任期和索引排序整个集群发生了什么一目了然。我刚调通第一个 Raft 集群时就是靠这种字符串日志理清了三次连续选举之间每个节点到底做了什么。Raft 的研究和实现到这里还远没结束。后面我打算继续在快照压缩、成员变更、网络分区恢复策略上做更细的优化也会尝试把日志存储从本地文件换成带持久化语义的消息队列让 Raft 层彻底不关心存储实现。这条路走完我对分布式一致性的理解应该会更扎实到时候再写一篇续篇争取把更细的坑都踩一遍告诉你。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。