Storforge
编码预计 55 分钟

确定性模拟与故障注入

分布式 bug 的死穴是「复现不了」。用 madsim 风格的确定性模拟把时间、网络、磁盘全部虚拟化,同一个种子必定复现同一个 bug。

学完这节你能做到

  • 解释确定性模拟为什么能复现并发 bug
  • 给 forge 接上模拟运行时并写一个故障注入场景
  • 用随机种子扫描找出一个真实 bug

分布式 bug 的死穴:复现不了

运维时你一定处理过这种工单:某个请求偶发超时,日志里什么都看不出来,重启后消失, 三周后又来一次。你能做的只有加监控、攒证据、烧香。转到研发这一侧,同样的噩梦换了个名字: 并发 bug。forgefs 在 L4 验收时跑过几十次故障演练都没事,不代表它没有 bug —— 只代表那几十次里,消息到达顺序、定时器触发时机、线程调度恰好没踩到雷。

问题的根源是不确定性:真实世界里网络延迟、磁盘响应、线程调度都是随机的, 同一段代码每次跑出的执行序都不同。一个只在「A 的心跳恰好在 B 选举超时前 1ms 到达」时 触发的 bug,靠重跑一万次也未必撞上,撞上了也留不下现场。

这节课的答案来自 FoundationDB:既然随机性是敌人,就把随机性全部抓在自己手里

FoundationDB 的遗产:simulation testing

FoundationDB 团队做过一个激进的决定:先花两年造测试框架,再写数据库。 他们的 simulation testing 把整个分布式集群跑在单线程、单进程里 —— 网络是模拟的,时钟是模拟的,磁盘是模拟的,所有「随机」事件由一个伪随机数生成器驱动。 于是得到一个魔法性质:

同一个种子(seed),必定产生完全相同的执行序 —— 包括那个 bug。

这改变了整个游戏:

  • 想测一千种「节点在错误时机崩溃」的场景?换一千个种子跑就行,单机几分钟的事
  • 撞到 bug?记下种子,无限次精确重放,可以加日志、上调试器、逐步缩小范围
  • 模拟时钟想跳多快跳多快 —— 30 秒的选举超时在模拟里是一次函数调用, 一小时的「集群生活」几秒钟跑完

Rust 社区把这个思路做成了 madsim 等框架,RisingWave、TiKV 相关项目都在用。 我们给 forge 接上同样的机制 —— 而且地基早就打好了。

兑现 L0 的伏笔:trait 换后端

还记得 L0 最后一课那句「面向 trait 编程,就是给未来的自己留后门」吗? 现在开门。forge 里所有与外部世界打交道的东西,当初都定义成了 trait:

/// 存储后端:L1 的 forge-store 是真实现
pub trait BlobStore: Send + Sync {
    fn put(&self, key: &[u8], value: &[u8]) -> Result<(), StoreError>;
    fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, StoreError>;
}

/// 时钟:生产代码里禁止直接调 Instant::now()
pub trait Clock: Send + Sync {
    fn now(&self) -> Instant;
    fn sleep(&self, dur: Duration) -> BoxFuture<'static, ()>;
}

/// 网络:L3 的 tonic 客户端是真实现
pub trait Transport: Send + Sync {
    fn send(&self, to: NodeId, msg: Message) -> BoxFuture<'static, Result<Reply, RpcError>>;
}

模拟运行时给这三个 trait 各写一个假实现,全部由同一个种子驱动:

pub struct SimWorld {
    rng: StdRng,          // 一切随机性的唯一来源,由种子初始化
    clock: SimClock,      // 虚拟时间:事件队列里没事干就直接快进
    net: SimNet,          // 消息进队列,按"随机但确定"的延迟投递
    disks: Vec<SimDisk>,  // 内存里的假盘,可注入 EIO 和慢 IO
}

impl SimWorld {
    pub fn new(seed: u64) -> Self {
        let rng = StdRng::seed_from_u64(seed); // 种子决定一切
        // ...
        Self { rng, clock: SimClock::new(), net: SimNet::new(), disks: Vec::new() }
    }
}

关键纪律只有一条,但要贯彻到底:业务代码里不许出现任何绕过 trait 的不确定性来源。 直接调 Instant::now()tokio::time::sleeprand::random、真实 socket —— 任何一处都会让「同种子同执行序」失效。给 CI 加一条 grep 检查,把这条纪律变成门禁。

×决定论最常见的泄漏点

1. HashMap 迭代序:std 的 HashMap 每次进程启动哈希种子不同,遍历顺序不确定 —— 凡是遍历后影响行为的地方换 BTreeMap。
2. 多线程:模拟必须单线程跑,tokio 用 current_thread 运行时;线程池调度序是抓不住的随机性。
3. 系统时间:日志里打 SystemTime 没事,业务逻辑(超时判断、TTL)必须走 Clock trait。

故障注入 DSL:分区、延迟、盘错误

有了模拟世界,故障注入就是往事件队列里塞事件。把你运维时见过的故障做成一套声明式描述:

let mut sim = SimWorld::new(seed);
let cluster = sim.spawn_cluster(3); // 3 节点 forge,跑真实的业务代码

sim.run(async {
    // 后台负载:持续写入并记录期望值
    let workload = spawn_verified_workload(&cluster, 10_000);

    // 故障脚本:每一项都是你值过班的夜
    sim.at(secs(5), Fault::Partition { a: node(0), b: node(1) }); // 网络分区
    sim.at(secs(9), Fault::Heal);
    sim.at(secs(12), Fault::Crash(node(2)));                      // kill -9
    sim.at(secs(15), Fault::Restart(node(2)));
    sim.at(secs(18), Fault::DiskError { node: node(1), kind: DiskFault::Eio { ratio: 0.01 } });
    sim.at(secs(20), Fault::SlowNet { pair: (node(0), node(2)), extra: millis(800) });

    workload.await
})?;

// 终局校验:写进去的每个字节都要能读回来且校验和正确
cluster.verify_all_data()?;
cluster.verify_invariants()?; // 例:Raft 已提交日志在多数派上一致

注意两个设计点。第一,负载自带验证:workload 记住自己写了什么, 结束后逐一读回比对 —— 这就是 L1 里程碑「kill -9 后全量校验」的分布式版。 第二,不变量断言:除了「数据不丢」,把系统的核心承诺写成代码 (提交点不回退、同一 extent 不被分配两次),每轮模拟结束都查一遍。 违反不变量比数据损坏更早暴露问题。

种子扫描与最小化复现

框架搭好后,测试策略简单粗暴:拿种子当弹药,扫

# CI 每晚跑:种子 0..10000,每个种子一轮随机故障脚本
cargo test -p forge-sim --release -- --seed-range 0:10000
# 输出: seed 7342 FAILED: verify_all_data: key k-4471 checksum mismatch

种子 7342 在手,这个 bug 就归你了:重放一次是几秒钟的事,加日志重放、 二分故障脚本(去掉一半故障事件还复现吗?)、缩短负载长度 —— 把复现场景从「20 秒 6 个故障」压到「3 秒 1 个故障」,再逐行对着执行日志找答案。 以前要靠运气的活,现在是流水线。

真实的收获长什么样?给你一个典型:扫描发现某个种子下,节点崩溃重启后 拿着旧的放置表响应了读请求,返回了已被重平衡搬走的 stale 数据。 时序窗口不到 10ms,真机上可能一年撞不到一次 —— 模拟器一晚上撞到了 17 次。

Checkpoint单选

确定性模拟能稳定复现并发 bug 的根本原因是什么?

AI 结对:给 forge 搭起模拟测试骨架pair with ai

这是本阶段最重的一次改造,AI 会动到多个 crate —— review 时盯住两点:第一,它列出的不确定性调用点清单要你亲自再 grep 一遍核对, 漏掉一处决定论就是假的;第二,跑三遍同种子,diff 一下日志, 真的一字不差才算通过,「看起来都成功了」不算。

在 forge workspace 新增 crates/forge-sim(lib + 集成测试),目标是确定性模拟测试框架的第一版:

1. 审计现有代码:在 forge-node、forge-fs、forge-store 里搜索所有直接使用 std::time::Instant::now、SystemTime::now、tokio::time::sleep、rand 的调用点,列成清单给我;然后把业务逻辑中的调用点改为通过 forge-util 中新增的 Clock trait 注入(日志打点可以豁免,单独标注);
2. 在 forge-sim 里实现 SimClock(虚拟时间 + 事件堆,无待处理事件时时间直接跳到下一个定时器)和 SimTransport(实现 forge-proto 的 Transport trait,消息延迟由 StdRng::seed_from_u64(seed) 驱动,支持按节点对注入分区与固定附加延迟);
3. 写第一个模拟集成测试:单进程内起 3 个 forge 节点(current_thread 运行时),写入 1000 个带校验和的对象,期间在虚拟时间第 5 秒对任一节点对注入 3 秒网络分区,结束后全量读回校验;
4. 测试函数接受 seed 参数,提供 cargo test 可用的入口,默认跑 seed 0 到 63;任何一个 seed 失败时,断言消息里必须打印该 seed 值;
5. 附一段 README 注释说明决定论纪律:哪些 API 在业务代码里被禁用、为什么 HashMap 遍历序是隐患。

验收:同一 seed 连续跑 3 遍,失败/成功结果与日志中的事件序完全一致;cargo clippy 无警告。

小结

  • 并发 bug 复现不了的根源是不确定性;FoundationDB 的答案是把时间、网络、磁盘全部模拟, 一个种子决定一切随机性 —— 同种子必同执行序
  • L0 埋的 trait 伏笔在此兑现:BlobStore、Clock、Transport 换成模拟实现,业务代码一行不改
  • 决定论纪律要用 CI 门禁守住:禁直接调 now/sleep/rand,警惕 HashMap 迭代序和多线程
  • 故障注入 = 往事件队列塞你值过班的那些夜:分区、崩溃、EIO、慢网;负载自带验证 + 不变量断言
  • 种子扫描是流水线化的猎 bug:每晚一万个种子,失败的种子就是精确复现的门票
  • 下一节还另一笔债:你抱怨过的「系统什么都看不见」,轮到你给自己的系统装仪表盘