挑战最快的开源工作流引擎 ylc3000 2025-11-11 0 浏览 0 点赞 长文 # 挑战最快的开源工作流引擎 十一月,是我开始开发 Obelisk(一个用 Rust 编写的开源工作流引擎)的两周年纪念。 为了回顾其功能,我决定与 [WindMill](https://www.windmill.dev) 进行一次友好的比较。WindMill 发布了一份有趣的[基准测试](https://www.windmill.dev/docs/misc/benchmarks/competitors/windmill),并附有一篇题为“[最快的可自托管开源工作流引擎](https://www.windmill.dev/blog/launch-week-1/fastest-workflow-engine)”的博客文章。 该基准测试比较了用各种编程语言编写的斐波那契数列的简单实现: ```rust fn fibo(n: u8) -> u64 { if n <= 1 { n.into() } else { fibo(n - 1) + fibo(n - 2) } } ``` 比较了以下(及更多)工作流引擎: * WindMill * Temporal * Perfect * Airflow ## 关于公平性的说明 通常的告诫是,您应该根据自己的特定需求进行基准测试。我尝试使用相同的方法,但我没有像 WindMill 那样重新测试任何竞争对手。但是我使用了相同的 AWS 实例类型 `t2-medium`,所以希望这至少能提供一些共同的基础。 然而,这里的主要焦点是 Obelisk 如何从自身的基准上改进,以及编译型语言(Rust, Go)与 JavaScript 和 Python 在 WASM 内部运行时的比较,以及 WASM 与原生代码的比较。 请向下滚动查看实际的基准测试数据。 ## 为什么 Obelisk 速度快 * 一切都在单个进程内运行 * 基于 WASM:支持轻量级虚拟机执行 * sqlite - 消除了网络往返 ## 设定基线 一遍又一遍地计算 Fibonacci(10) 似乎是最无聊的测试,但它展示了工作流引擎必须做的某些方面的工作: * 监控待处理的执行 * 锁定以避免多个执行器处理同一个项目 * 管理执行,在工作流的情况下生成子执行,在活动的情况下根据需要进行超时处理 * 写入完成的执行结果并通知其他等待结果的执行 运行像[这样的工作流](https://github.com/obeli-sk/benchmark-fibo/blob/main/workflow/rs/src/lib.rs): ```rust fn fiboa(n: u8, iterations: u32) -> Result<u64, ()> { let mut last = 0; for _ in 0..iterations { last = fibo_activity(n).unwrap(); } Ok(last) } ``` 几乎没有任何优化的希望。每次工作流使用 `fibo_activity` 生成一个子执行时,引擎都必须向数据库提交一个新的执行并等待结果到达。Obelisk 有两个技巧:一个使用 [Tokio](https://tokio.rs/) 通道的简单发布/订阅机制,避免了轮询数据库,以及两种等待策略。`interrupt` 策略将阻塞的工作流从内存中移除,稍后重放其执行日志;而 `await` 策略,顾名思义,会保持执行在一段时间内处于“热”状态,希望结果在截止日期之前到达。为了避免重放,基准测试使用了具有长超时的 `await` 策略。 ## 利用确定性 在前面的代码中,每个活动调用都必须提交到数据库,因为为了继续工作流,我们必须获得 `fibo_activity` 的结果。如果我们重写代码,一次性生成所有子执行,只返回一个 promise 会怎么样? ```rust fn fiboa_concurrent(n: u8, iterations: u32) -> Result<u64, ()> { let join_set = new_join_set_generated(ClosingStrategy::Complete); for _ in 0..iterations { fibo_submit(&join_set, n); } let mut last = 0; for _ in 0..iterations { last = fibo_await_next(&join_set).unwrap().1.unwrap(); } Ok(last) } ``` 我们不仅可以并行计算斐波那契数列,还可以延迟昂贵的 `fsync` 操作: 我们不必在每次 `fibo_submit` 时都进行提交,而是可以透明地收集所有可以延迟的事件,并在一个大的事务中将它们一起提交。如果工作流引擎崩溃,这个状态不会丢失,因为由于保证了确定性,在下一次执行运行时,重放后会重新创建完全相同的事件。 执行事件的缓存可用于上面提到的 `await` 策略。 ## 利用幂等性 - v0.26.1 中的新功能 与 WindMill 使用 Postgres 不同,sqlite 中没有基于行的锁定。这可能成为一个瓶颈,因为每个执行首先需要被锁定,然后处理,然后其结果需要被写入。然而,它也有一个优势,即所有 Obelisk 事务都是短暂的,事务期间的交互性非常有限。 我们可以使用类似于重放工作流的机制:以前的 sqlite 事务变成了一个 [`FnMut`](https://doc.rust-lang.org/std/ops/trait.FnMut.html) 闭包,产生一个逻辑事务(LTX)。然后我们可以将许多 LTX 打包到一个“物理”事务中。 在写入方面,Sqlite 实际上已经是单线程的,写入线程要么在等待传入的事务请求,要么在处理事务,要么在提交到磁盘时被阻塞。因此,我们可以在 `fsync` 正在进行时收集 LTX 闭包,然后打开一个“物理”事务并开始处理 LTX 闭包,直到 LTX 通道被清空。 如果单个 LTX 失败怎么办?它会使原始的批量事务失败,但我们可以在各自的事务中重放每个 LTX。 这种机制在活动完成和事务提交之间增加了一个小的延迟。然而,当服务器在活动成功后立即崩溃时,已经存在一个数据丢失的窗口。这是 Obelisk 中所有活动必须是幂等的原因之一——即使前一个执行成功,它们也可以重新启动。 在本地测试期间,LTX 批量处理将并发工作流的执行速度从每秒约 140 次提高到约 220 次。原始 `fiboa` 工作流的执行速度保持不变,但是运行许多独立执行的吞吐量得到了提高。 ## 作弊模式 Obelisk 将 sqlite 设置为 WAL(预写日志)模式,并将 `synchronous` 模式设置为 `full`,这意味着每次事务提交都会触发一个 `fsync`。在某些情况下,如果崩溃后丢失最后几个事务不是什么大问题,可以切换到 `normal` 模式,该模式在 WAL 满了之后,通过一次 `fsync` 将事务以批量方式写入。 在 [Litestream 的提示与注意事项](https://litestream.io/tips/#synchronous-pragma)部分也鼓励进行此设置,并指出在使用异步复制时已经存在数据丢失的窗口。 可以使用以下 TOML 代码段配置宽松的 `fsync`: ```toml sqlite.pragma = { "synchronous" = "normal" } ``` 我没有包含此设置的基准测试(所有基准测试都使用 `full` 同步),然而,通过此设置,我能够将本地的轻量级执行速度从每秒 220 次提高到超过 1000 次。 # 基准测试结果 [t2-medium](https://aws.amazon.com/ec2/instance-types/t2/) 实例类型具有良好的 IO 和单核 CPU 性能,但它只有 2 个 vCPU,并且 CPU 性能是突发性的,这可能会使结果产生偏差。 至于 Obelisk 的设置,所有的工作流、活动、原生 fibo 实现和配置文件都可以在 [benchmark-fibo](https://github.com/obeli-sk/benchmark-fibo/) 仓库中找到。 ## WASM vs 原生代码 除了 `Obelisk - Rust + native` 之外,所有 Obelisk 基准测试都具有相同的架构:一个工作流和一个活动,都用相同的语言实现,并编译成 [WASM 组件](https://component-model.bytecodealliance.org)。 原生基准测试运行 WASM 工作流,该工作流又执行 WASM 活动,该活动使用[进程 API](https://obeli.sk/docs/latest/concepts/activities/process/) 来执行原生进程,从而进行实际的斐波那契计算。因此 `Fibo(10) * 1000 iterations` 会生成 1000 个 `N=10 fibo` 进程。 ## 小型 CPU 活动 工作流顺序调用一个短活动 40 次。 **Fibo(10) \* 40 次迭代 — 时间 (s)** | 引擎 | 时间 (s) | | :--- | :--- | | Obelisk - Rust | 0.226 | | Obelisk - JavaScript | 0.263 | | Obelisk - Rust + native | 0.292 | | Obelisk - Go | 0.310 | | Obelisk - Python | 0.383 | | WindMill Dedicated - Python | 2.092 | | WindMill Dedicated - JavaScript | 2.125 | | WindMill - JavaScript | 2.973 | | WindMill - Go | 2.973 | | WindMill - Python | 4.383 | ## 中等 CPU 活动 **Fibo(33) \* 10 次迭代 — 时间 (s)** | 引擎 | 时间 (s) | | :--- | :--- | | Obelisk - Rust + native | 0.220 | | Obelisk - Rust | 0.379 | | Obelisk - Go | 0.444 | | WindMill - Go | 0.780 | | WindMill - JavaScript | 0.935 | | WindMill Dedicated - JavaScript | 1.077 | | WindMill Dedicated - Python | 7.701 | | WindMill - Python | 8.347 | | Obelisk - Python | 18.761 | | Obelisk - JavaScript | 38.214 | 纯粹在 WASM 中运行的 Go 和 Rust 仍然表现良好,然而在 WASM 中的 Python 和 JavaScript 则落后了。 ## 大型 CPU 活动 在此基准测试中,一个工作流执行 100 次一个活动,每次计算 Fibo(38)。由于 WASM 中的 JavaScript 和 Python 在 Fibo(33) 上已经表现不佳,我将它们从下一个测试中排除了。 [WindMill 的基准测试文档](https://www.windmill.dev/docs/misc/benchmarks/competitors/windmill#fibonacci-100-iterations-n-38)也没有这些语言的结果。 这个基准测试表明,也许不足为奇,WASM 运行时无法与原生代码竞争。然而,我相信应该从 WASM 开始,只有在需要时才转换为原生代码,因为大多数现实世界的代码都会被网络调用阻塞。 **Fibo(38) \* 100 次迭代 — 时间 (s)** | 引擎 | 时间 (s) | | :--- | :--- | | Obelisk - Rust + native | 15.521 | | WindMill - Go | 27.648 | | Obelisk - Rust | 35.127 | | Obelisk - Go | 41.642 | ## 增加并行性 此基准测试计算 Fibo(38),迭代 100 次,但活动现在并行处理。请注意,`t2-median` 只有 2 个 vCPU,如果可用并行度更高,这些基准测试会好看得多。然而,它显示了运行 WASM 二进制文件和原生代码之间的区别。 **Fibo(38) \* 100 次迭代 (并行) — 时间 (s)** | 引擎 | 时间 (s) | | :--- | :--- | | Obelisk - Rust + native | 7.653 | | WindMill - Go with 10 workers | 11.899 | | Obelisk - Rust | 17.456 | | Obelisk - Go | 20.717 | 我将添加另一个基准测试,其中包含大量并行执行和极少的 CPU 活动: **Fibo(10) \* 1000 次迭代 (并行) — 时间 (s)** | 引擎 | 时间 (s) | | :--- | :--- | | Obelisk - Go | 4.262 | | Obelisk - Rust | 4.367 | | Obelisk - Rust + native | 4.729 | | Obelisk - JavaScript | 4.910 | | Obelisk - Python | 5.233 | 这个测试更侧重于数据库压力而不是 CPU,并从*利用确定性*和*利用幂等性*部分描述的优化中获益。 ## 结论 Obelisk 在每个基准测试中都占据了主导地位。 一个关键的发现是,简单的架构通常可以胜过更复杂的系统。Obelisk 没有 Docker Compose 文件,因为整个系统——数据库、编排器、webhooks、工作流和活动都在一个单一的进程中运行。 为了与原始基准测试保持一致,测试使用了具有 4GB 内存的 AWS 实例,但 Obelisk 能够在更小的虚拟机(通常为 256 到 512MB)中运行。 尽管在 CPU 密集型应用中 WASM 永远无法击败原生代码,但它适用于这种用例,支持每秒创建数千个轻量级虚拟机,在需要时从内存中卸载正在运行的工作流,并以保证的确定性重放执行事件。 随着像 [Wasmtime](https://wasmtime.dev/)、[Litestream](https://litestream.io) 和 [Turso DB](https://github.com/tursodatabase/turso) 等技术的成熟,这种简化的部署模型将变得越来越容易实现和普遍。 网闻录 挑战最快的开源工作流引擎