Skip to content

从 Raft 日志到容错 KV 服务 ​

Raft 决定命令的顺序,KV 服务决定命令含义。把 Put(k,v) 写入日志并不等于立刻写入对外可见的 map;服务要等提交,再按索引顺序应用,然后回复对应客户端。

三个不同的完成点 ​

  1. 追加:leader 本地日志出现命令,其他节点可能还不知道。
  2. 提交:协议确认命令不可被后续合法 leader 覆盖。
  3. 应用:状态机执行命令,产生业务结果。

返回前通常需要等待第三步。只等本地追加就在后续分区中丢失“成功写”;只等提交但应用落后,立刻读取业务 map 也可能看到旧值。

sequenceDiagram
  participant H as 请求处理器
  participant R as Raft
  participant A as 应用循环
  H->>R: 提交命令和请求身份
  R-->>H: 日志索引与任期
  H->>H: 注册结果等待者
  R->>A: 已提交命令
  A->>A: 更新 KV 与去重结果
  A-->>H: 匹配请求的业务结果
查看流程图文本
sequenceDiagram
  participant H as 请求处理器
  participant R as Raft
  participant A as 应用循环
  H->>R: 提交命令和请求身份
  R-->>H: 日志索引与任期
  H->>H: 注册结果等待者
  R->>A: 已提交命令
  A->>A: 更新 KV 与去重结果
  A-->>H: 匹配请求的业务结果

真实并发实现中,提交可能在“注册等待者”之前完成。需要锁、结果缓存或其他协议覆盖这个竞争。等待者不能只凭日志索引匹配:领导权切换后同一个未提交索引可能被别的命令占用,应核对请求身份和必要的任期信息。等待超时后清理等待者,防止无限泄漏。

Apply 是确定性的顺序函数 ​

每个副本对同一日志前缀应得到同样状态。不要在 apply 阶段读取本机当前时间、生成不同随机数或查询外部服务来决定结果;若命令需要时间或随机值,先由协议认可的输入明确记录。执行副作用也要考虑重放,例如“再次发送邮件”不是普通 map 更新。

模型的命令采用 (client, seq, op, key, value),支持 put/add/get。去重表保存请求对应结果,重复日志条目可以存在,但业务效果只发生一次。这区分了“日志中只出现一次”和“状态机只生效一次”。

代码模型还需要模拟消息传输的值边界:提交时复制命令,回复时复制结果。如果把调用者的可变字典直接存进 leader 日志,调用者随后修改字典,就能绕过复制协议让单个副本改变状态。这不是 Raft 算法允许的行为,而是进程内模型缺少序列化隔离造成的漏洞;配套测试专门检查这一边界。

读取也需要证明 ​

把写走共识、读随便读任意副本,会得到不同的一致性保证。最简单的线性一致读方案是把读命令也写入日志:成本高,但容易说明它在某个写序列位置上执行。优化方案如 ReadIndex 需要确认当前 leader 权限、获取提交屏障并等待本地已应用到该位置。租约优化还依赖时钟和租期边界,不能仅用“我最近发过心跳”当证明。

本模型选择日志读,因此隔离的旧 leader 即使本地有数据,也不能对新读声称线性一致成功。检查 test_partitioned_leader_cannot_serve_new_read。

快照:缩短恢复路径 ​

日志持续增长会使磁盘、网络和重启时间不断增加。状态机执行到索引 K 后,可以生成包含 KV、去重表及相关元数据的快照,记录 K 与其任期,之后丢弃不再需要的前缀。落后 follower 若所需日志已被截断,leader 发送快照再复制后续日志。

正确快照应满足:状态与边界一致;保存过程的中间文件不会当作完整快照;快照安装与后续 apply 不乱序;重复请求结果不会因截断丢失。空间回收与崩溃恢复因此是同一个协议的两面。

用案例审查接口 ​

客户端发起 add,服务已提交并回复,客户端没收到。重试到新 leader 时,应返回原请求结果;客户端用新编号再次 add,则是新的业务操作,应再次执行。这是 API 契约,不能靠服务器猜测两段相同文本是否“可能是重试”。

自测:为什么只对 Put 做去重,不对 Get 保存结果也可能有问题?

如果要求同一请求重试返回同一结果,那么第一次读到 1 后回复丢失,期间写成 2,重试返回 2 就改变了这次请求的结果。服务可以选择不同语义,但必须明确;本模型对所有请求统一缓存结果。

来源:MIT 容错 KV 实验、Raft 扩展论文。