返回 文章 学习 CMS 文章

最好的工作流引擎是编程语言:Vercel Workflow SDK 深度解析

用 TypeScript 写工作流,无需新基础设施,持久化执行像普通代码一样自然。

Workflow SDK持久化执行TypeScript无服务器
成长分 / 100 70 综合收获、行动、留存与影响

最好的工作流引擎是编程语言:Vercel Workflow SDK 深度解析
为什么值得读了解如何用编程语言本身作为工作流引擎,避免手动绘制 DAG 的复杂性。

学习 Workflow SDK 如何通过 'use workflow' 和 'use step' 指令实现持久化执行和自动重试。

关键洞察
  1. 代码本身就是 DAG,抽象语法树就是 DAG,无需手动绘制工作流图。
  2. Workflow SDK 是一个库而非平台,运行在你已有的基础设施上,后端可替换。
  3. 用单一原语 hook 取代了 Temporal 的信号、查询和更新三个原语,简化人在回路交互。
转成行动

深入阅读

正文与原文对照

原文保真覆盖:全文原文字符:12688

Workflow SDK 无需新增基础设施,就能把 TypeScript 变成持久化执行。

在不可靠、无状态的基础设施之上编排长时间运行的有状态逻辑,这个想法并不新鲜。消息队列、作业运行器、微服务编排,乃至成熟的完整工作流引擎,我们已经拥有很久了。我们从来没有的,是一个写起来让人觉得舒服的版本。

我花了大约六个月时间研究 Temporal 的一个分支,大多是在周末,试图把它改造成一个 serverless 方案,让开发体验更贴近 Vercel 原生风格。最终我意识到,要交付这样的体验,我还得掌控执行环境。于是我放弃了这个分支,加入 Vercel,并与 Nathan Rajlich 一起从零开始开发一个新框架。

代码本来就是一张 DAG

工作流就是一张 DAG,即有向无环图。在 Temporal(以及它之前的 Cadence)出现之前,几乎所有工作流框架都要你手工画出这张 DAG。Apache Airflow 就是典型的例子。你要把流水线描述成由任务和依赖构成的显式图,而真正的业务逻辑则被埋在节点里面。

这在我看来一直是本末倒置。我们早就有一种工具,可以表达“先做这个,再做那个,这几件事并行,在这里分支”,那就是编程语言。抽象语法树是一张 DAG,软件本身也是一张 DAG。

我在抽象层面上明白这一点,但直到看到 Temporal 才真正有所触动:你写的是看起来很普通的顺序代码,引擎在底层让它具备持久性。这才是一直以来的理想。

运行 Temporal 教会我的事

只要基础设施到位,Temporal 就很出色。而我是从零开始的,搭建它意味着:

  • 搭起 Temporal Cloud(或自托管服务端:Frontend、History、Matching 和 Worker 服务,外加 Cassandra/Postgres/MySQL 后端以及分片)。
  • 运行你自己的 worker 集群:Temporal 从不执行你的代码。是你的 worker 轮询服务端,并运行你的工作流和 activity。实际上,这就意味着一个由你自己维护的 Kubernetes 集群。
  • 把所有部分接起来:任务队列、activity 注册、客户端配置。
  • 自己管理 worker:扩缩容、可用性、重启,以及 worker 进程的构建和部署流水线。
  • 配置加密:worker 通过互联网与控制平面通信,因此你要配置双向 TLS 和数据转换器,在载荷离开你的环境之前对其加密。

所有这些就绪之后,工作流的理想基本成了现实。但我想要的是一个容易上手、又能在项目走向生产环境时继续扩展的东西。

运行中工作流的版本管理

除了要自己负责 worker 的构建和部署,还有一个更难的问题。如果我在运行仍在进行的时候修改了工作流代码,就有点进退两难了。因为已经没有任何 worker 在运行那段代码的旧版本,也就是正确的版本。重放时,新代码会遇到旧的事件历史,并因非确定性错误而中断。

官方的答案是补丁 API(patched() / GetVersion)。你根据变更 ID 对代码做分支,并随着旧的运行实例逐渐过了保留期,让它走完“先弃用、再移除”的多阶段生命周期。这个办法可行,但意味着演进工作流代码时必须极其小心。变更一多,工作流代码就会腐化成一团版本标志。

我没法解释清楚 Temporal 的 signal

每当我试图向朋友演示运行中的 Temporal 时,signal / query / update 就成了最让人困惑的部分。它们是三个独立的原语,用来向运行中的工作流输入和输出数据,各有各的规则。query 不能阻塞,signal 是发出即忘的,并且会被缓冲,你还得知道什么时候该用哪一个。这些概念确实能理解,但并不直观。在我看来,这是开发体验上的坏味道。如果我不借助白板就讲不清楚“人在回路”的原语,那它就太复杂了。这就是 Workflow SDK 用单一原语 hook 取代这三者的原因。

Workflow SDK 目前的样子

使用 Workflow SDK,你只需写一个由普通代码构成的文件。"use workflow" 标记一个编排器,"use step" 标记一个带副作用的工作单元。在两者之间,只是普通的 await、try/catch、Promise.all、循环和条件判断。

order-workflow.ts

export async function processOrderWorkflow(orderId: string) {
  "use workflow";
  const order = await fetchOrder(orderId);
  await chargePayment(order);
  return { orderId, status: "completed" };
}

async function chargePayment(order: Order) {
  "use step"; // full Node.js access in here
  const charge = await stripe.charges.create({ /* ... */ });
  return { chargeId: charge.id };
}

一个调用单个持久化 step 的订单工作流。step 中未捕获的错误会自动重试。

从这一个文件里可以推出几点:

  • 没有 DAG 文件:编译器读取这些指令,并把代码拆分为工作流包和客户端/step 包。图就是你的控制流。
  • 重试:未捕获的错误默认会重试。抛出 new FatalError(...) 可以停止重试。抛出新的 RetryableError(..., { retryAfter }) 可以自定义退避。设置 fn.maxRetries = n 可以调整。
  • 临时 webhook:const webhook = createWebhook() 只需一行,就能在运行中的工作流里创建一个真实可调用的 URL。运行会挂起,直到有人访问它,不需要路由,也不需要处理函数。
  • Hook:webhook 是更通用原语之上的语法糖。用 createHook<T>() 创建一个 hook,await 它,别人发送的数据就是返回的结果。这一个概念取代了 signal、query 和 update

下面是一个临时 webhook 的完整示例。这个审批流程可以暂停几秒,也可以暂停几周,无需额外部署任何东西。

approve-expense.ts

export async function approveExpense(expense: Expense) {
  "use workflow";
  // A real, callable URL, created inside the run
  const webhook = createWebhook();
  // Sending the email is a durable step
  await emailManager(expense.managerEmail, webhook.url);
  // Parks here until someone POSTs to webhook.url
  const request = await webhook;
  const { approved } = await request.json();
  return { expenseId: expense.id, approved };
}

一个审批工作流,会挂起在 webhook 上等待有人响应,可以是几秒,也可以是几周。

是一个库,而不是一个平台

传统引擎要你把基础设施带给框架。Workflow SDK 则是把框架带到你已有的基础设施上。

DBOS 的想法是对的

在琢磨具体怎么构建它的时候,我研究了所能找到的每一个工作流引擎。其中脱颖而出的是 DBOS,这是一个开源的持久化执行库,几乎完全运行在客户端。它的服务端唯一需要的就是 Postgres。

Workflow SDK 采用了这种形态。没有需要自托管或付费的专属编排器,也没有需要运维的新有状态系统。它需要的唯一基础设施,就是你本来就在运行的:你的应用、一个数据库、一个队列。所有的重活都在库里完成。

后端可以替换

Workflow SDK 把这个想法推广到了不止一种数据库。整个东西就是一份规范,一种编写工作流、step 和 hook 的方式,任何基础设施都可以作为后端支撑它。你可以根据自己的技术栈,自由组合底层:

  • 用 Redis / Kafka 等做流
  • 用 Postgres / Cassandra / 文件系统 / Turso / Durable Objects 等做持久化
  • 用 Vercel Queues / SQS / Cloudflare Queues 等做队列

运行时只与一个接口打交道,即 World,它涵盖存储、队列、鉴权和流式传输。替换任何一层,都不会触及你的工作流代码。

事实上,我们维护着一个受 DBOS 启发的官方 Postgres world,用 Postgres 实现持久化、队列和流式传输。而且在每一种 world 中,都没有 worker 集群,也没有控制平面。框架集成会暴露两个普通的 HTTP 端点,部署方式与你应用的其他部分一样。

就连 Vercel 的后端也“只”是一个 CRUD API

Vercel Workflow Server,也就是在 Vercel 上支撑工作流的那个东西,是无状态的,完全不做计算或编排。它就像 Postgres world,只是加上了 Vercel 的认证和多租户能力。就是一个 CRUD API,仅此而已。它甚至就作为一个普通的 Vercel 部署来运行。所有的重活、所有真正的工作流逻辑,都在开源的、采用 Apache 许可证的客户端库里,可以自由扩展。

所以,这个“托管”产品并不是一个会把你锁定的黑盒。它只是另一个 World,是一份你可以阅读、fork 和扩展的规范的一种实现。数据格式也不是专有的。技术栈中的任何一块你都可以换掉。

有些模式依赖平台

我应该坦率地说明:Workflow SDK 的少数设计选择,受到了 Vercel 执行代码方式的影响。最明显的例子是版本管理,也就是那个让传统引擎如此痛苦的代码演进问题。

我们的答案是,把每次运行固定在启动它的那个具体部署上。运行会一直对照它开始时所用的那份代码副本继续执行,因此修改工作流不会破坏运行中的实例。这是个干净的方案,但之所以容易实现,只是因为 Vercel 本来就会长期保留不可变的部署。官方的 Postgres world 目前还没有以这种方式追踪和路由版本。令人鼓舞的是,社区的 world 已经按预期实现了这份规范。Platformatic 在 Kubernetes 上实现的版本安全的持久化工作流就是一个很好的例子。

我认为这恰恰是正确的取舍。固定部署把版本管理的复杂性从编写工作流的开发者身上,转移到了基础设施上。这个负担落在 world 的作者、基础设施提供方以及作为框架作者的我们身上。如果每个使用框架的人都遇到同一个难题,并且都得各自用同样痛苦的方式去解决,那就是框架辜负了他们。好的框架会把这种复杂性上移。

Step 应该是免费的

性能是我们接下来要解决的最大问题。调用一个 step 的成本并不低。每个 step 都涉及网络通信,以及经由队列往返一趟,以便正确地持久提交其结果。从正确性的角度看,这样做是对的。但它与“分布式计算应该像函数调用一样”的主张是相悖的。

写普通代码时,你不会对函数调用的开销多想一秒。它快到几乎可以忽略不计。但如果每个 step 都带有沉重的开销,抽象就会泄漏。你会开始限量使用 step,开始掂量粒度,因为“不值得为它设一个 step”而不愿意把一个小函数变成检查点。

所以目标很直白:step 应该是免费的。

在理想的版本中,你可以拿现有代码库里的任何函数,加上“use step”,一切就能直接工作,没有序列化方面的限制,也没有性能损耗。

这个理想驱动着我们。

如果任何开发者只需两个词“use step”,就能把一个普通函数变成具备独立微服务那样的性能、网络能力、可观测性和可用性的东西,会怎么样?那将改变我们写代码的方式。

Workflow v5(撰写本文时处于 beta 阶段)已经在不改变面向用户 API 的前提下带来了最高 5 倍的性能提升,v6 会进一步提升,同时让第三方 world 同样快。我们会在这个 GitHub 讨论中随时分享细节,你今天就可以用 npm install workflow@beta 开始测试。

我一开始的目标,是构建一个持久化框架:写起来像普通代码,运行在你已有的基础设施上,而且足够快,让你在使用 step 时从不犹豫。它今天已经成为现实,其余部分我们正在公开构建。

来和我们一起构建 Workflow SDK

如果在真实约束下设计持久化系统听起来很有意思,Vercel 的 Workflow 团队正在招人。

查看开放职位。

贡献者

Kevin Sundstrom