Raft协议实现数据的分布式存储

📅 2026/7/24 6:02:19 👁️ 阅读次数 📝 编程学习
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──> C

Follower 收到后,会先写入自己的本地日志。

Node B:保存 index=3 Node C:保存 index=3

Follower 此时通常还不能立即执行这条命令,因为这条日志还没有被 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 = 3

B 看到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

由于:

  1. 所有节点拥有相同的日志;
  2. 所有节点按照相同顺序执行;
  3. 状态机逻辑确定性一致;

所以最终得到的 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 9

2. 新节点加入

如果新节点落后太多,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 Group

Raft 本身并不自动解决数据分片

九、一个完整流程图

客户端 │ │ PUT user:1001 = 张三 ▼ 路由层 │ │ 根据 Key 找到 Raft Group ▼ Group Leader │ ├── 追加日志到本地 WAL │ ├── AppendEntries → Follower 1 │ ├── AppendEntries → Follower 2 │ ├── 获得多数派确认 │ ├── 更新 commitIndex │ ├── 应用到本地 KV 状态机 │ ├── 通知 Followers 提交 │ └── 返回客户端成功