跨资源事务与长流程
账户扣款、预留库存、发放优惠券跨了三个资源。客户端只看见一次“下单”,系统却可能已经扣款、仍不知道库存是否确认,甚至优惠券已经被使用。问题不是给每个方法加事务注解,而是明确:谁裁决结果,哪些中间状态可以暴露,故障以后谁继续执行。
高级必会要求能画出每次本地提交和外部副作用的分界;架构师进阶要求给出持久决定、确认与取消竞争、不可逆动作和恢复期限。本文用协议与可复算历史论证,不把教学状态机标成某个组件的实际源码。物理本地提交的实现见 Spring 事务,业务状态与发送意图共同提交见 Outbox。
先定位原子域,再选择协议
把同一库、同一物理事务中的订单表和待发送记录放在一起,可以做到共同提交;同一调用链上的第三方 HTTP 扣款不在这个原子域中。数据库回滚不会撤回一个已经被支付方受理的请求,@Transactional 也不会把两个独立服务的连接自动合成一个事务。
先写业务不变量,例如“已确认订单必须有有效库存预留和可确认的支付事实”“同一业务单只能扣一次款”,再选择协议。若业务绝不接受两个资源短暂不一致,就不能仅因拆成两个服务而把要求改成“以后补偿”。可先调整数据与竞争域,使约束落在一个事务资源内。
| 候选 | 怎样裁决 | 故障中保留什么 | 主要条件与代价 |
|---|---|---|---|
| 单库本地事务 | 同一资源提交或回滚 | 本地恢复日志 | 约束能共处一个原子域;事务应短,不持锁等待外网 |
| 2PC / XA | 参与者 prepare 后服从同一全局决定 | 参与者已准备事实、协调者持久决定 | 参与者支持协议;准备态可能占锁并等待恢复 |
| TCC | 各参与者先保留可确认资源,再按决定 confirm/cancel | 业务预留、唯一分支身份、终态/取消墓碑 | 业务实现预留与幂等;协调者不能发出相互矛盾的决定 |
| Saga | 多个本地提交与后续业务补偿 | 步骤事实、待执行动作、补偿进度 | 中间态可容忍;补偿可失败,也可能无法恢复原状 |
| 本地事务 + Outbox | 本地事实与发送意图共同提交,异步传播 | 不可变事件、投递与消费身份 | 解决本地双写窗口;下游提交、顺序与重复仍有自己的协议 |
Outbox 常被用于 Saga 每一步的通知,不是与 Saga 同层互斥的“二选一”。选择某个协议时,要同时给出停止受理、超时、人工介入和对账条件;“重试直到成功”没有说明永久失败怎么办。
2PC 的准备态不能靠超时擅自取消
2PC 的关键不是两次 RPC,而是两个阶段之间的承诺。协调者先请求 prepare;参与者确认本地工作可提交并持久化准备态后投 YES。协调者收齐 YES 才能决定 COMMIT,并先持久化该全局决定,再通知参与者。参与者的重复 commit/rollback 必须按事务身份得到同一结果。
如果库存已经投 YES,协调者随即失联,库存不能仅因本地等待超时就回滚:其他参与者可能已收到 COMMIT 并完成提交。此时保留准备态、查询决定或等待协调者恢复,会产生阻塞与锁占用。投票前失败可以拒绝准备,与已经准备后的“结果未知”不同。协调者重启后也不能只读内存中的“似乎都成功了”,必须恢复持久决定。
本段解释经典协议的恢复条件,不声称本仓库已实现 XA 协调器。复制协调者、启发式人工决策和参与者实现会改变故障与运维边界,要单独说明;把 2PC 直接称为“任何故障下不可用”或“超时就全部回滚”都过度简化。
MySQL参与资源的具体XA能力和限制见8.0 XA文档;协议推演不等于已运行协调器。
预留、确认与取消的状态机
以 (order_id, branch_id) 标识库存分支,另保存固定请求摘要 request_hash。Try 预留 1 件库存,Confirm 把预留变成已售,Cancel 释放预留;同一身份异参必须冲突,不能把重复请求当成新的扣减。数量变更和分支状态在参与者自己的本地事务内共同提交。
| 当前状态 | Try(同一请求) | Confirm | Cancel |
|---|---|---|---|
| 不存在 | 条件扣减可用量并插入 RESERVED;不足则拒绝 | 拒绝未知预留,不凭确认请求新建库存效果 | 写入 CANCELLED 墓碑,尚无预留可释放 |
| RESERVED | 返回同一预留结果,不再扣减 | CAS 到 CONFIRMED,并把预留转已售 | CAS 到 CANCELLED,并且仅释放一次 |
| CONFIRMED | 返回已确认事实 | 返回同一确认结果 | 拒绝;需要独立退货/退款业务,不能改终态 |
| CANCELLED | 拒绝迟到预留 | 拒绝迟到确认 | 返回同一取消结果,不再释放 |
表中每格都是一次本地原子转换。常见的教学 SQL 形状如下,实际表还需唯一键、请求摘要与库存更新;两者必须在同一事务中提交,不能只更新状态而漏掉库存。
-- 两个竞争者都只能从 RESERVED 出发,必须检查影响行数。
UPDATE reservation SET state = 'CONFIRMED'
WHERE order_id = :order AND branch_id = :branch AND state = 'RESERVED';
UPDATE reservation SET state = 'CANCELLED'
WHERE order_id = :order AND branch_id = :branch AND state = 'RESERVED';
-- 成功者在同一事务完成对应库存转移;失败者查询已有终态,不能覆盖。这解决参与者内的终态竞争,不证明多个参与者都作了同一全局决定。完整 TCC 还需要协调者持久化唯一的 CONFIRM 或 CANCEL 决定,所有恢复者重放同一决定。若协调者对库存发送确认、对额度发送取消,即使每个参与者的 CAS 都正确,业务仍可能分裂。
取消先到是空回滚:Try 尚未落地,Cancel 没有资源可退,但仍要保存取消墓碑。之后迟到的 Try 必须看到墓碑并拒绝,这叫防悬挂。墓碑和业务身份不能在仍可能重放旧 Try 时随意过期;分库唯一键要让同一分支到达同一个裁决分区。仅在内存里记“取消过”无法抵御进程重启。
空回滚与防悬挂的实现背景可对照Apache Seata官方TCC分析;本页表格是业务协议教学模型,没有冒用该组件源码或默认启用它的拦截机制。Seata:AT、TCC、XA 与 TC 恢复另有固定 2.5.0 的 fence 状态及 TC 驱动 confirm/cancel 实验;其中 kill -9 后恢复的实验是 AT 未决事务的 undo 恢复,并非 TCC 重启恢复实验。
支付迟到与 Saga 补偿
原始故障历史是:扣款成功,库存确认响应超时,客户端重试。恢复时先按原业务身份查询支付与库存事实,不能因为调用超时再造一个支付单。支付查询键须在请求前已知并随请求传出;若供应商只能用首次响应中的 receipt-id 查询,而该响应丢失,系统仍可能是 UNKNOWN,需要供应商对账或人工处理。完整业务幂等和外部效果边界见 业务幂等。
另一条历史是预留超时取消已成功,支付成功通知随后到达。最后写入覆盖会把 CANCELLED 改回 CONFIRMED,可能出售已经分给别人的库存。应保留取消终态,核对权威支付事实后,执行独立退款、重新预留或人工分支;每个动作有自己的幂等身份和结果。不能既把超时当作“支付必失败”,又在迟到成功时无条件确认。
预留期限必须是参与者与协调者共同理解的受理契约。若协议要求准备后等待统一决定,参与者不能自行到点取消、同时允许协调者无限期确认;若采用有截止的业务预留,协调者就要承认过期后确认可能被拒绝,并定义已扣款的恢复路线。涉及时钟、迟到消息和临界时刻竞争时,应先把契约写清,再实现定时任务。
Saga 补偿是新的业务动作。扣款 100 元后退款 100 元,账本会多出一笔退款事实,并非把原扣款从物理历史抹掉;退款也可能失败或受理后结果未知。已经被使用的优惠券不能靠 DELETE coupon 撤销用户已经获得的权益。必要时先做可撤回的预留,把发券、通知或不可逆外部动作推迟到合适的确认点,但这会改变业务时延和资源占用。
多步 Saga 的成功路径 S1 → S2 → S3 失败后,常按已成功步骤逆序补偿 C2 → C1;顺序取决于业务依赖,不是固定数据库规则。补偿要幂等、可查询,失败后保持 COMPENSATING 或 NEEDS_REVIEW,不得把“已发起退款”当成“已退款”。并发流程可能已经观察到中间结果,所以即使全部补偿完成,也不保证对外历史等同从未发生。
恢复必须脱离原请求线程
持久流程至少记录 workflow_id、输入摘要、当前步骤、已知参与者事实、决定、due_at、owner、attempt、deadline 和最近错误。发送前保存待执行意图,响应后把事实与下一步意图共同提交;崩溃后按事实继续,不凭请求线程是否返回推断结果。
恢复者领取到期流程:在一次事务内取得属于本次领取的 claim_id / attempt
查询原业务身份的参与者事实:SUCCESS / FAILED_FINAL / UNKNOWN
按持久决定继续确认、取消或补偿;UNKNOWN 进入查询/对账,不凭超时改失败
CAS(workflow_id, own_claim_id, expected_step) 写入事实和下一步意图
到达 deadline 且事实仍不充分:转 NEEDS_REVIEW,保留待查证据与告警领取者 A 暂停、租约过期、B 接手以后,A 的旧 CAS 必须失败;A 不能醒来查询“最新 attempt”并冒认 B 的领取权。CAS 只保护本地流程更新,无法撤回 A 已经发出的外部请求;参与者还需按稳定业务身份去重和查询。涉及外部资源拒绝旧授权的协议见 租约与 fencing,不能把本地锁直接当成远端付款的撤销开关。
恢复与人工路径也必须持久化。人工决定退款后,后台不能读到旧步骤再确认;需要同一流程版本/决定校验。永久失败进入明确终态或人工状态,未决工作数量、最老年龄、补偿失败与对账差异都要可观测。只有成功请求数而没有 UNKNOWN 年龄,无法说明系统正在收敛。
隔离与跨行约束
跨资源协议之前,先确认本地约束已经成立。两个医生分别读到“自己之外还有一人在岗”,再各自把自己一行设为离岗,可能得到无人值班;两个更新的行不同,单行原子与快照读不能裁决这个跨行谓词。账户总额上限也可能被分别检查后写穿。
选择包括数据库可表达的约束、可串行化事务、显式锁定共同守卫行、原子条件更新或重新建模竞争域。可串行化要求提交结果等价于某个串行历史,不要求所有操作真的排成一条队列;某些引擎的 SSI 通过冲突检测中止事务,这是具体实现选择,不是本课程 MySQL 的通用机制。冲突后重试整个本地工作单元,稳定业务身份仍需保留;不得把已经成功的外部付款当作可随数据库事务任意重跑的 SQL。
本课程 MySQL 的 写偏差与守卫行给出双会话协议:所有写路径先锁同一个 guard,再以当前数据检查。锁后仍用旧快照检查、后台路径绕过 guard,都会破坏证明。T16 已在本机 MySQL 8.4.7 验证无 guard 最终 0 人、完整 guard 协议最终 1 人;源码基线仍为 8.0.39,范围见 MySQL 实验范围。这里引用该证据,不重复宣称运行了 XA 或分布式事务实验。
全局锁和单写者简化共同裁决,但扩大串行热点与故障影响;分区所有权提高并行度,前提是不变量也能按该分区独立成立。跨分区总额或跨仓库存转移仍需协调与恢复。单写者进程重启后的所有权、未完成请求和历史恢复,同样不能省略。
可检查实验
用纸面状态表逐步执行下列输入;每个动作后写出参与者终态、库存变化、流程决定和下一个持久意图。不需要假设网络会按发送顺序到达。
| 输入历史 | 可独立核对的预期 | 能暴露的错误 |
|---|---|---|
| Cancel(x) → Try(x) → Cancel(x) | 留一个 CANCELLED 墓碑,库存净变化 0,Try 被拒绝 | 无墓碑使迟到 Try 再次占用;重复取消多释放 |
| Try(x) → Confirm(x) 与 Cancel(x) 并发 | 同一 RESERVED 仅一次终态 CAS 成功,另一方读终态;库存只转移一次 | 最后写覆盖,确认与释放双生效 |
| Try(x) → Try(x,数量不同) | 摘要冲突,不新增预留 | 同一幂等键异参被当作成功重放 |
| 库存取消成功 → 支付成功迟到 | 库存保持 CANCELLED;按支付事实进入退款/重新受理/人工分支 | 用支付通知覆盖库存终态,出售他人库存 |
| 支付已受理 → 响应丢失 → 进程崩溃 | 重启按预知业务键查询;不可查时维持 UNKNOWN | 以超时当失败,再次无身份扣款 |
| 决定 CONFIRM 持久化 → 仅一个参与者确认 → 协调者崩溃 | 重启对其他参与者重放同一 CONFIRM;不另选 CANCEL | 只记内存决定,恢复后对不同参与者反向裁决 |
| A 发请求前暂停 → B 接手 → A 恢复 | A 本地旧 claim 更新被拒绝;外部仍按同一业务身份去重 | A 读最新 token 冒认;以本地 CAS 当网络撤销 |
这些是有限故障历史的手工核查,表中终态不是实际多节点运行输出。另有可运行的有限参与者模型:
python3 labs/middleware-senior/reliability_lab.py workflow --case all --json输入库存1。empty_cancel依次为CANCELLED/REJECTED/CANCELLED,可用1/预留0/已售0;terminal_races穷举确认与取消的两种终态先后,库存总和仍为1;异参Try返回CONFLICT。模型将协调者CONFIRM写入临时SQLite文件,重开连接后尝试写入相反CANCEL,唯一身份裁决保留原决定,恢复后两个分支均CONFIRMED;故意去掉共同决定的负例允许两个本地状态机分别CONFIRMED/CANCELLED,输出GLOBAL_DECISION_SPLIT。参与者单次转换的本地原子性是模型前提,只有协调者决定使用真实文件事务;它不实现网络、完整协调器或XA。公开CLI断言和本轮输出均已运行。可继续运行 Inbox / Outbox 实验的公开命令,观察真实临时 SQLite 本地事务的提交前后崩溃与领取恢复;它不实现完整 Saga、TCC、XA 或供应商支付。真实验收还需在选定参与者实现上注入断网、协调者重启、截止边界和补偿失败,并记录查询接口、业务身份与持久事实。本票不声称这些外部故障注入已经运行。
三轮追问:从超时走到约束变化
第一轮:“库存确认超时,应该马上取消还是重试?”先查询同一分支事实并读取持久决定。超时是未知观察,不是失败;决定已是 CONFIRM 就重放确认,状态未知就查询恢复。若采用有截止的业务预留,还要处理已到期拒绝与支付事实。评分:区分未知与失败 1 分、决定/事实 1 分。
第二轮:“优惠券已经用掉,还能补偿成原样吗?”不能保证恢复原历史;选择权益追回、独立补偿或人工处置,并记录成本与审计。应在方案阶段确认是否允许这个中间态。评分:不可逆反例 1 分、业务恢复路线 1 分。
第三轮:“把两个资源合到同一个库是否就全部解决?”同物理事务可收缩提交边界,但跨行谓词仍需完整裁决,外部支付仍在边界外,热点与部署约束也改变。评分:原子域 1 分、本地不变量与外部边界 1 分。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。