type
status
date
slug
summary
tags
category
icon
password
最初接触到确定性模拟的概念是在 2022 年 Rust China Conf 上听的一场演讲,后续一直持续关注着这个领域,也在腾讯组内分享过相关议题
背景
分布式系统面临的问题:
- 通信不可靠:丢包,超时,乱序,重复

- 时钟不可靠:时钟漂移,拖尾,回退


- 节点不可靠:阻塞,宕机,掉线,停顿

分布式软件/算法的目标:容忍不可靠的环境,实现容错,同时保证安全性
节点不可能只依赖自身状态来对整个系统进行判断,需要达成共识:Paxos、ZAB、Raft….
通常共识算法都是实现了全序广播,能够保证一系列消息以相同的顺序被应用到每个节点上,以此实现共识
共识算法的作者都声称他们的算法简单、易于理解,算法本身的正确性也通过形式化验证进行保证


共识算法看起来没有那么复杂,但具体落地时,正确实现非常困难,分布式系统的环境非常复杂,不确定因素和扰动过多,要在任何一种情况下都能正常工作是一个很大的挑战
一些 bug 可能需要运行数千次,才会发生一次,且几乎无法复现

如何解决在分布式系统上进行测试的问题?
确定性测试 & 模拟
安全性的一些业界方案:
- 混沌测试:ChaosMesh

- 验证框架:Jepsen

主动向系统注入故障并验证,这能提高错误发生概率,暴露问题。但无法复现的根本问题还是没有解决:
- 修复后如何验证真的解决了?还是需要精确复现这个问题
- 更多时候还因为日志和信息不足无法定位,要给程序添加日志后等待再次复现
可以发现,无法复现的原因是系统有很多因素每次运行时都是不确定的,bug 可能只有在程序的某些执行历史下才会复现。而网络延迟、进程调度的波动,最终都会导致执行历史变化,使系统走向无法预测
甚至有可能因为「修复」行为导致问题更难复现,例如为了复现问题,在性能敏感区域加了一长串日志输出,结果因为输出的开销导致的蝴蝶效应更难踩中 bug 触发的时间窗口
如果系统像纯函数一样是确定性的,测试就能简单得多。那能不能将不确定性的事件,通过某种参数关联为确定性的事件?典型例子是伪随机数生成器,看起来是不确定性的,实际上是与 seed 关联,结果是确定的。是否可能通过一个 seed,去 hook 系统中的不确定性因素?
Sled(一个类似 RocksDB 的嵌入式存储引擎) 的作者在一篇文章中提到他是如何在系统中进行测试的:

Jepsen 的出现成功击溃了几乎所有它测试的分布式系统,这表明我们在根本上以一种错误的方式构建分布式系统,这种方式无法避免 bug 的出现
那我们要怎么做才是正确的?

1. 将代码写成能在模拟器上被确定性运行的形式 2. 写一个模拟器去模拟真实世界的行为
这就是确定性模拟器
业界实现和落地
FoundationDB
FoundationDB 是 Apple 开源的分布式 KV 数据库,FoundationDB 在开发之初花了两年实现模拟器,在后期取得了非常大的回报,是业界最早全面落地确定性测试的工程之一,也是为数不多能够通过 Jepsen 的系统

FoundationDB 基于 C++ 扩展出了一个名为 Flow 的语言(与其说是语言,更像一种扩展的宏功能,只进行了预编译处理)
Flow 采用 Actor 单线程异步模型(async/await),由 runtime 负责所有调度,以此控制执行顺序,确保不会因为内核的线程调度导致不确定性。再通过接口接管了网络、存储、时间等其他外部调用,实现所有不确定性事件的受控
在这些改造后,Flow 看起来也和 C++ 没什么区别,使用
ACTOR
即可定义一个异步任务:ACTOR Future<float> asyncAdd(Futur<float> f, float offset) { float value = wait(f); return value + offset; }
模拟器中每个测试由一段配置文件定义,这里包括了测试的行为,以及注入的故障,例如向网络注入延迟、关闭连接、杀死节点、变更配置等。只要用相同的配置文件作为输入,模拟器就会产生相同的执行历史,问题就能被复现
testTitle=SwizzledCycleTest testName=Cycle transactionsPerSecond=1000.0 testDuration=30.0 expectedRate=0.01 testName=RandomClogging testDuration=30.0 swizzle = 1 testName=Attrition machinesToKill=10 machinesToLeave=3 reboot=true testDuration=30.0 testName=ChangeConfig maxDelayBeforeChange=30.0 coordinators=auto
FoundationDB 本身是开源的,但可惜模拟器部分没有开源,论文和演讲中也只进行了简单介绍

最终,他们声称确定性模拟让他们发现了数据库所有 bug:
Anyway, we did this for a while and found all of the bugs in the database. I know, I know, that’s an insane thing to say. It’s kind of true though. In the entire history of the company, I think we only ever had one or two bugs reported by a customer. Ever. Kyle Kingsbury aka “aphyr” didn’t even bother testing it with Jepsen, because he didn’t think he’d find anything.

无论如何,FoundationDB 开创了确定性模拟的先河。所有后来者都无法绕过 FoundationDB 的影响
RisingWave & MadSim
RisingWave 是一个分布式流数据库,受到 FoundationDB 的启发,他们开发了一个名为 MadSim 的确定性测试框架,两者都是开源项目

RisingWave 和 MadSim 都基于 Rust 开发。Rust 原生支持异步,但特殊的是,Rust 只提供了异步需要的语言特性、关键字和相关类型等;如异步任务如何执行、管理和调度等具体实现则不在语言内提供,因此社区有各种不同的 runtime
在这种设计下,MadSim 就可以作为一个异步 runtime 呈现,使用单线程运行异步任务,不需要对语言做侵入性改动即可轻松控制调度,并且能和工具链(如调试器)结合得更好
MadSim 的基本架构如下,这里也参考了 FoundationDB,包括一个全局的随机数生成器,以及基于这之上的定时器,任务调度器和环境模拟(网络、存储)

除此之外,MadSim 还提供了对 Rust 异步 runtime 事实标准 tokio 的 API 兼容,这使得使用 tokio 的项目无需修改一行代码,就可以无缝接入 MadSim
还有一些异步 API 之外的部分,例如获取系统时间和配置等(
gettimeofday
get_random
sysconf
等 API),可能会被部分标准库和依赖库不知不觉地调用而破坏确定性,Madsim 通过重载 libc 函数完成 hook最后一些常见的外部系统网络交互的 client 库,也提供了有确定性模拟的包装实现

MadSim 中,系统中每一个节点被抽象成状态机。输入会触发节点的状态转移,输入通常有两类:消息和定时器。而节点的输出就是对另一个节点发送的消息,定时器通常是节点自身在某一时刻做的一件事情
在系统的一开始,存在一个初始状态,接着一些节点的定时器可能被激活(例如心跳或注册等),触发状态转移,发送消息给其它节点,进一步触发其它节点的转移

从更高的维度将「整个系统」看作一个大的状态机,唯一的外部输入就只有时间,整个系统就是一个随着时间不断变化状态的状态机,可以看作只有状态转移的时候,时间才被推进了
将这些状态的转移在时间轴上排列,状态转移之间的时间对状态机是没意义的,模拟器就可以通过离散事件模拟的方式在一个转移结束后直接跳到下一个转移的时间节点,实现时间加速

这也是 FoundationDB 中提过的模拟器的另一个好处
既然是开源的,简单看一下关键实现
- runtime 初始化时,使用传入的 seed 构造随机数生成器,之后所有事件的模拟都能被这个 seed 确定
/// Create a new runtime instance with given seed and config. pub fn with_seed_and_config(seed: u64, config: Config) -> Self { let rand = rand::GlobalRng::new_with_seed(seed); let sims = Arc::new(Mutex::new(HashMap::new())); let task = task::Executor::new(rand.clone(), sims.clone()); let handle = Handle { rand: rand.clone(), time: task.time_handle().clone(), task: task.handle().clone(), sims, config, allow_system_thread: false, }; let rt = Runtime { rand, task, handle }; rt.add_simulator::<fs::FsSim>(); rt.add_simulator::<net::NetSim>(); rt }
- 定时器,实质上是在时间堆中加入了一个带定时器回调的输入
pub(crate) fn add_timer_at( &self, deadline: Instant, callback: impl FnOnce() + Send + Sync + 'static, ) { let mut timer = self.timer.lock(); timer.add(deadline - self.clock.base_instant(), |_| callback()); } pub(crate) fn add_timer(&self, dur: Duration, callback: impl FnOnce() + Send + Sync + 'static) { self.add_timer_at(self.clock.now_instant() + dur, callback); }
- 调度器:通过 event-loop 来进行调度,从就绪队列里随机取任务执行,这里的「随机」也是基于全局的随机数生成器,调度顺序都是被 seed 确定的。然后直接跳转到下一个事件的时间点
pub fn block_on<F: Future>(&self, future: F) -> F::Output { // ... loop { self.run_all_ready(); if task.is_finished() { return task.now_or_never().unwrap(); } let going = self.time.advance_to_next_event(); // ... } } /// Drain all tasks from ready queue and run them. fn run_all_ready(&self) { while let Ok(runnable) = self.queue.try_recv_random(&self.rand) { // ... // run the task let res = { let _guard = crate::context::enter_task(info.clone()); std::panic::catch_unwind(move || work(runnable)) }; if let Err(e) = res { // ... } // advance time: 50-100ns let dur = Duration::from_nanos(self.rand.with(|rng| rng.gen_range(50..100))); self.time.handle().advance(dur); } }
- 网络接口的语义比较复杂,这部分有不小工作量,最底下是维护链接的实现,这里存储了所有节点的信息,使用 channel 进行通信,并且可以根据配置的丢包率和延迟来注入故障。再往上提供 socket 语义的接口,感觉都是体力活
/// Opens a new connection to destination. pub(crate) async fn connect1( self: &Arc<Self>, node: NodeId, port: u16, mut dst: SocketAddr, protocol: IpProtocol, ) -> io::Result<(PayloadSender, PayloadReceiver, SocketAddr)> { self.rand_delay().await?; if let Some(addr) = self .ipvs .get_server(ServiceAddr::from_addr_proto(dst, protocol)) { dst = addr.parse().expect("invalid socket address"); } let (ip, dst_node, socket, latency) = (self.network.lock().try_send(node, dst, protocol)) .ok_or_else(|| { io::Error::new(io::ErrorKind::ConnectionRefused, "connection refused") })?; let src = (ip, port).into(); let (tx1, rx1) = self.channel(node, dst, protocol); let (tx2, rx2) = self.channel(dst_node, src, protocol); trace!(?latency, "delay"); // FIXME: delay // self.time.add_timer(latency, move || { socket.new_connection(src, dst, tx2, rx1); // }); Ok((tx1, rx2, src)) } /// Try sending a message to the destination. /// /// If destination is not found or packet loss, returns `None`. /// Otherwise returns the source IP, socket and latency. pub fn try_send( &mut self, node: NodeId, dst: SocketAddr, protocol: IpProtocol, ) -> Option<(IpAddr, NodeId, Arc<dyn Socket>, Duration)> { let dst_node = self.resolve_dest_node(node, dst, protocol)?; let latency = self.test_link(node, dst_node)?; let sockets = &self.nodes.get(&dst_node)?.sockets; let ep = (sockets.get(&(dst, protocol))) .or_else(|| sockets.get(&((Ipv4Addr::UNSPECIFIED, dst.port()).into(), protocol)))?; let src_ip = if dst.ip().is_loopback() { IpAddr::V4(Ipv4Addr::LOCALHOST) } else { self.nodes.get(&node).expect("node not found").ip.unwrap() }; Some((src_ip, dst_node, ep.clone(), latency)) } /// Returns the latency of sending a packet. If packet loss, returns `None`. fn test_link(&mut self, src: NodeId, dst: NodeId) -> Option<Duration> { if self.link_clogged(src, dst) || self.rand.gen_bool(self.config.packet_loss_rate) { None } else { self.stat.msg_count += 1; // TODO: special value for loopback Some(self.rand.gen_range(self.config.send_latency.clone())) } }
作者提供了一个基于 MadSim 的 MIT 6.824 课程 Raft 实验的重写版 MadRaft,这里模拟器中的 Raft 测试比真实运行快上近百倍:

对于未通过的测试,模拟器返回一个 seed,下次使用同样的 seed 运行,就能得到相同的结果
在 RisingWave 中,Madsim 被应用在四种不同的测试中:
- 单元测试:单元测试关注面比较小,难以发现复杂的问题,所以不是确定性测试主要作用的目标,但确定性模拟器在这里还是起了不少作用,比如测试有关超时的逻辑能瞬间完成。另外单元测试也能反过来验证一些外部系统包装(如 etcd 模拟器)的实现正确与否
- E2E 测试:E2E 测试中涵盖了系统的各个模块,更容易出现错误。RisingWave 架构复杂,涉及各种服务

通过 MadSim,可以将上述所有服务运行在模拟器的单线程环境中,使环境构建简单得多,并且基于模拟器时间加速的特性,一轮完整测试只用耗时两分钟,是原先的四分之一
并且能轻松地并行执行测试,如果发生了错误,使用同样的 seed 就可以轻易复现结果,包括修改代码添加日志,也不会影响可复现性
- 异常测试:E2E 测试主要还是在测试正常情况下的行为,没法完全发挥 MadSim 的作用。在异常测试中,会刻意构造各种故障并验证系统是否仍然能保持正确。在这个过程中发现了很多 bug,得益于模拟器的可复现性,这些问题都能被很快定位和修复

- 扩容测试:集群配置发生变化时,需要进行重平衡,这个过程也很容易发生错误,特别是叠加异常情况时

在正常测试中,受限于集群和数据规模的问题,测试覆盖面不足。但在模拟器中可以轻松构造较大规模和极端情况下的 case,帮助发现并修复了大量问题
在 CI 中,每一项确定性测试都会用不同的 seed 并行执行 16 次(16 核 CI 机器),以尽可能提高覆盖率
Dropbox Trinity
Dropbox 中 Sync Engine 是一个核心功能,负责在客户端和服务器之间进行文件同步
2016 年,Dropbox 开始使用 Rust 重写它们的 Sync Engine,并引入了确定性模拟技术来测试,该部分称为 Trinity
动机就像我们一开始提到的那样,问题复现困难,也没有足够日志定位:

整个测试流程也是类似的,通过 seed 构造全局随机数生成器来生成之后的所有随机决策,如果测试失败则输出 seed

由于也是 Rust 开发,Trinity 也是作为一个异步 runtime 执行,其他方面也都类似,包括文件系统、网络和时间模拟
Rust 生态中异步 runtime 的事实标准 tokio 也宣布了其官方的确定性测试项目 turmoil,在这方面 Rust 还是走在了前列
TigerBeetle & VOPR
TigerBeetle 是一个专为金融事务场景设计的数据库,使用 Zig 开发,在首页就着重强调了它们使用确定性模拟来构建数据库以体现可靠性

并且这个模拟版本 SimTigerBeetle 是可以运行在浏览器中的,还包装成了一个游戏的形式,能够折磨这些 beetle(注入故障)

他们开发了称为 Viewstamped Operation Replicator (VOPR) 的模拟器,并将系统编译为 WebAssembly 运行,和前面的模拟器一样,这里也都包含网络、存储、时钟的模拟,并支持故障注入
Zig 不像 Rust 那样可以自定义异步 runtime,那这里是如何控制调度的呢?一开始认为这里编译为 WASM 的目的除了支持浏览器外,也是为了能在模拟器上单线程执行。但看了文档才发现,TigerBeetle 从一开始就是单线程的设计,那也不需要什么控制调度一说了:
但并发是必须的,不利用多线程或其他语言的异步机制,如何实现并发?
pub fn main() !void { // ... while (tick < cli_args.ticks_max_convergence) : (tick += 1) { simulator.tick(); tick_total += 1; if (simulator.pending() == null) { break; } } // ... } pub fn tick(simulator: *Simulator) void { simulator.cluster.context = simulator; simulator.cluster.tick(); simulator.tick_requests(); simulator.tick_crash(); }
真实运行的部分,也是通过
tick
不停推进。相当于手动在代码结构上设计了任务的时间片切分,每 tick
一次就执行这个任务的一个时间片,调度顺序就是代码中 tick
调用的顺序,本身就是被确定的。这是完全贯彻了把系统作为状态机实现的想法,从一开始就是为确定性模拟而设计的:while (true) { replica.tick(); if (multiversion != null) multiversion.?.tick(); try command.io.run_for_ns(constants.tick_ms * std.time.ns_per_ms); }
这对代码设计要求会比较高,在很多场景是反范式的,例如 TigerBeetle 内 LSM-Tree 的实现,有点难想象如何通过一堆 tick 来推进整个 LSM-Tree 的 Compaction 流程:
以及他们如何设计这样的 I/O 库:
除此之外,TigerBeetle 还有些很独特的设计哲学,例如 0 依赖、0 动态内存分配。包括确定性模拟,这些思想都很前卫,值得一看:
FrostDB & Resonate
从前面的方案中可以发现,大部分外部调用都可以通过接口 mock 的方式来实现确定性,最麻烦的是如何让整个分布式系统运行在一个节点的一个线程上,消除调度的不确定性
上文提到的系统中除了 Rust 能比较好地实现外,其他语言都有些限制,但从头自己造一套任务和调度机制,还是能做到的
而另外一些语言在设计之处就是完全透明多线程的,在这些语言上会困难得多。例如 Go,很难去避免使用 goroutine,而一旦有多个 goroutine,调度就完全不可控了
虽然可以将 Go 程序编译为 WASM 来单线程执行并禁用抢占(
GOMAXPROCS=1
是不行的,碰到阻塞调用时还是会创建线程),但 runtime 仍然会故意随机调度 goroutine,以及 Go 的 map 遍历顺序也是故意随机的,还有其他很多不确定性来源不过 Go runtime 中这些不确定性来源都是通过启动时的一个 seed 来确定的(就像确定性模拟器做的那样),如果能自定义这个 seed 那就能解决这些问题。但只差这一步,Go 的 runtime 开放和自定义程度很低。要想突破这最后一个限制,只能 fork 一份 runtime 来修改。这几乎就是 FrostDB 所做的事,他们 fork 了 Go runtime 修改了几行代码 来实现这一切:
另一条路线是 Resonate,他们则是确实避免使用 goroutine,自己造了一套 coroutine:
coroutine.go
resonatehq
Resonate 给出了一个使用模拟器发现 bug 的例子,包括 seed,用这个 seed 我们也能在本地复现出一样的结果:
总得来说,在 Rust 这样比较开放的语言上实现确定性模拟是比较简单且兼容程度较高的。其次是其他相对底层的语言,虽然大多数时候需要实现一套自己的机制导致代码不具备普适性,但至少它们不会偷偷做额外的事把一切变得更糟。最麻烦的是 Go 这样隐藏了很多细节且不可控的语言,各种层面上限制都太大
恰好最近 Go 1.24 发布,新增了
synctest
包,可以在测试代码中实现作用域内的模拟时钟:import ( "testing" "testing/synctest" "time" ) func Test(t *testing.T) { synctest.Run(func() { before := time.Now() time.Sleep(time.Second) after := time.Now() if d := after.Sub(before); d != time.Second { t.Fatalf("took %v", d) } }) }
这也是确定性模拟中所需的一个重要机制,希望随着 Go 自身的进一步开发,未来能有更 native 的方式在 Go 上实现确定性模拟
Antithesis
模拟器都是单线程运行的,是因为我们默认无法干涉内核层面的调度,才有这样的限制。但真的不能吗?如果将模拟器实现在更底层的级别中呢?
Antithesis 是 FoundationDB 前成员(CEO 就是前面 FoundationDB 的演讲者)创立的一家公司,他们的平台能为任意系统提供确定性模拟:
Antithesis is a continuous reliability platform that autonomously searches for problems in your software within a simulated environment. Every problem we find can be perfectly reproduced, allowing for efficient debugging of even the most complex problems.
作为商业化解决方案,面对各种客户的不同系统。不可能要求客户对系统做大量修改和适配甚至重新设计,确定性模拟必须是透明的
因此,Antithesis 开发了一个确定性模拟计算机的 hypervisor。这很疯狂,但确实可行,只要整个虚拟机都是确定性的,那对被测试的软件就是完全透明的

脱离语言的另一个好处是,可以真正运行「整个系统」。例如 FoundationDB 没法在模拟器中使用 RocksDB,因为它有后台线程。RisingWave 也给 etcd 和 Kafka 编写了单独的模拟器。但在 Antithesis 中都不需要为这些依赖的库和组件操心
实现一个确定性的 hypervisor,这会比想象中更难,因为 CPU 也不是所有的情况都能保证确定性,而它非常复杂
为了模拟时间流逝,Antithesis 根据执行指令数来推进模拟时钟。但 PMC 中记录的执行指令数并不总是正确,这会破坏确定性。只有对 CPU 的细节足够了解,才可能解决这些问题
在并发上,虽然被测系统是多线程的,但还是必须让它们运行在一个物理核上。因为有时间加速,这并不会对被测系统的性能造成多大影响。反而从 Antithesis 的角度来说,可以不需要关注核间同步,在不同核上运行更多单独的虚拟机实例提高效率。并且由于工作在更底层的级别上,使得 Antithesis 还能构造像线程饥饿这样的问题

Antithesis 还实现了一些很奇妙的技术,例如能智能判断系统执行历史、探索分支路径和状态空间,并且能保存状态。这意味着 Antithesis 的测试是完全自主的,你不需要编写任何测试用例,系统会自动生成用例挖掘可能的分支,进行比人工更可靠的测试,类似于一种更加智能的 fuzzing
Antithesis 可以提供每个 checkpoint 的快照,并且和走向其他分支的执行历史进行对比,他们称为 Multiverse(多重宇宙),你能在这些不同宇宙中进行「时间旅行」式的调试。随时回退到过去和继续走向未来看看发生了什么,甚至可以在时间旅行中执行命令或者使用调试器调试进程,捕获网络数据包,跑火焰图… 当你改变了过去之后,一个新的宇宙就会诞生

这一切都非常黑魔法,更多内容可以浏览他们的 Blog,都很有趣。也许 Antithesis 真的能定义未来的测试方法
Antithesis 的定价不便宜,但在客户侧都有不错的评价,包括一些知名基础设施系统:
- MongoDB 使用 Antithesis 测试存储引擎,核心服务器,同步和升降级功能。发现了一个严重的数据丢失问题:
- Ethereum 在从 PoW(工作量证明)转向 PoS(权益证明)时使用 Antithesis 进行测试
- WarpStream 端到端测试了整个 SaaS 系统,而不是局限在单个组件或进程
- CockroachDB 在 Antithesis 上重现并定位了之前被搁置数年的事务并行提交 bug
除了 Antithesis 之外,还有一些项目也试图在更底层的级别上进行确定性探索。例如 rr 和 dettrace 都是通过 ptrace 替换系统调用的想法来实现的确定性调试器,它们都诞生得更早一些。Facebook 也曾发起过 hermit 项目,虽然现在已经没有在积极开发
总结
确定性模拟器提供了一个非常美好的、仿佛触手可及的设想。这里调试不再困难,系统更加可靠
但软件工程没有银弹,确定性模拟器仍然有很多问题。既然是模拟,前提是我们了解被模拟物的所有行为,但这是不可能的。所有模拟疏漏或失真的细节,最终也会在真实系统中遇见,例如错误理解的网络协议,意想不到的 system call 行为,依赖外部系统本身的 bug。都可能让系统再次在真实环境中故障
更重要的是不确定性的消除十分困难,模拟器本身来说,很难 cover 各种场景,而任何一点遗漏,都会把不确定性引入系统,最终走向混沌
另一种情况是被测程序的修改会破坏可复现性,被测程序本身就是模拟器输入的一部分,如果修改被测程序,虽然不会破坏「确定性」,但可能会无法复现期望的问题。例如在程序启动时新运行一个线程,那模拟器的调度序列可能会因为这个新的输入而改变,它仍然是确定性的(相同的输入有相同的输出),只是没能再触发之前的问题。这实际上某种程度违背了模拟器提供的承诺,确定性和可复现性并不总是能完全划等号
从工程角度来说,语言上实现的模拟器大多会有比较强的侵入性,会限制并发模型和依赖库。除了新项目以外难以引入。而 Antithesis 方案技术壁垒过高,大部分人没有能力实现,如果没有开源方案共建,无法广泛普及
不过这也只是模拟器实现中的困难,从方向上来说,高度的可复现性和极高的测试效率就足以成为任何追求可靠性的系统尝试和探索它的理由。我始终相信这项技术的巨大潜力,一定会是未来的方向