Temporal 项目实战:将客户开通流程迁移到持久执行架构

在前面的 AcmeFlow 案例里,星河设备客户开通中心已经有了申请、运营审核、合同签署、到账、ERP 创建与权益开通。第十一篇讨论过为什么长时间等待不能只靠一段常驻 Python 进程。本篇更进一步:假设业务已经跑在旧的数据库调度器上,现在决定引入 Temporal,怎样让新实例使用持久执行,同时让正在处理的旧实例安全收尾?这不是把一段函数改写成装饰器的问题。真正危险的是迁移窗口里两个系统都以为自己是同一申请的主人,一个在旧表里扫描任务,一个在新引擎里等待消息,然后都去调用 ERP。
我们会做一个真实引擎实验。代码启动本地 Temporal 开发服务,提交带 UUID 的申请,停止第一个 Worker,再启动第二个 Worker;在期间发当前版签署和到账事实,让流程恢复执行。另一个流程在 Worker 停止期间越过期限,恢复后被判为过期。外部 ERP 和权益由两个独立的 SQLite 提交模拟,并注入一次"ERP 已提交、响应丢失",观察重试后是否出现重复记录。实验能验证引擎行为与本地机制,不能证明真实 ERP、生产服务端持久化或组织级迁移已经完成。

图 1:业务切流示意图。图中的路由表是本篇教学机制,生产上需要与创建申请、发消息的入口形成可审计的一致性边界。
一、先定义迁移不变量,再选择引擎
我们的业务身份沿用第 03 篇:tenant_id=xinghe-demo、application_id 为 UUID、business_key 在租户内唯一,material_version 表示资料版本。审批和签署必须绑定当前资料版;到账是独立业务事实。审批、签署、到账三项齐备,只能到 READY。ERP 明确返回或对账确认 CREATED,权益明确成功后才能到 ACTIVE。这个状态门槛来自业务协议,不会因为使用 Temporal 自动成立。把业务不变量写在引擎之前,后面才能识别模型和代码各自负责什么。
迁移的第一个不变量是"单实例单所有者"。业务申请一旦创建,就拥有唯一的执行 owner:legacy 或 temporal。旧申请不需要为了形式统一而强行改写成 Temporal 事件历史;它们由旧调度器按原版本收尾。切流后创建的新申请只由 Temporal 路径派发。这里的切流依据是申请实例,而不是某天零点之后所有事件统一进新系统。因为合同回执可能迟到,切流之后到达的一条消息仍可能属于旧实例。如果仅凭消息到达时间转给 Temporal,新引擎会收到一个自己从未创建过的业务流程。
教学代码使用 routes(application_id, owner) 的主键唯一约束作最小机制演示。新申请写 temporal 后,旧调度器的 may_dispatch 会拒绝它;旧申请写 legacy 后,Temporal 入口也不能启动它。这段路由并未连接第 03 篇 FastAPI/PostgreSQL 主基座,不能宣称已经完成双系统生产迁移。真正接入时,申请创建和路由登记最好在同一可信命令路径完成,避免"申请已经保存,owner 还没落库"的空窗;事件网关必须先按申请 ID 查 owner 再投递,而不是让两个引擎各自订阅所有回调。切换开关也应先支持灰度、回退新建流量,已创建实例的 owner 不宜随意改写。
第二个不变量是"引擎知道流程,不代替业务系统知道事实"。Temporal 将 Workflow 的等待、计时、Activity 调度等事件保存在服务端,Worker 可以依据事件历史恢复计算。官方文档说明 Workflow Execution 与事件历史、重放之间的关系,也说明 Workflow 可通过 Signal 接收消息、通过 Activity 与环境交互。Temporal Workflow Execution 文档 但 ERP 是否真的创建、权益是否真的生效,是外部系统的事实。引擎记录了一次 Activity 结果不等于远端系统从此不会重复处理请求。边界一旦说不清,团队容易把"恢复执行"误听成"跨系统 exactly once"。本篇刻意用一次丢回复实验暴露差别。
第三个不变量是"当前资料版决定事实是否有效"。申请在 v2,v1 审批或 v1 合同签署不能满足 v2 的前置条件。演示 Workflow 用 Fact(kind, material_version, event_id) Signal 接收事实,并保留已处理的 event_id 集合。Signal 在 Workflow 里执行版本比较;重复同一个事件 ID 不再重复改变事实。这里仍有生产缺口:Signal 处理器本身没有认证调用者,也没有在输入中再次证明租户身份。生产应在外层可信 API 层核对租户、申请 ID、消息签名、回执来源和事件权限,再定位 Workflow ID 发 Signal。仅有一个 UUID 并不能构成授权。
二、Workflow、Worker、Activity 的职责要分清

图 2:编排与副作用边界示意图。外部系统各自提交,Temporal 不能替它们执行数据库回滚。
Workflow 定义的是一段可恢复的业务编排:当前申请等待哪些事实、截止时间在哪里、什么条件允许安排 ERP 创建、ERP 确认后才安排权益。它的代码看起来像普通异步 Python,但不能把任意环境读写直接塞进去。若每次重放时随机数、系统时钟或外部查询得到不同结果,重放过程就可能与已记录历史不匹配。Temporal 官方关于事件历史和重放的说明是理解这个约束的起点。Temporal Workflow Execution 文档 本例让 Workflow 在 wait_condition 上等待,并通过 execute_activity 明确提出外部动作。
Worker 是加载 Workflow 与 Activity 实现、轮询任务队列的进程。第一个 Worker 停止时,申请并不变成失败。只要 Temporal 服务端仍保留执行历史,另一个 Worker 继续消费同一任务队列,就能处理后续 Signal 与计时事件。我们实验使用 SDK 启动的本地开发服务,其输出明确是 Temporal Persistence: in-memory。因此本篇验证的是 Worker 停止与更换,没有验证 Temporal 服务端进程本身重启后的持久性。生产环境需要部署有持久后端、备份、监控和容量规划的服务端,不能把这次本地实验外推为完整灾备证明。
Activity 才负责与 ERP、权益等外部系统交互。演示里每个 Activity 都写一个独立 SQLite 表,以便看清"远端已提交"与"Worker 拿到结果"不是同一事务。create_erp 第一次写入 erp:{application_id} 后故意抛异常,模拟请求执行成功但响应丢失;Temporal 随后重试该 Activity。由于外部效果表以稳定业务键作为主键,第二次尝试无法新增第二行,Activity 才能返回确认结果。重试是引擎行为,去重是外部接口或适配层的责任。真实 ERP 如果没有查询能力和幂等业务键,应该进入结果未知的对账流程,不应简单地把"超时"判为"失败,可以重建"。
下面是本篇代码中最关键的流程骨架;完整可运行文件见文末。为了避免把大量实现直接堆进博客,读者可以先看顺序,再运行源码检验所有断言。
python
@workflow.run
async def run(self, application: Application) -> str:
await workflow.wait_condition(
lambda: self.approved_version == application.material_version
and self.signed_version == application.material_version
and self.paid,
timeout=timedelta(seconds=application.deadline_seconds),
)
self.phase = "READY"
self.phase = "PROVISIONING"
await workflow.execute_activity("create_erp", application.application_id, ...)
await workflow.execute_activity("grant_rights", application.application_id, ...)
self.phase = "ACTIVE"
return self.phase
这段代码的 READY 是"前置条件满足",紧接着变为 PROVISIONING,最后才是 ACTIVE。代码里的省略号是文章节选,不是可直接执行的完整函数;运行请使用附件 code/demo.py。若 ERP Activity 一直重试失败,Workflow 会停留在安排外部动作的阶段,不能因为已经满足审批、签署和到账,就提前向客户展示"已开通"。若权益结果未知,必须按第 10 篇的对账原则先查远端事实,不可通过本地状态更新掩盖未知。
三、真实实验:停止 Worker 后继续同一个申请

图 3:实验步骤示意图。图中的结论来自本地真实 Temporal 开发服务测试;该图片不是服务端控制台截图。
实验为新申请生成 UUID,Workflow ID 取 acmeflow/{tenant_id}/{application_id}。这个固定映射的意义是:同一业务申请重试启动时,入口可以定位同一个引擎身份,而不是制造许多语义相同的运行实例。真实生产还需要明确 Workflow ID 重用策略、业务键唯一约束以及入口重试语义。另一个容易混淆的点是 Workflow ID 与 Run ID:同一 Workflow ID 在特定机制下可能关联执行链的不同 Run,业务查询不要随手把某一次 Run ID 当作永久业务主键。Temporal Workflow Execution 文档
Worker A 启动并接收 APPROVED(v1) 与 APPROVED(v2)。申请当前为 v2,所以前者不会满足条件,后者才算当前有效审批。此时仍缺签署和到账,Workflow 等待;程序退出 Worker A 的上下文,刻意留下一个没有 Worker 的短窗口。接着 Worker B 用相同任务队列和代码启动,接收 v1、v2 签署及一条重复到账事件。v1 签署不生效;v2 签署和到账满足条件,流程执行 ERP 与权益 Activity,最终返回 ACTIVE。外部效果表只有 erp:UUID 和 rights:UUID 两条记录。
这项实验常被简化描述成"Temporal 帮我们记住执行到哪一行"。更准确的说法是,服务端保存事件,新的 Worker 按确定性 Workflow 代码和事件历史恢复到可继续推进的状态。这个区别会影响发布设计:运行多天的 Workflow 可能在新代码上线后由不同版本的 Worker 重放。若直接删除或改变旧历史中的分支含义,恢复可能失败。Temporal 提供 Worker Versioning 等部署机制,官方文档说明如何控制旧执行与新 Worker 代码的兼容关系;本篇未实测生产 Worker Versioning,只把它列为迁移验收项。Temporal Worker Versioning 文档
四、事实输入不是字符串通知,而是可核对的契约

图 4:Signal 输入与版本校验示意图。可信来源校验必须在引擎入口之外补齐。
很多流程示例把 Signal 写成 approved(),这个名字看起来方便,却缺了生产所需的上下文。谁批准的?批准的是哪版资料?对应哪个客户、哪个合同?事件重放时如何识别重复?本例把事实封装为 kind、material_version、event_id 三个字段,实际投递时还应从路由和已认证上下文固定 tenant_id 与 application_id。这比单个布尔值多写了几行,但换来了审计与版本辨识的空间。
审批事件只更新当前版本的审批标记;签署事件只更新当前版本的签署标记;到账独立标记。这样一来,客户在 v1 签完合同后修改套餐并形成 v2 资料,旧签署不会被误用于新的价格或服务范围。到账是否需要重新匹配金额,是另一条业务规则;本例假设固定金额到账仍然有效。若套餐变更影响金额,不能机械地保留 paid=True,必须在支付适配层保存金额、币种、订单版本与对账结果,并由业务决定何时失效。引擎能忠实执行规则,却不会自行判断这种商业含义。
event_id 去重也是有限度的。演示把 ID 留在 Workflow 的集合里,足以展示相同 Signal 不重复改变内部事实,但长期流程若收到海量事件,集合与事件历史会增长;此时可能需要外层持久消息收件箱、窗口化去重、Continue-As-New 等策略。更重要的是,即使内部事实去重,外部 Activity 的重试仍需自己的业务键。一个防重复机制不能替所有边界承担责任。Temporal 的 Python 消息传递文档列出了 Signal 与其他交互方式;选择哪一种要看发送方是否需要等待确认或结果,而不是把所有消息都变成无约束通知。Temporal Python 消息传递文档
五、持久计时验证:Worker 离线时期限继续流逝

图 5:持久计时实验示意图。0.4 秒是为了快速复现,不代表企业审批的合理时限。
第 08 篇已经讨论过绝对截止时间、时区和竞态。在 Temporal 里,等待条件可以附带超时;引擎把计时作为执行事件的一部分,而不是靠某个 Worker 里的 sleep 线程一直活着。Temporal Workflow Execution 文档 这次实验创建另一条申请,只发当前版审批,暂不发签署和到账。Workflow 开始等待后停止 Worker,等待时间跨过预设的 0.4 秒,再启动新的 Worker。恢复后,程序得到 EXPIRED,外部 ERP 与权益效果表仍为零。这证明在本地开发服务仍运行的前提下,Worker 停止不会把已经设定的期限悄悄延长。
为什么本章没有把"过期"直接写成"客户取消"?因为系统计时和业务撤回是两种来源不同的命令。超时表示约定窗口已经关闭,撤回表示有授权主体主动表达不再继续。两者在审计里应有不同的命令类型、发生时间和责任归属。如果计时器与签署回执恰好同时到达,业务还需要规定哪一个时间点具有裁决效力;第 08 篇用可信服务端时间和状态迁移事务说明了这个问题。Temporal 可以给出事件顺序,却不会代替业务定义"截止前收到"和"截止前发生"哪个算数。生产接入应明确接收时间、远端签署时间、时钟误差和补录政策。
同样,EXPIRED 不意味着迟到的真实到账可以丢弃。流程实例可能已经结束,财务对账义务仍在。来自支付系统的消息应先进入可审计收件箱,之后关联原申请,决定退款、转新申请或人工处置。不能因为 Workflow 已经关闭,就让接入层对迟到回执返回"成功处理"却不留记录。本章代码只演示到期后不发生开通动作,没有实现这一整套迟到消息处理;部署时应把它列入单独验收。
六、ERP 已成功、响应丢失:为什么重试仍需要业务键
假设 ERP 创建服务收到请求后成功提交,网络在响应返回前断开。Worker 只能知道这次 Activity 抛出异常,无法从异常推出"ERP 没有创建"。如果简单重试一个无幂等约束的 create(),远端可能生成第二张客户账户。我们的本地模拟按 erp:{application_id} 插入,第一次提交后故意抛错;第二次相同业务键的插入被唯一约束阻止,随后返回 ERP_CREATED。这不是 Temporal 自动替 ERP 去重,也不是把两个数据库纳入同一事务,而是教学用的幂等适配器展示了正确责任位置。
现实系统通常还需要"查单"而不是只靠再次插入。若 ERP 返回结果未知,适配层应按稳定业务键查询;查到已创建,就把事实记录为 CREATED;查到明确不存在且接口契约允许重试,才重新发创建请求;查询本身不可用,则暂缓并进入对账。第 10 篇 Saga 文章已经把这些分支展开。Temporal 的 Activity 重试可以帮我们再次调度外部调用,但不会替我们区分远端明确失败、远端成功回包丢失、远端处理仍在进行三种情况。把"最多三次重试"写成"至多创建一次"是一个危险的推断。
我们还要防止另一个方向的误判:ERP 已创建,不等于权益已经生效。实验把 grant_rights 放在 ERP Activity 确认之后,并在最终返回前检查两项效果均存在。生产状态若需要展示更细,可区分 READY、PROVISIONING、ACTIVE、MANUAL_REVIEW。某个 Activity 最终失败时,流程并不自动回到 READY 然后什么都没发生,因为 ERP 可能已经提交。需要依据对账结果选择重试、补偿或人工处理;不可逆的外部动作不能被称为本地数据库回滚。
七、把旧系统切走的实际顺序
第一步先盘点现存实例,而不是先关旧 Worker。按旧流程状态查询每条未结束申请,确认它们的资料版本、未完成任务、已收到事实、待处理消息和 ERP 结果。无法归属的历史记录先进入对账队列。把所有旧实例强行导入新引擎会要求重建精确历史和副作用状态,很容易遗漏一个"远端已经做了、旧系统还没记上"的窗口。对于一个规模适中的迁移,允许旧实例在原系统收尾往往更简单,也更容易解释事故责任。
第二步在唯一入口写入 owner,并保证旧调度器只读取 legacy 行。切流开关在申请创建处决定新实例的 owner;不能让前端向旧接口创建申请,后台却把同一事件送入 Temporal。对于第 03 篇基座,可以在 applications 旁新增执行路由表,外键指向同一个 application_id,把创建申请与登记路由放在一致事务中。业务唯一键仍是 (tenant_id, business_key),Workflow ID 是由这个已确定的业务身份派生的引擎定位符。本文 SQLite 小表只是这套方案的局部机制验证,不是已接入主基座的迁移代码。
第三步分阶段放量。先让测试租户或少量新申请进入 Temporal,监控新旧路径的启动量、完成量、等待时间、未处理事件和外部副作用次数。灰度停止新流量时,不应对已经进入 Temporal 的实例直接改回 legacy;它们仍归 Temporal 收尾。要回退的是后续新申请的路由决定,而不是把执行到半途的申请从一种历史语义生硬搬到另一种历史语义。这样才能避免一个路径认为任务还没做,另一个路径却已经做完。
第四步完成对账。拿业务数据库里的申请列表,与旧系统和 Temporal 各自的未结束实例对比:每条申请恰好由一个 owner 管理吗?是否出现有路由却没有引擎实例、有引擎实例却没有业务申请、两个引擎都持有同一申请的情况?如果发现双重持有,应立即停止该申请的外部自动动作,先核对 ERP 和权益事实,再由授权人员决定保留哪条执行路径。不要靠简单删除一个实例掩盖已经发生的远端副作用。迁移验收的核心不是"新引擎能跑",而是"迁移窗口没有丢申请、没有双开通、没有失去可追责记录"。
八、代码与真实运行结果怎么复现
本篇完整代码在 code/demo.py,运行命令和环境限制见 code/README.md。代码只依赖 Python SDK 及其依赖,由 WorkflowEnvironment.start_local 启动一个本地 Temporal 开发服务。首次运行可能下载开发服务二进制,需要网络;已经安装的环境可复用缓存。本次实测使用 Python 3.12、temporalio==1.33.0、Temporal CLI 1.9.1、Server 1.32.0。输出原文存为 code/actual-output.txt,其中本地端口、Workflow ID 和 Run ID 每次会变,不应把这些随机值当成断言。
text
[迁移路由] old=legacy, new=temporal; 交叉派发均被拒绝
[Worker A] 已受理审批,仍等待当前版签署与到账
[Worker B] 恢复执行=ACTIVE;ERP/权益各一条;ERP 丢回复后重试未重复创建
[持久计时] Worker 停止期间越过截止;恢复后 EXPIRED;外部副作用=0
控制台还会有一次 create_erp Activity 失败的堆栈,这是故障注入的预期证据,随后引擎才重试成功。删掉堆栈并写成"全程没有失败"会损失实验价值。复现时还可以故意把 effects 里的唯一业务键拿掉,观察为什么一次网络不确定性会变成重复创建风险;但请只在本地教学副本操作。业务系统真正的幂等语义还应考虑取消后重建、产品版本变更、人工补偿与重复请求,这个演示表不覆盖这些生命周期。

图 6:实验验收矩阵,逐项对应代码断言与本地运行输出;图片是整理后的示意,不是运行界面截图。
九、迁移到生产前还缺哪些东西
本篇最明确的未验证项,是服务端的持久存储和高可用。我们启动的是内存型开发服务,只要它自己退出,历史能否恢复就不在本次实验范围内。生产的 Temporal 集群、数据库、备份、网络策略、认证、命名空间隔离和运行监控,都要单独部署与演练。即使引擎部署好了,也要测长时间无 Worker、Worker 崩溃于 Activity 提交前后、网络分区、Signal 重复或乱序、Workflow 代码升级等场景。把每种故障的业务期望写成验收项,比只看"成功例子跑通"更有用。
代码版本尤其容易被忽视。新部署的 Worker 可能接手旧事件历史,Workflow 的分支结构不能随意变化。若升级改变了某个决定条件,必须设计兼容策略或使用引擎提供的版本化能力,并用真实旧历史回放验证。另一方面,业务规则版本与 Worker 代码版本不是一回事:审批规则 v2 可能由旧 Worker 代码执行,或者旧审批规则仍由新 Worker 执行。申请上需要明确 rule_version,使审计人员能解释当时的决定依据。仅用镜像 tag 猜测规则版本是不够的。
跨系统安全也尚未完成。我们的 Signal 没有认证,SQLite 效果表不是 ERP,routes 没有与 FastAPI/PostgreSQL 共事务。真实接入应有身份、租户、回执签名、请求限流、幂等键和审计记录。运行界面也应区分"引擎流程在运行""业务等待人处理""ERP 结果未知""权益已生效",否则运营人员看到一个绿色引擎状态,却误以为客户已经可用。Temporal 的状态只是执行层事实,业务系统仍要维护可理解的客户状态与处置指引。
十、迁移后的观察指标与值班处置
完成切流后,值班人员首先需要一张按 owner 分组的申请清单,而不是只看 Temporal 的总任务数。清单应能按业务申请 ID 查到路由 owner、资料版本、流程阶段、最后一条可信事实、下次期限、正在等待的外部动作、ERP/权益查询结果。若某条申请在业务库标为 temporal,但引擎查不到 Workflow,可能是创建命令未发出、命令超时后结果未知、或者路由写入与启动之间失去协调。此时不能立刻给它再生成一个新的 UUID;应先按固定 Workflow ID 查询,核对启动请求日志,再决定重试同一身份还是人工修复。
第二类指标是"多长时间没有进展",而不仅是"有多少实例未结束"。某些申请等待客户签合同十天是正常的,某些申请卡在 ERP Activity 十分钟就需要干预。应按阶段设置不同的观察阈值:待人审批看任务领取和期限;待签署看合同服务回执延迟;待到账看支付对账;PROVISIONING 看 Activity 重试、查询与人工对账队列。指标要携带 tenant_id 和匿名化的业务标识,避免在监控标签中暴露合同原文或个人信息。报警接收人也应与业务处置权限匹配,技术值班看到 ERP 结果未知时可以启动查询,但不能擅自批准申请。
第三类指标是双路径一致性。迁移期间旧调度器和 Temporal 都在运行,最有价值的告警不是"某个 Worker 重启",而是"同一 application_id 在两个路径都有可执行任务""owner 与实际引擎不一致""外部 ERP 按同一业务键出现多条记录"。每天抽样比对路由、业务表、引擎实例与远端效果,能及早发现切流入口的漏网之鱼。若出现双路径,应冻结该申请自动副作用并保留两侧历史,再由授权人员确定事实;简单关掉一个 Worker 可能掩盖已经发出的外部请求。
最后要设计一次可操作的事故演练:先让 Worker 停止,再观察审批和签署事件能否保留;恢复后核对状态;再注入 ERP 回包丢失,确认远端业务键没有重复;然后尝试在截止后发送迟到回执,检查业务收件箱和人工对账。演练报告要记录具体申请 ID、引擎 Run ID、事件时间、外部查询结果和最终处置人。只有在团队能解释每一条路径的后果时,"持久执行"才从框架宣传语变成可维护的服务能力。
FDE Thinking:什么时候不该急着迁移旧实例
若旧系统的实例只剩几天就能全部结束,而新引擎的主要收益针对今后新建申请,强行搬迁旧实例往往增加风险却没有足够收益。FDE 应先问客户:旧实例有多少、最长还要等多久、有没有已经发生但未记录的 ERP 副作用、谁对人工对账负责?如果这些答案不清楚,"全面迁移"只是一个漂亮但不可验收的词。让旧实例自然收尾,同时验证新实例的单 owner 路由和故障恢复,可能更符合客户的业务节奏。
相反,如果旧系统里有大量需要等待数月的实例,并且旧运行环境即将下线,就需要设计明确的逐条迁移协议。迁移时不能只拷贝一个 state 字段:还要转移当前资料版本、已完成的审批与签署事实、期限、去重窗口、人工任务责任人、ERP/权益的真实结果、待处理消息和审计证据。对每条迁移实例留有"迁移前快照、迁移命令、迁移后实例 ID、验证结果、回退处置"记录。此时可以考虑专门的迁移工具,但它必须经过双系统对账和演练,不应藏在一个一次性数据库脚本里。
这也是 FDE 与只会写一个 Workflow 示例的区别:你要让客户知道哪些不变量由引擎保证,哪些由业务入口保证,哪些必须靠外部系统和人工对账。只有边界被写清楚,持久执行才真正改善交付,而不是把旧的不确定性换了一个名字。
交付时还应给运维团队一份"停止新流量"和"继续旧实例"的具体操作手册。前者改变创建入口的路由开关,后者要求旧调度器、旧任务队列和旧版规则仍可用;两者不能混为一条"关旧系统"命令。若旧系统必须退役,先导出所有未完成申请、待处理回执和外部查询结果,逐条确认 owner 已被安全转移或业务终结,再撤销旧系统的外部调用权限。删除旧表或停掉容器并不是迁移完成的证据。只有业务团队能按申请 ID 解释客户当前所处阶段、下一步由谁负责、异常时怎样处置,迁移才真正结束。