第 13 / 23 篇

第 12 章:取消、停止等待和业务补偿分别处理

用户点击取消,通常希望系统不要再产生后续影响。但请求可能已经在网络中,工具也可能完成了提交。执行引擎必须把用户愿望、当前可阻止的动作和已经成立的事实分别保存,才能诚实地解释取消结果。

12.1 取消请求是一项新事实

示例保存 cancel_requested,而不是收到取消就删除工作流。等待审批或就绪状态可以直接进入 cancelled,因为它们尚未由 worker 开始调用。已经处于 running 的任务则保留执行事实,记录取消意愿,并在调用之前检查是否还能停止。

取消也需要租户与主体约束。另一个租户不应只凭猜到工作流编号就终止任务。本书在受信任的调用上下文内检查租户,没有把这个检查替代为完整生产认证;实际 API 必须先建立可信身份。

删除工作流尤其危险。它会同时删除或断开操作身份、计划指纹和核查依据,使后来到达的工具回执难以归属。即使界面不再显示该任务,后台也应按保留策略保存恢复仍然需要的事实。

12.2 调用之前尽力停止

worker 领取任务后,执行前再检查取消和审批,可以阻止一类清楚的竞争窗口。例如取消在领取之后、HTTP 发送之前到达,工具效果仍可保持为零,任务结束为 cancelled。

但调用前检查和远程受理之间仍然有时间。取消可能恰好落在这段间隔里。它不意味着系统已经违反了一个不存在的原子合同,而是要求产品明确取消的语义:停止尚未开始的工作、尝试中断在途工作,还是必须由工具确认撤销。

Go context 可以传播停止等待和协作式终止,但它本身不是业务撤销协议。关闭请求连接以后,工具可能已经提交,也可能忽略了客户端断开。需要通过工具回执或权威查询确认,而不是只看本地 context 已经取消。

12.3 已有效果需要如实保存

随书实验在工具生效后请求取消,再让 worker 保存结果。最终可以同时存在 succeeded 与 cancel_requested=true,工具效果仍是一。这不是让两个状态互相打架,而是分别表达业务已经完成和取消请求到达过。

界面可以解释为“操作已完成,取消未能阻止这次效果”,并提供后续处理入口。直接显示“取消成功”会误导用户,以为账户没有变化;直接清除取消标记,又会让操作者看不到这次有意义的请求。

核查过程中也可能发生取消。若查询找到了原效果,仍应记录匹配结果;若查询为空且取消已成立,就不能把任务重排为 ready。随书测试专门制造查询期间取消的竞争,防止旧核查结果重新放行已经被停止的执行机会。

12.4 补偿是一项新的业务操作

发放积分后再扣回,不等于让原发放从历史上消失。补偿可能失败,账户余额可能已被使用,外部系统也可能已经据此发送通知。必须为补偿选择新的业务身份、审批与不变量,并与原操作建立关联。

原操作:已发放积分,保留成功事实
补偿操作:申请扣回,等待独立审批与执行
关联:说明补偿针对哪项原操作及其范围

不能拿原幂等键发送相反参数。工具会正确地把它认定为参数冲突,或者返回旧效果。绕过冲突保护不叫补偿,只是破坏了操作身份合同。

多步流程的补偿顺序也属于业务设计。先预留库存再收款,与先收款再安排发货,能够采取的恢复措施不同。某些动作不可逆,只能追加说明、退款或人工协商。本书没有实现一套通用 Saga 或自动补偿编排器。

12.5 给操作者可理解的取消结果

产品可以区分尚未执行已停止、正在核查、已经完成但请求取消、需要申请补偿。它们比一个覆盖全部情况的“取消成功”更接近系统实际知道的事情,也让操作者能够选择正确的后续动作。

维护记录应关联原操作、取消主体、观察到的状态与后续核查。日志无需复制敏感工具内容,但必须留下能够解释执行决策的原因。人工恢复也要遵守相同规则,不能通过后台脚本绕过幂等键和审批。

评审取消功能时,至少选择排队、领取后调用前、工具提交后回执前、核查查询期间四个窗口。对每个窗口分别检查本地状态、工具效果和界面表达。只在一个很快完成的正常请求上点击取消,不能证明这些竞争都得到了处理。

练习。 一项操作已经给账户增加 500 单位,用户随后取消。请设计原操作与补偿操作的身份和状态,列出补偿失败时仍然成立的事实,并说明为什么数据库回滚和关闭 HTTP 连接都不能撤销原工具事务。