撤销也是一次新变更
一句话总结
撤销不是抹掉过去的操作,而是确认当前状态后执行的一次新变更。要把原变更和补偿的回执分开,对之后被其他人改过的行,不去覆盖。
为什么需要它
因下雨预报而取消的庆典又要举办了。负责人要求撤销早上的取消。如果用备份文件把整张表恢复,取消之后进来的新订单和其他负责人对数量的修改,也可能一起被抹掉。以前做过备份这一点,并不会给出现在就可以覆盖它的许可。必须重新划定可以撤销什么的边界。
这次的任务,是把已取消的订单恢复为 pending 的小型补偿。它不是连实际的支付取消或客户通知也一起撤销的示例。退款、发货、外部通知,各自需要另外的 API 和恢复策略。这里处理的是可以放进一个 PostgreSQL 事务的订单状态和补偿记录,在这个边界之内,防止部分变更和重复执行。
工作原理
首先,把 changes 中的原批准内容与 audit 中逐行的前后版本做核对。批准里有订单 1 的版本 3,审计里必须留有从 3 变为 4 的记录。补偿计划不是对当前表重新批准,而是从这份记录算出取消之后立即的期望版本 4。如果没有审计行,或者记录了不同的数量,依据就不一致,所以停下来。不能仅凭当前订单是 cancelled,就去推测它是哪次变更的结果。
恢复用的 UPDATE,要把客户、ID、取消之后的版本、数量、cancelled 状态全部放进去。匹配就改为 pending,并把版本再加一次。如果取消版本 3 之后变成了 4,那么补偿之后就是 5。如果降回旧版本 3,过期的批准材料可能会再次看起来是匹配的。即使把值改回旧样子,那之后发生过新事件的事实,也必须留在版本里。
원 승인 주문 1 / pending / revision 3
취소 확정 주문 1 / cancelled / revision 4
보상 확정 주문 1 / pending / revision 5
数字不是无限的。到达 PostgreSQL integer 的上限,取消和补偿就无法继续增加。原批准版本太大的话,要从补偿计划开始就拒绝。在真实的服务里,可以设计更大的类型或其他标识策略,但在本实验中,掩盖错误、让版本循环或减小,不是解决办法。
补偿里要留下 undo_id、原变更 original_id 和理由 reason。undo_records 中 undo_id 唯一,original_id 也唯一。这是为了防止用不同的补偿 ID 重复恢复同一个原变更。相同的补偿 ID、原变更、理由再次调用,会以 False 完成,但理由或原变更不同则是 Conflict。False 不是失败,而是“已经执行过的相同请求”这个结果。
补偿回执的插入、所有订单的恢复、undo_audit 记录是同一个事务。第二笔订单冲突时,第一笔订单的恢复和未完成的回执也都必须不存在。不修改原来的 changes 和 audit。因为补偿而删除原记录,就切断了说明当前状态为什么会是这样的联系。在过去的审计被删除的状态下,只留下新的成功记录,不是安全的补偿。
在现场相遇的样子
提交补偿之后程序立刻关闭,用户就收不到成功响应。用同一个 ID 再次请求时,必须读取补偿回执,在没有新变更的情况下完成。换成不同的补偿 ID 重新请求,原变更的唯一性约束和内容核对会告知冲突。要把这个差别说清楚,运维人员才能避免为了消除错误而不停地重新生成编号。
回执是补偿当时的事实。补偿之后其他负责人如果修改或删除了订单,就可能与现在的状态不同。inspect_undo 返回当时的记录,reconcile_undo 把当前对象分为一致、变更、缺失。即使数量相同,版本更高就是之后的变更,不能用别的 ID 的新订单去抵消原订单的缺失。当前核对只读取,不会在没有新批准的情况下纠正。
对实际客户,要把取消回执、补偿回执、当前核对结果区分开来传达。如果补偿失败,要说明哪个前提变了,并请求重新判断。这个程序以权限负责人调用为前提,接收 tenant 字符串本身并不是权限校验。真实服务的认证、角色、审计留存和访问控制是另外的边界。
下一项实验要做什么
分 8 步实现请求校验、原记录核对、单行恢复、有时间限制的批次、原子补偿、记录查询、连接所有权和有限的重试。在提交前后三处真实终止客户端,并同时执行相同的和不同的补偿 ID。已有的数据库镜像中 fsync 和 full_page_writes 是关闭的,所以不要把客户端终止验证,扩大成服务器断电的耐久性保证。
参考:PostgreSQL UPDATE、唯一性约束、事务隔离。补偿 schema、理由和版本范围是本课所定的契约,不是外部系统自动取消的保证。