DocsBlogPricingCompany GitHubDiscordSign up Open main menu Published on April 2, 2025

用于 Async Rust 的确定性模拟测试

Shikhar BhushanShikhar Bhushan

确定性模拟测试 (Deterministic simulation testing, DST) 是一种非常强大的技术,通过消除边缘情况并可靠地重现出现的错误,来增强对系统的信心——当 DST 之神显灵时,你应该感谢他们,因为那意味着用户不必遇到这个 bug!

在构建 S2 时,我们就知道 DST 是正确的方法,这受到了 FoundationDBTigerBeetle 的启发。但是,我们如何在实践中将其应用到使用 Tokio 作为运行时的 Rust 代码库中呢? 在这篇文章中,我的目标是分享我们在此过程中学到的一些东西。

单元测试可以做得非常确定。 这里的不同之处在于,我们试图执行 大量 不同的场景(如属性测试),并具有整体系统的思维方式(如端到端集成测试)。

构造应该使得每次运行时,你都 随机地 在巨大的状态空间中绘制一条路径。 随机性和确定性听起来可能相互矛盾,但每个选择都来自一个单一点——随机数生成器 (random number generator, RNG) 种子。

测试对象

DST 在你不仅以客户端身份检查不变量,而且更细粒度地在主线代码中检查不变量时效果最佳——这是一种深度防御的实践。 数据库尤其应该慷慨地使用断言; 在涉及 ACID 特性时,大声崩溃要好得多。

顺便说一句,虽然某些语言可能会将断言视为可以在生产环境中优化掉的东西,但我认为 Rust 在这里选择了正确的默认值——很少有代价高昂的检查会允许使用 debug_assert! 进行编译。

当然,我们不 希望 在生产环境中出现 panic! 为了加速构建健壮的分布式数据系统的漫长旅程,实际测试的核心是在模拟时间的延长期间合成混乱的交互。 我们在每个 PR、提交和数千个 nightly 试验中运行 DST。

除了内部检查之外,系统还必须维护可从外部观察到的不变量,这些不变量最好从客户端的角度验证,例如崩溃后的持久性以及围绕并发操作的 API 语义。

使其具有确定性

为了创建一个真正具有确定性的模拟,必须驯服什么?

这有很多变量需要控制。 幸运的是,我们能够利用社区完成的许多现有的开源工作!

Tokio 确实拥有一流的支持,可以使用单线程调度器运行。 在内部,它的时钟被抽象化,并且可以“暂停”以进行测试,在这种情况下,时间仅在调用 sleep() 时才会前进。 通过使用 tokio::time::Instant 而不是 std::time::Instant,你可以确保任何经过时间的测量都与此时钟对齐。 运行时还有一个内部 RNG,用于制定调度决策,例如为 tokio::select! 选择一个分支——但可以为它播种。

这使我们在 I/O 方面处于什么位置? 在这里,我们采用了 Turmoil 项目,该项目假定 Tokio 作为运行时。

Turmoil 是一个用于测试分布式系统的框架。 它通过在单个线程中运行多个并发主机来提供确定性执行。 它通过模拟网络中的变化将“困难”引入系统。 可以手动或使用种子 rng 控制网络。

模拟网络正是我们所需要的,在同一物理进程中的逻辑“主机”之间。 注入诸如延迟和进程崩溃之类的问题的能力对于在不愉快路径下梳理行为非常有用。

我们的每个联网服务都作为一个或多个主机运行。 Turmoil 提供了 Tokio 的 TcpListener / TcpStream 的对应物,我们在 DST 模式下使用它们,隐藏在编译时功能后面。

还需要考虑外部依赖项,例如元数据和对象存储。 使用最少接口进行编码的实践使我们能够轻松地替换实现。 我们有可以通过模拟 turmoil 网络访问的内存模拟器,也可以作为模拟中的主机运行。

我们设法运行了模拟——但是随着我们深入研究,它们并不完全具有确定性。 我们在 CI 中遇到了无法在 Mac 上重现的故障,甚至在某些情况下,在同一平台上运行之间也会出现故障。

我们未能控制住什么? 查看 TRACE 级别的日志,各种差异都非常突出。 像 HTTP 数据包中的时间戳这样的东西🤦。

内部 Slack 中关于可能导致不确定性的来源的讨论

Rust 成为一种高效语言的部分原因是高质量的库生态系统。 但是,它们中的每一个都可能代表多线程、对 RNG 的依赖、当前系统时间或外部 I/O 的来源。 控制世界很难——因此像 Antithesis 这样的初创公司有空间使其变得容易。 但是,感觉我们正在逼近目标,我们还没有准备好放弃。

很容易判断出没有线程正在启动,所有工作都在主线程上完成。 我们知道没有意外的网络调用,所有通信都严格通过模拟网络进行。 但是对于时间和随机性——turmoil 的方法不够全面,无法解决任意依赖项可能正在做的事情!

我们还意识到了一些微妙之处,例如 Rust 的 HashMap随机化以防止 DOS 攻击。 我们可以确保自己的应用程序为每个哈希映射使用种子 RandomState,但是我们的依赖项呢?

混合 Turmoil 和 MadSim

这就是我们开始研究 MadSim 的地方——用于 Rust 中分布式系统的神奇确定性模拟器——在 RisingWave 中使用有什么魔力? Github 中关于其覆盖 libc 函数的方法的讨论 我们喜欢基于 turmoil 的 DST 的整体人体工程学,但一点疯狂似乎是缺失的成分——libc 符号覆盖以控制时间和熵。 你可以查看我们刚刚推送到 Github 的 MadSim 派生的 crate,mad-turmoil

要点

那么,我们现在具有确定性了吗? 是的! 为了避免重蹈非确定性的覆辙,我们还在 CI 中添加了一个“元测试”,该测试重新运行相同的种子,并比较 TRACE 级别的日志。 直到线路上的最后一个字节,我们都具有一致性。 我们可以从 CI 中获取失败的种子,并在我们的 Mac 上轻松重现它。

这项努力值得吗? 毋庸置疑,分布式数据系统很复杂,生产使用总是会带来惊喜。 但是,知道我们可以在模拟时间内成熟我们的系统并及早发现很多问题,这会带来极大的缓解。

我们有一个正在运行的文档,记录了我们的 DST 帮助我们发现的各种棘手问题,并且总数达到了 17 个值得注意的问题。 从外部 API 和内部协议的细微之处到普通的死锁,应有尽有。 其中许多都会成为有趣的故事,因此请留意以后的帖子!

S2

用于流式数据的 Serverless API。

BlueskyLinkedInGitHubDiscordEmail © 2025 Bandar Systems Inc