Day 39 Hard Workflow Engine Durable Execution Saga 补偿事务

工作流引擎 — 让崩溃后还能接着跑的长流程Workflow Engine: Durable Execution, Saga Orchestration, Compensation

问题场景 + 需求约束

设计一个订单履约编排引擎:每天 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 恢复

关键技术点

1. 持久化执行(Durable Execution)—— 把「状态机」藏进普通代码

原理:核心思想是 event sourcing + 确定性重放。workflow 的每一次进展(Activity 被调度、完成、Timer 触发、收到信号)都作为事件顺序追加到 History。当 worker 崩溃重启,引擎把这段 History 从头重放给你的 workflow 代码:走到已经记录过结果的那一步,直接返回缓存值(不重新执行副作用);只有 History 里还没有的那一步才真正落地。于是「运行数天、跨崩溃」的流程,在代码里看起来就是一段平铺直叙的同步函数。

Trade-off:
# 订单履约 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 直接返回缓存结果,
# 不重复扣款;只有尚未记录的那一步才真正执行。
现实案例:

2. 编排 vs 编舞(Orchestration vs Choreography)

原理:跨服务的多步事务有两种协调风格。编排(Orchestration):一个中央协调器(workflow)主动依次调用各参与者——「你扣款、你锁库存、你发货」,逻辑集中、可视、易补偿。编舞(Choreography):没有中央大脑,每个服务处理完发一个事件,下一个服务订阅事件自行接力(订单已支付 → 库存服务监听 → 发库存已锁事件 → 物流监听)。

Trade-off:

经验法则:步骤 ≤ 3、语义松耦合走编舞;步骤多、要补偿要可视化,走编排。工作流引擎本质是把编排做成基础设施。

现实案例:

3. 补偿事务(Compensation)—— 没有回滚,只有「语义反做」

原理:分布式下没有跨服务 ACID 回滚。Saga 把长事务拆成一串本地事务 T1…Tn,每个 Ti 配一个补偿事务 Ci:一旦某步失败,就逆序执行已完成步骤的补偿(Cn-1…C1)。补偿不是回到原样的物理 rollback,而是业务语义上的抵消——扣了款就退款、锁了库存就释放、发了邮件就再发一封更正。这正是 1987 年 Garcia-Molina & Salem 的 Sagas 论文奠定的模型。

Trade-off(前向 vs 后向恢复):
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
陷阱:补偿本身也会失败。补偿必须幂等且能无限重试,且不能再触发需要补偿的新副作用,否则补偿链会无限发散。真正做不了的补偿要落「人工介入」队列。
现实案例:

4. Activity 执行保证 —— at-least-once + 幂等 + 超时/心跳

原理:workflow 本身靠重放做到「逻辑上 exactly-once」,但真正碰外部系统的 Activity 只能保证 at-least-once:引擎派发任务给 worker,若 worker 执行完但上报结果前崩了,引擎没收到 ACK,超时后会重派——于是同一次扣款可能被执行两次。唯一正解是让 Activity 幂等:用业务唯一键(订单号)做幂等键,下游据此去重。长任务还要定期 heartbeat,让引擎区分「慢」和「死」,避免误判超时。

Trade-off:投递语义与限流—— at-most-once(不重试,可能丢,不可接受)vs at-least-once(会重试,配幂等,业界默认)。所谓 exactly-once 不是不重复执行,而是重复执行但只生效一次(幂等键去重)。
// Activity 至少执行一次 → 业务侧必须幂等
func ChargePayment(ctx, order) error {
    key := "charge:" + order.ID          // 幂等键 = 业务唯一性
    if seen(key) { return nil }          // 重试到达 → 短路返回
    heartbeat(ctx)                       // 长任务定期心跳,防误判超时
    charge(order.Amount, idemKey=key)    // 把幂等键透传给下游支付网关
    return nil
}
现实案例:

扩展与优化

常见陷阱 + 面试问题

1. 在 workflow 里直接 time.now() / rand() / 调 HTTP。 破坏确定性重放:重放时值变了,历史对不上直接报错。所有不确定操作都必须放进 Activity,或用引擎提供的确定性 API。
2. 以为引擎给 exactly-once 就不用管幂等。 Activity 是 at-least-once,副作用必须自己幂等去重,否则重试就是重复扣款。
3. 把不可补偿的步骤放太靠前。 一旦执行了不可撤销的操作(钱打给第三方),后面失败就无法整单回滚,只能前向重试——排序上尽量把它放到最后。
4. 面试常问:为什么微服务要避免分布式 2PC?(阻塞、协调者单点、锁资源久、参与者故障放大)Saga 用什么换什么?(放弃隔离性 I,换可用性——中间态对外可见,需业务容忍)。
5. 面试常问:workflow 引擎怎么保证进程崩溃后不丢状态、不重复副作用?(答:event sourcing 持久化 + 确定性重放 + Activity 幂等键)。

深入资源

深入思考(点击展开答案)

1. workflow 引擎号称崩溃后精确恢复,凭什么?如果 Activity「执行完了但上报结果前 worker 崩了」,会怎样?

凭 event sourcing + 重放:workflow 的推进过程被追加成不可变事件流持久化在 History。worker 崩溃后换台机器把 History 从头重放,走到已有结果的步骤直接返回缓存值,只有历史里没有的步骤才真正执行——所以 workflow 逻辑「exactly-once」。

但那个尴尬窗口是真实存在的:Activity 已经把款扣了,结果还没写进 History,worker 就崩了。引擎收不到完成事件,超时后重派这个 Activity → 第二次扣款。引擎层面无法消除,只能靠 Activity 幂等:用订单号做幂等键,下游支付网关认这个 key,第二次直接返回首次结果。这就是为什么「引擎给你 durable,但幂等得你自己保证」。

2. 为什么 workflow 代码必须「确定性」?一个 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(其结果被持久化,重放时不再重算)。

3. 都用工作流引擎了,为什么还需要 Saga / 补偿?两者是替代还是互补?

互补,不是替代。 引擎解决的是「流程状态怎么可靠地持久化和恢复」(durable execution);Saga 解决的是「多步业务失败了语义上怎么回滚」(补偿)。引擎给了你一个可靠的地方去编排 Saga——在 workflow 里顺序调用各步,用 try/except 捕获失败后逆序调用补偿 Activity。

没有引擎你也能写 Saga(编舞式,靠事件),但补偿的状态、重试、超时又得自己持久化,绕回手写状态机的泥潭。所以「Temporal 里跑一个 Saga workflow」是当下最常见的组合:引擎负责 durable + retry,Saga 负责 business rollback 语义。

4. 在途 workflow 有 500 万个还没结束,此时你要改 workflow 代码逻辑。会出什么事?怎么安全发布?

灾难场景:老 workflow 是按旧代码路径写入 History 的;新代码部署后,这些在途 workflow 被重放时走的是路径,生成的命令和旧历史对不上 → 大面积非确定性报错、集体卡死。这是长流程引擎最凶的运维坑(流程活几天,代码却在天天发)。

安全做法:不能直接改分支,要版本化兼容。用引擎的 getVersion()/patch API:v=getVersion("addStep", 1, 2); if v==1: 老逻辑 else: 新逻辑。老 workflow 重放时拿到记录过的旧版本号走老路径,新启动的走新路径,两者在同一份代码里共存。等所有老 workflow 跑完,再摘掉旧分支。或者干脆另起新 task queue / 新 workflow 类型,让老流程在旧 worker 上自然消化完。

5. 补偿事务失败了怎么办?「扣款成功、库存锁定、发货失败、要退款但退款接口也挂了」——设计这条兜底路径。

补偿不是一次性 best-effort,它本身也是可靠的 Activity:配置无限重试 + 指数退避,因为退款接口大概率只是暂时不可用。引擎会持久地把这个补偿 Activity 挂在那儿一直重试,进程崩了也不丢——这正是用引擎跑 Saga 相比裸写的最大好处。

但要防两件事:① 补偿必须幂等(退款接口带幂等键),否则重试可能退好几次;② 设重试上限 / deadline,超过后不再自动重试,而是把这单打进「人工介入」队列并告警,转人工对账退款。绝不能让一条补偿无限重试悄悄耗资源、也不能假装成功放过去——宁可卡住等人,不可静默丢钱。生产上还会配一条独立对账 job 兜底:每天扫「已扣款但最终未履约且未退款」的订单强制补退。