Raft协议实现数据的分布式存储
Raft 是一种分布式共识协议。它本身不直接负责把数据存入磁盘,而是保证多个节点按照相同顺序执行相同的操作,从而让多个节点拥有一致的数据副本。
可以把它理解为:
客户端命令 ↓ Raft 复制日志 ↓ 多个节点按相同顺序执行日志 ↓ 每个节点得到相同的 KV 数据例如:
PUT user:1001 {"name":"张三"}Raft 要保证所有副本最终都按同样的顺序执行这条命令。
一、Raft 实现分布式存储的总体结构
一个基于 Raft 的 KV 存储系统通常分为四层:
客户端 │ ▼ 请求路由层 │ ▼ Raft 共识层 │ ▼ 状态机层 │ ▼ 本地存储引擎具体来说:
1. 客户端层
客户端发送:
PUT("user:1001", "张三") GET("user:1001")2. Raft 层
Raft 负责:
选举 Leader
复制操作日志
确认多数节点已经保存日志
保证日志顺序一致
处理节点故障和 Leader 故障
3. 状态机层
状态机负责真正执行命令:
PUT user:1001 张三执行后变成:
内存状态: user:1001 → 张三4. 本地存储层
每个节点可以将 Raft 日志和状态机数据保存到本地磁盘,例如:
WAL 日志
SSTable
B+ 树
RocksDB
LevelDB
自定义文件
Raft 保证的是“大家执行的命令相同”,本地存储引擎负责“如何保存这些命令产生的数据”。
二、Raft 集群中的三种角色
Raft 节点有三种角色:
Follower 跟随者 Candidate 候选者 Leader 领导者1. Follower
Follower 不主动处理普通写请求,主要负责:
接收 Leader 的心跳
接收 Leader 的日志
投票选举
保存日志执行
已经提交的日志
2. Candidate
当 Follower 长时间没有收到 Leader 的消息时,会认为 Leader 可能失效,转换为 Candidate,开始发起选举。
3. Leader
Leader 负责:
接收客户端请求
追加日志向 Followers
复制日志
判断日志是否提交
通知 Followers 执行日志
正常情况下,客户端只需要和 Leader 通信。
三、写入数据的完整流程
假设客户端执行:
PUT user:1001 {"name":"张三","age":25}集群结构:
客户端 │ ▼ Node A Leader / \ ▼ ▼ Node B Node C Follower Follower第一步:客户端找到 Leader
客户端可能首先连接到 Node B,但 B 是 Follower。
B 可以:
返回 Leader 地址
将请求转发给 Leader
直接拒绝并提示客户端重试
最终请求到达 Node A。
PUT user:1001 ... │ ▼ Node A Leader第二步:Leader 将命令写入日志
Leader 不会马上直接修改最终 KV 状态,而是先将命令追加到本地日志:
日志: index term command ----------------------------------------------- 1 3 SET config:x 1 2 4 PUT user:1001 张三 3 5 PUT user:1001 {"name":"张三","age":25}日志中的几个重要字段:
Index
日志条目的位置:
1、2、3、4……Term
写入这条日志时 Leader 所处的任期。
Command
真正要执行的操作:
PUT user:1001 ... DELETE user:1001 INCR stock:1001第三步:Leader 向 Followers 复制日志
Leader 向 B、C 发送日志:
A ──日志 index=3──> B A ──日志 index=3──> CFollower 收到后,会先写入自己的本地日志。
Node B:保存 index=3 Node C:保存 index=3Follower 此时通常还不能立即执行这条命令,因为这条日志还没有被 Leader 确认提交。
第四步:等待多数节点确认
假设 B 成功保存,C 暂时宕机:
A:保存成功 B:保存成功 C:没有响应三个节点中有两个节点已经保存:
A + B = 2达到多数派,因此该日志可以提交。
commitIndex = 3第五步:Leader 执行状态机
Leader 将已经提交的日志交给状态机执行:
PUT user:1001 {"name":"张三","age":25}状态机执行后:
KV 数据: user:1001 → {"name":"张三","age":25}然后 Leader 向客户端返回成功:
OK第六步:通知 Followers 执行
Leader 会在后续心跳或日志同步消息中告诉 Followers:
leaderCommit = 3B 看到leaderCommit=3后,也执行 index=3:
Node B: user:1001 → {"name":"张三","age":25}C 恢复后,Leader 会先把缺少的日志补给 C,C 再执行这些已经提交的命令。
四、Raft 中的“提交”和“应用”不是一回事
这是一个很重要的概念。
日志复制成功
表示日志已经保存在足够多的节点上。
A、B 已保存 index=10日志提交
表示 Leader 确认它已经不会丢失:
commitIndex = 10状态机应用
表示节点真正执行了命令:
user:1001 → 张三流程是:
日志写入 ↓ 达到多数派 ↓ 日志提交 ↓ 状态机应用 ↓ KV 数据发生变化通常每个节点维护两个位置:
commitIndex:已经提交到哪里 lastApplied:已经执行到哪里要求:
lastApplied <= commitIndex节点会持续将:
lastApplied + 1到:
commitIndex之间的日志交给状态机执行。
五、KV 数据如何与 Raft 状态机结合
Raft 只处理命令,不直接理解 KV 业务。
例如客户端发来:
PUT user:1 张三系统可以把它编码成:
Command { type: "PUT", key: "user:1", value: "张三" }Leader 将这个 Command 写入 Raft 日志:
LogEntry { index: 10, term: 7, command: PUT user:1 张三 }当日志提交后,每个节点都调用相同的状态机:
stateMachine.apply(command)伪代码可以表示为:
function apply(command): if command.type == "PUT": kv[command.key] = command.value if command.type == "DELETE": delete kv[command.key] if command.type == "INCR": kv[command.key] += command.amount由于:
- 所有节点拥有相同的日志;
- 所有节点按照相同顺序执行;
- 状态机逻辑确定性一致;
所以最终得到的 KV 数据也一致:
Node A:user:1 → 张三 Node B:user:1 → 张三 Node C:user:1 → 张三这叫做:
状态机复制 Replicated State Machine六、读取数据如何处理
写请求通常必须发送给 Leader,但读请求有多种处理方式。
1. 从 Leader 读取
最简单的方式:
GET → Leader这样可以保证读取到最新提交的数据。
但 Leader 需要确认自己仍然是当前 Leader,否则可能出现旧 Leader 读取旧数据的问题。
2. ReadIndex
Leader 通过一次心跳确认自己仍然获得多数派支持,然后执行读取。
适合需要线性一致性的读取。
3. Leader Lease
Leader 在一个租约时间内认为自己仍然有效,可以直接读取。
优点是延迟低,缺点是依赖时钟和网络延迟假设,使用时需要谨慎。
4. 从 Follower 读取
可以直接从 Follower 读取,但可能读到旧数据:
客户端写入成功 立即从 Follower 读取 Follower 还没同步完成 返回旧值这种方式称为:
Stale Read,陈旧读取适合对实时一致性要求不高的场景。
七、Raft 日志不能无限增长
如果所有历史操作永久保存在日志中,日志会越来越大:
PUT a 1 PUT a 2 PUT a 3 PUT b 4 DELETE c ...因此需要快照机制。
1. 创建快照
当日志达到一定大小时,节点将当前状态机状态保存成快照:
Snapshot: a → 3 b → 4然后删除快照之前的旧日志:
旧日志:1 2 3 4 5 6 7 8 9 快照包含:1 到 7 保留日志:8 92. 新节点加入
如果新节点落后太多,Leader 不必发送几百万条日志,而是直接发送快照:
Leader ──InstallSnapshot──> 新节点新节点恢复快照后,再同步快照之后的少量日志。
3. 快照和日志的关系
可以理解为:
快照 = 某个时间点的完整状态 日志 = 从这个时间点之后的增量操作恢复数据时:
加载快照 ↓ 重放快照之后的日志 ↓ 得到最新状态八、Raft 如何实现水平扩展
一个 Raft 集群通常不应该把全部数据放进一个无限增长的 Raft 日志组,否则所有写入都要经过同一个 Leader,吞吐量会受限制。
更常见的方式是:
多个 Raft Group例如按照 Key 分片:
Raft Group 1:user:0 ~ user:999 Raft Group 2:user:1000 ~ user:1999 Raft Group 3:order:0 ~ order:999每个 Raft Group 有自己的 Leader 和副本:
Group 1:A、B、C Group 2:D、E、F Group 3:G、H、I请求路由层根据 Key 找到对应的 Group:
user:1001 → Group 2 → Group 2 的 Leader这样多个 Group 可以并行处理请求
要注意:
Raft 负责副本一致性 分片负责容量和吞吐扩展 路由层负责把请求送到正确的 Raft GroupRaft 本身并不自动解决数据分片
九、一个完整流程图
客户端 │ │ PUT user:1001 = 张三 ▼ 路由层 │ │ 根据 Key 找到 Raft Group ▼ Group Leader │ ├── 追加日志到本地 WAL │ ├── AppendEntries → Follower 1 │ ├── AppendEntries → Follower 2 │ ├── 获得多数派确认 │ ├── 更新 commitIndex │ ├── 应用到本地 KV 状态机 │ ├── 通知 Followers 提交 │ └── 返回客户端成功