设计一个订单履约编排引擎:每天 100 万笔订单,每笔要走「扣款 → 锁库存 → 生成运单 → 等物流揽收(可能几小时到几天)→ 发通知」。这是一条长时间运行、跨多个服务、必须全成或全补偿的流程。难点不在单步,而在中间任何一步崩溃后如何精确接着跑:worker 进程被 OOM kill、支付回调延迟 2 天、库存服务超时——你既不能重复扣款,也不能把订单卡死。
裸写这套逻辑的下场:用 DB 状态字段 + cron 轮询驱动状态机,很快演变成几百个 if state==... 分支、重试和补偿代码散落各处,没人敢改。工作流引擎把「状态持久化、重试、超时、补偿」下沉成基础设施。
graph TD
C["客户端 / API
StartWorkflow(order)"]
FE["Frontend 服务
gRPC 接入 · 鉴权"]
HIST[("History 存储
Event Sourcing
Cassandra / MySQL")]
MQ["Task Queue
Matching 服务"]
W["Worker 集群
你的 Workflow + Activity 代码"]
EXT["外部系统
支付 / 库存 / 物流"]
C --> FE
FE -->|① append 事件| HIST
FE -->|② 派发 task| MQ
MQ -->|③ long-poll 拉取| W
W -->|④ replay 历史恢复状态| HIST
W -->|⑤ 调用副作用| EXT
W -->|⑥ 结果 → 新事件| FE
classDef client fill:#1a2530,stroke:#64c8ff,color:#e8eef5
classDef core fill:#1a1a30,stroke:#ffb450,color:#e8eef5
classDef store fill:#2a1530,stroke:#ff7ab6,color:#e8eef5
class C,W client
class FE,MQ core
class HIST,EXT store
引擎(Frontend + History + Matching)只管持久化与调度,不跑业务代码;业务跑在你自己的 Worker 里,崩了就重放 History 恢复
原理:核心思想是 event sourcing + 确定性重放。workflow 的每一次进展(Activity 被调度、完成、Timer 触发、收到信号)都作为事件顺序追加到 History。当 worker 崩溃重启,引擎把这段 History 从头重放给你的 workflow 代码:走到已经记录过结果的那一步,直接返回缓存值(不重新执行副作用);只有 History 里还没有的那一步才真正落地。于是「运行数天、跨崩溃」的流程,在代码里看起来就是一段平铺直叙的同步函数。
now()/rand()/读全局),有学习曲线和引擎运维成本。# 订单履约 workflow —— 看着同步,实际可跑数天、跨进程崩溃
@workflow
def fulfill_order(order):
pay = call(charge_payment, order) # ① Activity = 真正的副作用
if not pay.ok: return "payment_failed"
call(reserve_inventory, order) # ② 每步结果追加进 History
ship = call(create_shipment, order)
workflow.sleep(days=2) # ③ 持久定时器,不占线程
call(send_notification, order, ship)
return "done"
# 崩溃后 worker 重放 History:已完成的 Activity 直接返回缓存结果,
# 不重复扣款;只有尚未记录的那一步才真正执行。
原理:跨服务的多步事务有两种协调风格。编排(Orchestration):一个中央协调器(workflow)主动依次调用各参与者——「你扣款、你锁库存、你发货」,逻辑集中、可视、易补偿。编舞(Choreography):没有中央大脑,每个服务处理完发一个事件,下一个服务订阅事件自行接力(订单已支付 → 库存服务监听 → 发库存已锁事件 → 物流监听)。
经验法则:步骤 ≤ 3、语义松耦合走编舞;步骤多、要补偿要可视化,走编排。工作流引擎本质是把编排做成基础设施。
原理:分布式下没有跨服务 ACID 回滚。Saga 把长事务拆成一串本地事务 T1…Tn,每个 Ti 配一个补偿事务 Ci:一旦某步失败,就逆序执行已完成步骤的补偿(Cn-1…C1)。补偿不是回到原样的物理 rollback,而是业务语义上的抵消——扣了款就退款、锁了库存就释放、发了邮件就再发一封更正。这正是 1987 年 Garcia-Molina & Salem 的 Sagas 论文奠定的模型。
def saga(order):
done = [] # 已成功步骤的补偿栈
try:
charge_payment(order); done.append(refund_payment)
reserve_inventory(order);done.append(release_inventory)
create_shipment(order); done.append(cancel_shipment)
except StepFailed:
for compensate in reversed(done): # 逆序补偿(LIFO)
compensate(order) # ⚠️ 必须幂等 + 可无限重试
raise
原理:workflow 本身靠重放做到「逻辑上 exactly-once」,但真正碰外部系统的 Activity 只能保证 at-least-once:引擎派发任务给 worker,若 worker 执行完但上报结果前崩了,引擎没收到 ACK,超时后会重派——于是同一次扣款可能被执行两次。唯一正解是让 Activity 幂等:用业务唯一键(订单号)做幂等键,下游据此去重。长任务还要定期 heartbeat,让引擎区分「慢」和「死」,避免误判超时。
// Activity 至少执行一次 → 业务侧必须幂等
func ChargePayment(ctx, order) error {
key := "charge:" + order.ID // 幂等键 = 业务唯一性
if seen(key) { return nil } // 重试到达 → 短路返回
heartbeat(ctx) // 长任务定期心跳,防误判超时
charge(order.Amount, idemKey=key) // 把幂等键透传给下游支付网关
return nil
}
Idempotency-Key 头,客户端重试同一 key 只扣一次——正是给上游 workflow 重试兜底的经典实现。getVersion() / patch 分支兼容老历史——这是长流程引擎最难的运维点。time.now() / rand() / 调 HTTP。 破坏确定性重放:重放时值变了,历史对不上直接报错。所有不确定操作都必须放进 Activity,或用引擎提供的确定性 API。凭 event sourcing + 重放:workflow 的推进过程被追加成不可变事件流持久化在 History。worker 崩溃后换台机器把 History 从头重放,走到已有结果的步骤直接返回缓存值,只有历史里没有的步骤才真正执行——所以 workflow 逻辑「exactly-once」。
但那个尴尬窗口是真实存在的:Activity 已经把款扣了,结果还没写进 History,worker 就崩了。引擎收不到完成事件,超时后重派这个 Activity → 第二次扣款。引擎层面无法消除,只能靠 Activity 幂等:用订单号做幂等键,下游支付网关认这个 key,第二次直接返回首次结果。这就是为什么「引擎给你 durable,但幂等得你自己保证」。
if random() > 0.5 会引发什么具体故障?恢复靠重放:把同一段 History 喂给 workflow 代码,必须每次都生成完全相同的命令序列(调度哪个 Activity、设哪个 Timer),才能和历史逐条对齐。
if random()>0.5 第一次跑走了 A 分支(调度 Activity A,写进历史);崩溃重放时 random() 又摇了一次,这次走 B 分支要调度 Activity B。引擎发现「代码想调度 B,但历史第 N 条是 A」——命令与历史不匹配,直接抛非确定性错误,workflow 卡死。所以随机、当前时间、读外部状态、并发 map 遍历顺序……全是雷区,必须挪进 Activity(其结果被持久化,重放时不再重算)。
互补,不是替代。 引擎解决的是「流程状态怎么可靠地持久化和恢复」(durable execution);Saga 解决的是「多步业务失败了语义上怎么回滚」(补偿)。引擎给了你一个可靠的地方去编排 Saga——在 workflow 里顺序调用各步,用 try/except 捕获失败后逆序调用补偿 Activity。
没有引擎你也能写 Saga(编舞式,靠事件),但补偿的状态、重试、超时又得自己持久化,绕回手写状态机的泥潭。所以「Temporal 里跑一个 Saga workflow」是当下最常见的组合:引擎负责 durable + retry,Saga 负责 business rollback 语义。
灾难场景:老 workflow 是按旧代码路径写入 History 的;新代码部署后,这些在途 workflow 被重放时走的是新路径,生成的命令和旧历史对不上 → 大面积非确定性报错、集体卡死。这是长流程引擎最凶的运维坑(流程活几天,代码却在天天发)。
安全做法:不能直接改分支,要版本化兼容。用引擎的 getVersion()/patch API:v=getVersion("addStep", 1, 2); if v==1: 老逻辑 else: 新逻辑。老 workflow 重放时拿到记录过的旧版本号走老路径,新启动的走新路径,两者在同一份代码里共存。等所有老 workflow 跑完,再摘掉旧分支。或者干脆另起新 task queue / 新 workflow 类型,让老流程在旧 worker 上自然消化完。
补偿不是一次性 best-effort,它本身也是可靠的 Activity:配置无限重试 + 指数退避,因为退款接口大概率只是暂时不可用。引擎会持久地把这个补偿 Activity 挂在那儿一直重试,进程崩了也不丢——这正是用引擎跑 Saga 相比裸写的最大好处。
但要防两件事:① 补偿必须幂等(退款接口带幂等键),否则重试可能退好几次;② 设重试上限 / deadline,超过后不再自动重试,而是把这单打进「人工介入」队列并告警,转人工对账退款。绝不能让一条补偿无限重试悄悄耗资源、也不能假装成功放过去——宁可卡住等人,不可静默丢钱。生产上还会配一条独立对账 job 兜底:每天扫「已扣款但最终未履约且未退款」的订单强制补退。