确定性模拟与故障注入
分布式 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::sleep、rand::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 次。
确定性模拟能稳定复现并发 bug 的根本原因是什么?
这是本阶段最重的一次改造,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:每晚一万个种子,失败的种子就是精确复现的门票
- 下一节还另一笔债:你抱怨过的「系统什么都看不见」,轮到你给自己的系统装仪表盘