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 论文只关注磁盘状态):

rust 复制代码
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 如何导致数据丢失:

text 复制代码
Legend:
Ni:   Node i
Vi:   RequestVote, term=i
Li:   Establish Leader, term=i
Ei-j: Log entry, term=i, index=j

N5 |          V5  L5     E5-1     E5-2
N4 |          V5         E5-1     E5-2
N3 |  V1              V5,E5-1  V5,E5-2  E1-1
N2 |  V1      V5                        E1-1
N1 |  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])

rust 复制代码
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:

rust 复制代码
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:

rust 复制代码
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...

相关推荐
科技小花4 小时前
数据治理平台架构演进观察:AI原生设计如何重构企业数据管理范式
数据库·重构·架构·数据治理·ai-native·ai原生
一江寒逸4 小时前
零基础从入门到精通MySQL(中篇):进阶篇——吃透多表查询、事务核心与高级特性,搞定复杂业务SQL
数据库·sql·mysql
D4c-lovetrain4 小时前
linux个人心得22 (mysql)
数据库·mysql
阿里小阿希5 小时前
CentOS7 PostgreSQL 9.2 升级到 15 完整教程
数据库·postgresql
荒川之神5 小时前
Oracle 数据仓库雪花模型设计(完整实战方案)
数据库·数据仓库·oracle
做个文艺程序员5 小时前
MySQL安全加固十大硬核操作
数据库·mysql·安全
不吃香菜学java5 小时前
Redis简单应用
数据库·spring boot·tomcat·maven
一个天蝎座 白勺 程序猿5 小时前
Apache IoTDB(15):IoTDB查询写回(INTO子句)深度解析——从语法到实战的ETL全链路指南
数据库·apache·etl·iotdb
不知名的老吴5 小时前
Redis的延迟瓶颈:TCP栈开销无法避免
数据库·redis·缓存
YOU OU5 小时前
三大范式和E-R图
数据库