写点什么

Raft 中的 IO 执行顺序:内存状态与持久化状态的陷阱

作者:Databend
  • 2025-10-11
    福建
  • 本文字数:3082 字

    阅读完需:约 10 分钟

Raft 中的 IO 执行顺序:内存状态与持久化状态的陷阱

前言

在 Raft 实现中,处理 appendEntries 请求时需要持久化两类数据:term 和 log entries。Raft 论文要求"在响应 RPC 之前必须更新持久化状态",但并未明确说明这两类数据的持久化顺序。这个看似无关紧要的细节,却可能导致已提交数据的丢失。


问题的根源在于:Raft 论文描述的是一个简单的抽象模型(只有磁盘状态),而实际实现为了性能会分离内存状态和持久化状态。这种状态分离引入了论文中未定义的行为,当 IO 操作允许重排序时,就可能破坏 Raft 的安全性保证。


本文将深入分析这个问题是如何产生的,以及主流实现(TiKV、HashiCorp Raft、SOFAJRaft)如何避免这个陷阱。

内存状态与持久化状态的陷阱

在实际的 Raft 实现中,为了提升性能,通常会分离内存状态(current_term)和磁盘状态(persisted_term)。处理 appendEntries 请求的典型流程是:


  1. 收到 appendEntries,如果 req.term > current_term,立即更新 current_term

  2. 异步提交 save-term IO

  3. IO 完成后更新 persisted_term(有些实现中可能没有显式的 persisted_term


这种状态分离引入了 Raft 论文中没有定义的行为(Raft 论文只关注磁盘状态):


struct RaftState {    // In-memory term, updated immediately when receiving higher term    current_term: u64,
// Persisted term on disk, updated only after IO completes persisted_term: u64,}
复制代码


上面描述的流程是常见的 Raft 实现的流程, 在没有 IO-reorder 时, 它是正确的。但当 IO 操作可以重排序时,就会出现严重的安全问题。

问题场景

用一个具体的时间线来展示 IO-reorder 如何导致数据丢失:


Legend:Ni:   Node iVi:   RequestVote, term=iLi:   Establish Leader, term=iEi-j: Log entry, term=i, index=j
N5 | V5 L5 E5-1 E5-2N4 | V5 E5-1 E5-2N3 | V1 V5,E5-1 V5,E5-2 E1-1N2 | V1 V5 E1-1N1 | V1 L1 E1-1------+---+---+---+------+--------+-----+------> time t1 t2 t3 t4 t5 t6 t7
复制代码


  • t1-t4: 两次选举,N1(term=1)和 N5(term=5)先后成为 leader

  • t5: L5 复制 E5-1 到 N3(N3 的 current_term=1 < req.term=5

  • N3 需要执行两个 IO:持久化 term=5 和 E5-1

  • 等待两个 IO 完成才返回成功

  • t6: L5 复制 E5-2 到 N3(关键时刻)

  • N3 可能还在处理 t5 的 IO

  • 这时是否存在 IO-reorder 至关重要

  • t7: L1 尝试复制 E1-1(term=1, index=1)


关键在于 t6 时刻的第二个 AppendEntries 请求。让我们看看 N3 的内部状态变化。

t5 时刻:第一个 AppendEntries

N3 收到 appendEntries(term=5, entries=[E5-1])


fn handle_append_entries(&mut self, req: AppendEntries) {    // Check: RPC term > in-memory term?    if req.term > self.current_term {        self.current_term = req.term;           // Update memory immediately: 5        self.submit_io(save_term(req.term));    // Submit IO request    }
self.submit_io(save_entries(req.entries)); // Submit IO request
// Wait for both IOs to complete wait_for_both_ios(); return success();}
复制代码


N3 的状态:


  • current_term = 5(内存已更新)

  • persisted_term = 1(磁盘还未更新,IO 进行中)

  • IO 队列:save_term(5), save_entries(E5-1)


这个请求本身是正确的,问题出现在下一个时刻。

t6 时刻:第二个 AppendEntries

N3 还没完成 t5 的 IO,就收到了 appendEntries(term=5, entries=[E5-2])


如果代码只检查内存 current_term(大多数实现的做法), 并提交 save-entries IO:


fn handle_append_entries(&mut self, req: AppendEntries) {    // Check: 5 > 5? No    if req.term > self.current_term {        // Won't enter this branch    }
// Only submit save_entries(E5-2) self.submit_io(save_entries(req.entries));
// Only wait for save_entries to complete wait_for_io(save_entries); return success(); // Return success!}
复制代码


问题出现:在允许 IO-reorder 的时候,


  • save_entries(E5-2) 完成

  • save_term(5) 可能还没完成(如果存在 IO 重排序)

  • N3 向 Leader 返回成功


如果 N3 此时崩溃重启,磁盘状态可能是:


  • persisted_term = 1(save_term(5) 未完成)

  • entries = [E5-1, E5-2](都完成了)

  • Leader L5 认为 E5-2 已提交

t7 时刻:数据丢失

重启后 N3 的磁盘状态:term=1, entries=[E5-1, E5-2]


当 L1 发送 appendEntries(term=1, entries=[E1-1])


  • N3 检查:RPC term (1) == 本地 term (1),接受

  • E1-1 覆盖 index=1

  • 已向 L5 确认提交的 E5-1 和 E5-2 被覆盖


注意, 如果不允许 IO-reorder, 那么 t6 的 save_entries(E5-2) 的完成就暗示了save_term(5) 的完成, 满足了 appendEntries 成功的条件, 不会出现问题.

问题的本质

如果允许 IO-reorder,必须检查 persisted_term 来判断是否下发 save-term IO;如果不允许 IO-reorder,检查 current_term 即可。


Raft 论文不区分内存状态和持久化状态,这是实现相关的陷阱。论文要求 "Before responding to RPCs, a server must update its persistent state",在实现中需要更精确的表述: 必须等待所有使 persisted_term >= req.term 的 IO 完成后,才能返回成功

正确的做法

检查持久化的 term 而不是内存 term:


fn handle_append_entries(&mut self, req: AppendEntries) {    // Check persisted term, not in-memory term!    let need_save_term = req.term > self.persisted_term;
if need_save_term { self.current_term = req.term; self.submit_io(save_term(req.term)); }
self.submit_io(save_entries(req.entries));
if need_save_term { wait_for_both_ios(); // Must wait for save_term to complete } else { wait_for_io(save_entries); }
return success();}
复制代码


注意:这种实现可能多次提交 save-term IO,需要在实现中谨慎优化。

主流实现的方案

主流实现(TiKV、HashiCorp Raft、SOFAJRaft)通过限制 save-term 和 save-entries 不能 reorder,因此只检查 current_term 也是安全的:


  1. 原子批处理(TiKV):将 save-term 和 save-entries 放到一个 IO 请求里,一次性提交。这样根本不存在"第二个 appendEntries 只提交 save_entries"的情况。

  2. 有序分离(HashiCorp Raft):save-term 和 save-entries 顺序执行,不会重排序。先完成 term 的 fsync(失败则 panic),再写 log。

  3. 混合顺序(SOFAJRaft):term 同步写入(阻塞等待 fsync),log 异步批处理。保证了 save_term 完成后才会入队 save_entries。

总结

Raft 论文的抽象模型(只关注持久化状态)和实际实现(内存状态 + 持久化状态)之间存在微妙的映射关系。


关键不变式:log entry (term=T) 在磁盘 → persisted_term ≥ T 也必须在磁盘


维护此不变式的两种方式:


  1. 消除 IO-reorder:原子批处理、有序执行或混合方式(主流实现)

  2. 处理 IO-reorder:检查持久化状态,等待必要的 IO 完成

相关资源

关于 Databend

Databend 是一款开源、弹性、低成本,基于对象存储也可以做实时分析的新式湖仓。期待您的关注,一起探索云原生数仓解决方案,打造新一代开源 Data Cloud。


👨‍💻‍ Databend Cloud:databend.cn


📖 Databend 文档:docs.databend.cn


💻 Wechat:Databend


✨ GitHub:github.com/databendlab…

发布于: 刚刚阅读数: 2
用户头像

Databend

关注

还未添加个人签名 2022-08-25 加入

还未添加个人简介

评论

发布
暂无评论
Raft 中的 IO 执行顺序:内存状态与持久化状态的陷阱_Databend_InfoQ写作社区