Ohhnews

分类导航

$ cd ..
foojay原文

持久化执行是一种属性,而非产品

#持久化执行#工作流引擎#temporal#jobrunr#基准测试

三次方法调用。这就是我的全部工作流:扣款、锁定库存、发送确认邮件。

把这三次调用跑在一个专用的工作流引擎上,在你的业务逻辑完成之前,会发生这些事:追加 23 条历史事件,向数据库提交 15 次持久状态转换,并在你的 worker 与编排集群之间往返 15 次 gRPC。

这一段话,就是我想聊聊持久化执行的原因。不是因为引擎不好。它们是由严肃的工程师打造的、令人印象深刻的系统。我想聊它,是因为 Java 社区正在被兜售一种特性,就好像它是一个产品,而我认为我们大多数人其实已经拥有这种特性了。

在继续深入之前,先做完整披露:我在 JobRunr 上工作,这是一个面向 Java 的开源后台任务调度器。你读这篇文章时,绝对应该把这一点记在心里。这也是为什么以下所有内容,要么是在一个你可以自己运行的公开仓库里实测出来的,要么是引用自引擎厂商自己的文档。

特性与产品

持久化执行只意味着一件事:重要的工作能在崩溃中存活并恢复,而不是从头再来。你的三步订单任务不应该因为某个 pod 在第 2 步和第 3 步之间被 OOM 杀掉,就重新扣客户的钱。这就是那个特性,而且你希望几乎所有在后台运行的东西都具备它。

而在某个节点上,这个特性被包装成了一个产品品类。像 Temporal 这样的工作流引擎,通过确定性的、基于事件溯源的重放来提供持久性:引擎记录一个工作流生命中的每一个事件,崩溃之后,它从头重新运行你的编排代码,把记录下来的结果喂给它,直到追上它死亡时的进度。这是一个真正优雅的模型,但同时也是一个很重的模型。你的编排代码必须是确定性的,这意味着不能用时钟、不能有随机值、不能在活动(activity)之外做 I/O。修改一个正在运行的工作流,变成了一种版本管理纪律。而且在运维层面,你现在要运行第二套分布式系统:Temporal 自己的文档就描述过,在你的第一个工作流执行之前,你得先部署四个独立扩展的服务,外加一个专用的持久化存储。

[LOADING...]

大多数厂商不会告诉你的一点是:事件溯源重放只是持久化执行的一种实现。数据库行里的一个检查点,是另一种实现:运行一个步骤,记下它已完成,重试时跳过所有已经记录下来的部分。两种方式都能在同样的崩溃中存活下来。它们只是付出的代价天差地别,而这个差别是可以测量的。

精确一次本来就不在菜单上

在我们测量任何东西之前,得先澄清那个通常会终结这场讨论的论点:"没错,但引擎给我精确一次。"

它并没有,厂商自己也这么说。Temporal 的文档明确写道,活动可能会被执行不止一次。工作流逻辑重放起来就像只运行过一次,但那些触碰真实世界的步骤——扣款、调用 API 的那些——是以至少一次的方式执行的。一个进程永远可能在副作用已经发生、但记录还未持久化时死掉。世上没有任何架构能关上这扇窗,因为宇宙不提供跨越你的进程和别人的支付 API 的事务语义。

这就是为什么每一个持久化执行引擎都告诉你要让步骤幂等。这也是为什么实际竞争的环境比推销话术所暗示的要公平得多。无论你跑的是工作流引擎还是任务队列,真正困难的部分——让涉及资金的步骤可以安全地重复——无论哪种方式都是你自己的活儿。向你的支付服务商传一个稳定的幂等键,重复的尝试就变成了空操作。这一行纪律,在两种世界里都欠着。

一旦你看清这一点,问题的形状就变了。它不再是"安全的引擎 vs 不安全的任务"。而是:同一个特性的两种实现,都要求幂等的步骤。一种需要一套新的分布式系统和一份确定性契约。另一种需要你已经运行着的数据库。那么,更重的那一种究竟要花多少钱?

在你已有的技术栈上,这个特性的代价

让我先给你看更轻的那种实现,因为我觉得亲眼看到它能破除魔力。你不需要框架就能获得带检查点的步骤。一张表和三十行 JDBC 就够了:

$ query
create table jobs (
    id              uuid primary key,
    type            text  not null,
    payload         jsonb not null,
    state           text  not null default 'ENQUEUED',
    attempts        int   not null default 0,
    completed_steps jsonb not null default '[]'
);
$ java
void runStepOnce(Connection con, UUID jobId, String step, SqlRunnable sideEffect)
        throws Exception {
    try (var check = con.prepareStatement(
            "select jsonb_exists(completed_steps, ?) from jobs where id = ?")) {
        check.setString(1, step);
        check.setObject(2, jobId);
        try (var rs = check.executeQuery()) {
            if (rs.next() && rs.getBoolean(1)) return;   // 已经完成,跳过
        }
    }

    sideEffect.run();                                     // 真实世界在这里发生

    try (var mark = con.prepareStatement(
            "update jobs set completed_steps = completed_steps || to_jsonb(?::text) where id = ?")) {
        mark.setString(1, step);
        mark.setObject(2, jobId);
        mark.executeUpdate();                             // 检查点,一次 UPDATE
    }
}

加一个轮询循环、一个重试计数器,再加一个用于崩溃恢复的 locked_until 列,你就在团队已经在运维、监控和备份的基础设施上构建出了持久化执行。在副作用和检查点之间,有一个至少一次的窗口,和引擎完全一样,而你也用同样的方式关上它:幂等的步骤。

我并不是认真建议你在生产环境里手搓这个。僵尸任务检测、指数退避、仪表盘和分布式锁,才是吞噬你周末的部分。已经有库在你现有的数据库之上完成了这一切;JobRunr 就是我在做的那个,有了它,本文开头那个完整工作流只是一个方法:

$ java
@Job(name = "Fulfill order %0", retries = 5)
public void fulfillOrder(String orderId, JobContext jobContext) {
    jobContext.runStepOnce("charge-payment", () ->
            paymentService.charge(orderId, jobContext.getJobId().toString()));
    jobContext.runStepOnce("reserve-inventory", () -> inventoryService.reserve(orderId));
    jobContext.runStepOnce("send-confirmation", () -> mailService.confirm(orderId));
}

你也不必凭空想象这个机制。刚刚发布的 JobRunr 9 会把它展示在屏幕上:仪表盘中的任务历史新增了一个任务进度图视图(Job Progress Chart View),逐步展示每一次尝试。下面是我们一个演示任务——一次因支付服务商超时而失败的发票运行。第一次尝试在扣款步骤失败。重试并不会从头开始:两个已经完成的步骤被标记为 skipped,任务正好从扣款处恢复,而在每个步骤旁边,你都能看到它花了多长时间。这个视图在免费的开源版本里就有,我喜欢它,因为它让本文的论点变得可见。那张图里没有重放的魔法。它只是一个数据库行,记住了哪些步骤已经完成。

[LOADING...]

但即便没有任何库,这个观点依然成立。这个特性在你当前的栈上就能获得。问题只在于,作为产品来买它,要在其之上多付出多少成本。

实测凭据

空口无凭,所以我们做了测量。我们用两种方式实现了同一个三步订单工作流,一次用 JobRunr 加 Postgres,一次用 Temporal Java SDK,各自用 24 个 worker 跑了 1000 个订单。而且,因为一个被操纵的基准测试比没有基准测试更糟,每一个判断题我们都朝对引擎有利的方向选了:Temporal 运行的是真实的自托管生产镜像,配它自己的 PostgreSQL,使用 512 个历史分片(它的生产默认值),而不是内存版开发服务器。工作流的启动由 24 个并发线程发出。我们甚至把 SDK 的任务轮询器从默认的 5 提高到了 24,因为默认值会悄悄限流快速的活动,而我们想测量的是引擎,不是一个配置错误。完整的测试框架在 GitHub 上,你可以自己全部跑一遍。

在一台专用的 8 核 Hetzner 服务器上,同样的 1000 个订单:

JobRunr on PostgresTemporal,自托管
即时步骤1.8 秒13.6 秒
每步 25 ms 的真实工作8.4 秒13.7 秒
CPU,所有进程13.3 cpu-s83.2 cpu-s
峰值内存388 MB868 MB

一台 14 核 Mac 讲出了同样的故事,差距还更大。但最值得盯着看的是第二行。当我们为每个订单加入 75 ms 的模拟 API 延迟后,JobRunr 的总耗时从 1.8 秒增长到 8.4 秒,因为它大部分时间都在等实际的工作。Temporal 的总耗时没有变化。真实的工作完全被隐藏在了引擎自身的开销里。当增加工作变得免费时,瓶颈就是编排器,而不是你的代码。

接着我们去问数据库究竟发生了什么。对于这 1000 个订单,任务队列提交了 1,181 个 Postgres 事务,大约每个订单一次插入加两次更新。引擎在它的两个数据库之间提交了 113,218 个事务。同样的三个步骤,同样的持久性特性,95 倍的持久化写入。而这其中没有任何一个是 bug。这就是文档所描述的设计:每个工作流 23 个事件,每一个活动之后都安排一个全新的工作流任务,好让某个 worker 去轮询、把你代码推进一行,并通过 gRPC 响应。Temporal 自己的容量指南用每秒状态转换数来衡量集群吞吐量,Temporal Cloud 则按操作计费。写放大不是实现的意外。它就是计价单位。

引擎何时值回票价

到这里,我似乎应该告诉你引擎永远是错的,但我不会,因为那不是真的。

那些每个订单 113 次事务,买来了真实的东西:每一次执行的完整事件历史、基于重放的调试、可查询的工作流状态、信号、定时器、子工作流,以及跨用不同语言编写的服务进行编排的能力。三个问题能告诉你是否需要它们。你的编排是否分支深到需要完整的重放和工作流版本管理?是否有一个工作流要协调用多种语言编写的服务?你是否需要把信号、查询和子工作流作为一等原语?

[LOADING...]

如果你回答"是",那就采用引擎,别回头。最重的编排问题恰恰就是它被造出来解决的,而它的成本是那些能力的诚实价格。Temporal 自己的联合创始人也是这么描述这个工具的:它不是用来取代队列的,而是一种不同的应用设计方式。

但要诚实地面对你的回答。对大多数 Java 团队、对大多数后台工作来说,这三个问题都是"否"。条件反射般地仍然伸手去拿引擎,和当年让中型团队搞出五十个微服务、一套每天处理 200 条消息的 Kafka 集群,是同一种反射。我们在这个行业里一次又一次地过度购买编排,而持久化执行眼下正好经历着这样的时刻。## 直白地说出观点

持久执行是一种几乎值得为你所有后台运行任务都拥有的属性。它意味着持久化、带检查点的步骤、自动重试以及幂等的副作用。今天,你可以从自己已经在运维的数据库中获得这种属性,无论是通过一个小型库,还是——如果你足够固执——三十行 JDBC。这些引擎通过一种重得多的机制提供同样的属性,并附带大多数后台任务永远不会调用的能力,而代价你现在已经知道如何衡量:在我们的基准测试中,大约是 6 到 10 倍的 CPU、2 倍的内存、95 倍的数据库事务,以及值班轮值表上多出的第二个分布式系统。

需要产品时再买产品。永远不要为了获得你已经拥有的属性而去购买它。


Nicholas D'hondt 在 JobRunr 工作,这是一个面向 Java 的开源作业调度器。本文的基准测试框架、原始结果和插桩代码可在 github.com/iNicholasBE/temporal-vs-jobrunr-benchmark 获取。所有关于引擎内部机制的声明都参考 Temporal 的公开文档:docs.temporal.io/workflow-execution(状态转换)、docs.temporal.io/tasks(工作流任务)、docs.temporal.io/cloud/actions(按操作计费),以及 temporal.io/blog/scaling-temporal-the-basics(以每秒状态转换数衡量的容量)。
分享此页面

发现错误,或有内容要补充?在 GitHub 上编辑此页面 [LOADING...]
作者

Nicholas D'hondt

JobRunr 增长负责人

加入讨论