Skip to content

필사 모드: Temporal Worker Versioning GA — 回放模型制造的部署难题,抛弃两代之后拿出的第三个答案

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 持久执行的账单在部署时寄到

持久执行(durable execution)引擎的整体对比,我已经在五月的深潜里讲过了。本文是它的反面,只挑一个引擎往窄了往深了挖 — Temporal 在 2026 年 3 月 30 日宣布了 Worker Versioning 的 GA,在 OSS 服务端则由 v1.31.0(2026 年 4 月 29 日)写明了 Worker Deployment API 的 GA。

听到「版本化功能 GA」你可能会打个哈欠,但这不是那种新闻。从首次预览(2023 年 6 月)到 GA 花了两年九个月,其间两代 API 被整个抛弃。在 GA 公告的第一段里,厂商自己就承认 — 给长时间运行的工作流部署新代码,一直是持久执行里最棘手的问题之一。为什么这个问题这么难,直接从引擎的核心设计,也就是确定性回放里推出来。就先从这个机制看起。

回放打破部署的机制

Temporal 工作流的持久性是这样运作的。工作流代码每发出一条命令 — 执行活动、定时器、子工作流 — 服务端就把它记进事件历史(event history),等到 worker 挂掉或被替换,新的 worker 就从头回放那段历史,以恢复代码的执行状态。要让这套魔法成立,需要一个前提 — 对同一段历史,代码必须始终发出同样的命令。官方文档明确要求了这一点:工作流代码必须是确定性的,像 API 调用或 DB 查询这类非确定的工作要挪进活动里,而活动由 Temporal 自动重试。

可这个前提,在代码改动的那一刻就崩了。

v1 代码: 执行 A → 定时器 → 执行 B      (历史已按这个顺序记录)
v2 代码: 执行 A → 执行 C → 定时器 → B  (在中间插入了新活动 C)

用 v2 worker 回放一个在 v1 上启动的执行时:
  历史里的下一个事件是「定时器启动」
  但代码发出的是「调度活动 C」这条命令
  → 非确定性(non-determinism)错误,工作流任务失败

如果只有短工作流,这算不上什么问题。它们在部署前就都结束了。但 Temporal 存在的全部理由,正是那些运行几天、几周、几年的工作流。也就是说「部署时刻没有正在运行的工作流」这个假设在结构上不成立,而回放这个持久性的源头,原样变成了部署的地雷。这不是 Temporal 的 bug,而是整个基于回放的持久执行模型共同承担的约束。

至今为止的答案 — 打补丁,以及它的代价

这个问题的传统答案是打补丁(patching)。借用文档的说法,它类似于特性开关,就是在代码里埋一个逻辑分支,去问「这个执行是不是在该变更之前启动的」。

from datetime import timedelta
from temporalio import workflow


@workflow.defn
class MyWorkflow:
    @workflow.run
    async def run(self) -> None:
        if workflow.patched("use-new-activity"):
            # 在这个补丁之后启动的执行
            self._result = await workflow.execute_activity(
                post_patch_activity,
                schedule_to_close_timeout=timedelta(minutes=5),
            )
        else:
            # 在补丁之前启动、还在运行的执行 — 保留旧路径
            self._result = await workflow.execute_activity(
                pre_patch_activity,
                schedule_to_close_timeout=timedelta(minutes=5),
            )

它能运作。问题在累积。GA 公告精准地点了出来 — 因为工作流可能运行几天、几周、几年,补丁会作为真实的代码复杂度和认知负担堆积起来。要删掉一个分支,你得先确认走旧路径的执行是否全部结束,再走一遍弃用(deprecate)流程,而这也是手动的。一句话,打补丁是把部署问题吸收进工作流代码里的条件判断的做法,而在变更频繁的代码库里,那些条件判断就是技术债。

Worker Versioning 把思路翻了过来 — 与其把版本分支埋进代码,不如按部署单位给 worker 整个做版本化,并把正在运行的工作流留在启动它的那个版本的 worker 上。想法很简单,可要把这个简单想法的 API 定下来,花了三年。

三年,三代 — 版本化 API 废弃的编年史

按时间顺序追一遍发布说明,这个功能的难度就直接摆在纸面上了。

2023-06-23  v1.21.0  V1 预览: 把 Build ID 归入「版本集」的方式
                     UpdateWorkerBuildIdCompatibility / GetWorkerTaskReachability
2024-05-31  v1.24.0  宣布废弃 V1 的版本集概念
                     → 由 V2「版本化规则」(assignment rules)替代,实验阶段
2024-12     (预发布) 基于 Deployment 的第三套设计登场
2025-06-27  v1.28.0  Worker Deployment API 升为公开预览
                     V1、V2 API 一并 deprecated
                     2024-12 预发布 API 停止支持
2026-03-30  博客     正式宣布 GA(面向全部 SDK)
2026-04-29  v1.31.0  OSS 服务端写明 GA,V1、V2 进入 sunset
                     → 预告在下一版本 v1.32.0 中移除

一个个数过来是这样。v1.21.0(2023 年 6 月)是首次预览 — 给 worker 打上 Build ID,再用 UpdateWorkerBuildIdCompatibility 把哪些构建彼此兼容登记进「版本集」。v1.24.0(2024 年 5 月)在发布说明里写道,版本集这个概念本身被废弃,由更灵活的「版本化规则」替代。而 v1.28.0(2025 年 6 月)的说明里,连作为「2024 年 12 月预发布」出现的那些基于 Deployment 的 API(DescribeDeployment 等)也被标为停止支持,而如今的 Worker Deployment API 升上公开预览。就在这时 V1、V2 API 被一并 deprecated,还给那些在 v1.27.x 上用版本化的用户附上了一条破坏兼容的公告 — 升级前请删除所有现有的 Deployment。

然后 v1.31.0(2026 年 4 月)把 SetWorkerDeploymentCurrentVersionSetWorkerDeploymentRampingVersionDescribeWorkerDeployment 等 9 个 API 宣布为 GA,同时预告 V1、V2 API 将在下一个服务端版本 v1.32.0 中移除(写这篇文章时最新的服务端是 v1.31.2,移除还没执行)。有意思的是,与 GA 同时,CreateWorkerDeployment 这类实验阶段的 API 是新加进来的 — 意思是这个表面还在动。

从这里能读出两点。第一,在回放模型之上设计「安全部署」的 API,难到足以让一个厂商推倒重来两次。第二,一条实务警告 — 如果你是 self-hosted,并且在 V1(build ID 兼容性)或 V2(版本化规则)API 之上堆了部署自动化,那么迁移不是可选项,而是一件有截止日期(v1.32.0)的活。

GA 的模型 — Deployment、Pinned、Auto-Upgrade

活下来的第三套设计,结构是这样的(以概念文档为准)。

Worker Deployment 是服务层级的逻辑分组(有名字),而 Worker Deployment Version 是这个服务的一次迭代,用部署名 + Build ID 来标识。worker 在开始轮询时会把自己的版本报告给服务端。worker 一侧的配置长这样(生产指南里的 Python 示例)。

from temporalio.common import WorkerDeploymentVersion, VersioningBehavior
from temporalio.worker import Worker, WorkerDeploymentConfig

worker = Worker(
    client,
    task_queue="mytaskqueue",
    workflows=workflows,
    activities=activities,
    deployment_config=WorkerDeploymentConfig(
        version=WorkerDeploymentVersion(
            deployment_name="llm_srv", build_id=my_env.build_id
        ),
        use_worker_versioning=True,
        default_versioning_behavior=VersioningBehavior.UNSPECIFIED,
    ),
)

核心是每个工作流类型上声明的 Versioning Behavior 两种。

@workflow.defn(versioning_behavior=VersioningBehavior.PINNED)
class OrderWorkflow:
    ...
  • Pinned — 在启动时的那个版本上一直运行到结束。就像文档给出的保证措辞那样,保证在单个 Worker Deployment Version 上完成。既然运行途中代码不会变,那个工作流就不需要打补丁。真要挪,就用 temporal workflow update-options 手动移动。
  • Auto-Upgrade — current 版本一变就自动跟着走。作为代价,正如文档明说的,它会在多个版本之间往返,所以你必须用打补丁手动维持回放安全性。不是版本化替代了打补丁,而是仅对 pinned 予以豁免。

路由靠三个概念转动。新工作流去往的 Current Version,先接一部分流量(0~100%,按工作流 ID 来分组)的 Ramping Version,以及每个执行接下来要登上的 Target Version。像金丝雀那样先只往新版本放 5%,没问题就把它升为 current,出了问题就把 current 回退到之前的版本 — 因为旧版本的 worker 还在轮询,回滚是即时的。

版本的生命周期这样流动:Inactive → Active → Draining(已从 current/ramping 上撤下,但还有 pinned 工作流残留) → Drained(残留的 pinned 全部结束)。继承规则也写进了文档 — pinned 父级的子级(如果是同一部署的任务队列)会继承父级版本,auto-upgrade 什么都不继承,而 cron 绝不继承。Continue-as-New 会把 pinned 版本沿着链条往后接续下去。

版本要求可不轻松。以文档为准:服务端 v1.29.1+,CLI v1.4.1+,SDK 则是 Go v1.35.0 / Python 1.11 / Java 1.29 / TypeScript 1.12 / .NET 1.7.0 / Ruby 0.5.0 及以上。

超长工作流留下的窟窿 — Upgrade on Continue-as-New

有一种情形,pinned 和 auto-upgrade 都没法干净地收拾。GA 公告直接举的例子:像跑几个月的实体工作流,或在交互之间一睡几周的 AI 智能体那样,实际上永不结束的工作流。留成 pinned,它就永远被绑在那个版本上;留成 auto-upgrade,就又回到了打补丁地狱。

与 GA 一同作为公开预览推出的 Upgrade on Continue-as-New,瞄准的正是这个窟窿。很多长寿工作流已经把 Continue-as-New 当检查点用了,所以主意是把那个边界当作版本升级点 — 每个 run 以 pinned 运行(run 内无需打补丁),而当新版本成为 current/ramping 时,通过 GetTargetWorkerDeploymentVersionChanged(每次工作流任务结束时刷新)检测到,并在下一次 Continue-as-New 切换到新版本。每个 run 在一个版本内结束,但逻辑上的工作流横跨了多个版本。v1.31.0 里还加入了接续到 ramping 版本的行为。不过,这部分还是公开预览,这一点需要纳入考量。

诚实的权衡

别被 GA 这个印章骗了,把它当成免费午餐来读。账单长这样。

第一,pinned 就是彩虹部署,而彩虹部署是基础设施成本。 只要还有 pinned 工作流残留,你就得让那个版本的 worker 一直转下去。文档自己就说,在彩虹部署里会同时有两个以上的活跃版本在跑。一个几个月的 pinned 工作流意味着一支几个月的 worker fleet,而每部署一次版本就多一个,运维对象也随之增多。若是 Kubernetes,已 GA 的 Temporal Worker Controller会替你管理按版本划分的 fleet,但计算成本本身并不会消失。

第二,auto-upgrade 并不能让你从打补丁里解脱。 就是上面引用的文档措辞。就算开了版本化,auto-upgrade 工作流的代码变更依然要求做回放安全性审查和补丁标记。「都 GA 了,所以我可以把 patched 调用全删掉」这个结论是错的。

第三,版本不能无限堆。v1.28.0 发布说明里的运维旋钮,每个 deployment 的版本数上限默认是 100,说明里警告把它提到几百以上是不安全的(与之对照的是每个命名空间的 deployment 数默认 100,它说大幅调高是安全的)。如果那些 drain 一直不结束的 pinned 执行堆积起来,版本卫生 — 清空并删除旧版本 — 就成了一项新的运维工作。drainage 状态的刷新默认也是 3 分钟一个周期,所以不是实时的。

第四,引入是一项部署流水线的集成工程。 版本化的一半不在服务端,而在你的 CD — 每次构建都打上 Build ID,并在部署之后往流水线里加入调用 SetWorkerDeploymentRampingVersionSetWorkerDeploymentCurrentVersion 的步骤。上面那张最低版本矩阵(服务端、CLI、SDK 全都要)也得填满。

第五,确实存在不必用它的情形。 如果你所有的工作流都短到能在部署间隔内结束 — 如果你能自然地把任务队列清空再部署 — 那么一条 unversioned 队列就够了。如果工作流代码的变更很少,那几个 patched 比运维按版本划分的 worker fleet 便宜。这个功能的盈亏平衡点,在于「部署之间不结束的工作流 × 代码变更频率」足够大的时候。

同一发布里的其余部分 — 引擎的方向

为了给个上下文,把 v1.31.0里一起发的东西也点一下。让服务端盯着任务队列、直接 invoke/scale AWS Lambda worker 的 Serverless Workers 以预发布进来(带来一个叫 Worker Controller Instance 的新服务端组件,默认关闭);把工作流之外的服务端内部状态机加以泛化的 CHASM 框架默认启用(其上的应用还是关着的);不带工作流、单独运行活动的 Standalone Activities 进入公开预览;任务队列的优先级/公平性升为 GA。Nexus 现在不用特性开关就常开。从工作流回放机器,到通用的持久执行平台 — 发布说明指向的方向是一致的。

那么你该不该用

把判断标准压成三个问题,就是这样。

  • 在部署之间真的有不结束的工作流吗? 没有的话到此为止 — 就用 unversioned 过日子。
  • 你在部署时真的撞上过非确定性错误,或者 patched 分支在代码评审里没完没了地被挑刺吗? 那么把 pinned 设为默认,能把那份痛苦置换成一个部署拓扑问题。
  • 你有跑按版本划分的 worker fleet 的基础设施余力吗? 如果承受不了彩虹部署的计算成本和版本卫生的活儿,那还不如把工作流切短、把 Continue-as-New 的周期收紧。

只需提防一个错觉 — 版本化解决的是部署问题,而不是重试问题。活动依然由 Temporal 重试,而这些副作用(支付、邮件、工单)的幂等性依然是你的分内事。这个话题在AI 智能体在生产环境中是如何失败的 — 14 种失败模式,以及重试为何不安全The Truth About Exactly-Once Semantics里详细讲过。尤其是,如果你的团队想把一个一睡几周的 AI 智能体裹进持久工作流,那么请一并记住:这次 GA 的 Upgrade on Continue-as-New 瞄准的正是那个场景 — 以及它还是个预览。

结语

归纳一下。确定性回放是持久执行的源头,但它的代价在部署时结账 — 因为历史和代码错开的那一刻,就会冒出非确定性错误。至今为止这份代价由工作流代码里的补丁分支吸收,而 Worker Versioning GA 把它挪进了部署拓扑 — 按版本划分的 worker fleet 与路由规则。约束并没有消失,只是换了地方。代码变干净了,作为代价,基础设施变厚了。

而这个答案能落地之前,一个厂商花了三年、抛弃了两代 API 这件事本身,大概就是关于「在回放模型之上做安全部署有多本质地难」这件事最诚实的证词。无论你采不采用,只要你在挑持久执行引擎,就把「这个引擎让我怎么在有工作流正在运行时改代码」放进你的第一批问题清单里。

参考资料

현재 단락 (1/96)

持久执行(durable execution)引擎的整体对比,我已经在[五月的深潜](/blog/culture/2026-05-25-durable-workflow-temporal-innges...

작성 글자: 0원문 글자: 9,324작성 단락: 0/96