系统请求退回 29900 分,支付服务已经写入账本,却在返回响应前断开连接。Worker 只看到超时。如果把超时解释成“没有执行”,下一次换一个操作标识重新申请,用户可能收到两笔退款。
可靠执行的难点正是这段未知窗口。运行时不能靠模型猜结果,也不能仅靠一个 completed 内存集合证明外部动作没有重复。
先定义什么算同一次操作
一次退款申请对应稳定 operation ID,重试和恢复继续使用它。相同订单的另一次部分退款使用新的 ID,同时仍受到剩余可退金额约束。
操作 ID 还要绑定参数指纹,包括租户、订单、金额、币种等。O17 第一次表示退一百元,第二次却表示退三百元,服务端应拒绝参数冲突,而不是返回之前的成功或执行新金额。
请求 ID 通常用于追踪某次网络请求;operation ID 用于识别业务动作。一次操作可以经历多次网络请求。把二者混用,会在网络重试时破坏稳定身份。
为什么“先记完成”与“后记完成”都不够
先写完成标志再调用支付,可能在两者之间崩溃,导致记录显示完成但实际没有退款。先调用支付再写完成标志,则可能支付成功、本地未记录,恢复后重复调用。
方案 A:写 completed → 崩溃 → 支付没有发生
方案 B:支付成功 → 崩溃 → completed 尚未写入
在同一个数据库中,业务写入与幂等记录可以用事务和唯一约束共同提交。但支付服务是另一个系统,普通本地数据库事务不能包住它的 HTTP 请求。
因此需要支付接口支持幂等键或可查询操作回执,或者设计业务对账与人工处理。没有这些能力时,不能承诺任意外部动作恰好执行一次。
模拟支付怎样展示真实边界
配套payment_simulator.py下载使用独立 SQLite 数据库,保存 operation ID、参数指纹、金额和回执。相同操作重复提交返回原回执;同 ID 不同参数则拒绝。
它在同一支付数据库事务内检查剩余金额、写退款记录并减少可退余额。随后可以故意抛出超时,让调用方丢失响应。下一次运行先查询原 operation ID,再把回执写入任务数据库。
两个数据库故意分开,就是为了保留“远端已成功、本地未知”的窗口。该示例不连接真实支付,没有借本地演示宣称所有网关都支持相同保证。
Outbox 解决的是哪一段断裂
任务状态变为 ready 后,需要向队列发布执行消息。若先提交数据库再发消息,进程可能在中间崩溃,任务永远没人执行。若先发消息再提交状态,消费者可能看到不存在或尚未批准的任务。
Outbox 将任务更新与待发布消息写入同一数据库事务,再由发布器读取并发送。这样只要本地事务提交,发布意图就能被继续处理。
发布器仍可能发送成功却未标记已发送,所以消息可能重复。消费者需要 Inbox 或其他去重机制,在同一事务内记录 event ID 与可原子完成的业务更新。若消费者又要调用外部服务,仍要处理新的远端边界。
outbox_demo.py下载用独立消费者数据库模拟确认丢失,再用 Inbox 唯一键防止本地计数重复。它没有连接真实消息队列。
Outbox 不是“从此没有重复消息”,它把容易丢失的双写拆成可恢复的状态和允许重复的传递。
重试、死信和对账怎样分工
临时故障可以退避重试,加入抖动避免大量 Worker 同时再次请求。应限制总次数、总时间,并避免模型 SDK、工具层和任务层各自重试造成倍增。
参数错误、权限拒绝和余额不足通常不能靠原样重试解决。多次失败进入死信或人工队列时,应保留操作身份和最后已知状态,不能把它当成全新任务重新发布。
对账面对的是结果未知:查询支付回执、核对金额和对象,确认后更新本地任务。缺少回执并不总能证明从未执行,要依据支付系统查询的完整性与状态定义处理。
补偿不是把时间倒回去
预留库存后支付失败,可以释放库存;邮件发错后不能让收件人从未见过,只能更正;已经退款后再扣款也不是简单数据库回滚,需要新的业务授权。
Saga 将跨服务过程拆成步骤及相应补偿。补偿自身会失败,因此也要记录、重试和核对。设计时先问某个动作是否真的可逆,而不是给每个函数机械添加一个 undo。
审批和取消不能改变已经发生的事实
恢复时若审批过期,不能继续创建新的支付动作;但查询已经发生的操作并记录结果仍然必要。当前授权控制新的副作用,对历史事实的对账则按相应读取权限进行。
取消也是如此:若支付尚未提交,可以结束;若可能已提交,应先核对。最终任务可以记录“取消请求到达时退款已完成”,而不是为了迎合界面状态把账本成功改成失败。
完整命令行示例下载会保留 ready、executing 和 reconciling 等阶段,帮助读者沿时间线观察。下一篇把人工决定纳入同样的持久状态设计。