事务恢复:幂等、Outbox、提交未知与恢复责任
本页把四类常被分开放置的机制放在同一条合成订单链上验收:幂等与 Inbox、Outbox 与领取令牌、提交未知与补偿、缓存水位与重试预算。它固定 MySQL 8.4.7(本机 DBngin)、Redis 8.6.4 standalone、Boot 4.0.9 BOM 与 mysql-connector-j 9.7.0。机制权威仍在既有四篇(业务幂等、Outbox、跨资源事务与长流程、缓存与数据库一致性)与 超时重试、熔断限流降级;本页新增的是同一条链上的真实运行证据与“提交/发送/ack 的边界如何各自失败、谁负责恢复”。
入口:python3 labs/transaction-recovery/run.py,8 个测试、9 条观察行、exit 0,证据在 evidence/u61-2026-10-27.json。
一、同一业务上的五个身份
合成业务只有五张表:stock、orders、order_idempotency、outbox、inbox 与效果表 notification。同一次下单会经过五个不同的身份,每个身份回答不同的问题:
| 身份 | 回答的问题 | 本页落在哪里 |
|---|---|---|
tenant + idempotency_key | 这是不是同一次请求 | order_idempotency 唯一键 |
order_id | 哪一笔业务事实 | orders 主键,Outbox 的 aggregate_id |
event_id | 哪一条传播事实 | outbox.event_id,消费者的 Inbox 主键 |
claim_id + claim_version | 谁是这次投递的责任人 | outbox.claim_id/claim_version/lease_until |
payload_hash | 同身份是否同参数 | 幂等表与 Inbox 各存一份 |
把“同一次请求”与“同一条事件”混成一个键,会出现两种典型错误:用户重试被当成新订单(键太细),或不同事件被当成重复而丢效果(键太粗)。三种身份给出了完整对照;本页只强调:唯一键必须建在“你想让它只发生一次”的那一层,而不是统一用订单号。
二、重复请求:先查后写不够,唯一约束才是裁决
同键串行重放与服务端幂等:同一 idempotency_key 第二次请求必须返回第一次的结果。但“先查再写”在并发下会同时通过检查。真实运行把两种情况都跑出来:
U61 duplicateIdempotency=PASS serialDuplicate=true conflict=true concurrent=8 winner=1 uniqueLosers=7 effects=18 个线程带同一个键并发:1 个胜者插入 order_idempotency,其余 7 个在同一事务内插入时撞唯一键 uk_idem,抛出 SQLIntegrityConstraintViolationException(SQLState 23000)。正确的处理不是把异常抛给调用方,而是回滚整个本地事务(包括已经扣掉的库存和已插入的订单),再回查胜者记录,按 payload_hash 回答 DUPLICATE 或 CONFLICT。
这里有一个只有真实跑才会暴露的细节:首版实验没有捕获这个异常,测试直接失败在“期望 8 个结果,实际抛出 SQL 异常”。这正是并发幂等的真实形态——唯一约束是裁决者,“查不到”不是。同键异参返回 CONFLICT 而不是“按新参数再下一单”,因为 payload_hash 固定了请求语义。
三、提交响应丢失:客户端看到失败,服务器已经提交
“提交结果未知”最难解释,因为它要求真实让服务器提交、但客户端收不到响应。实验用一个受控 TCP 代理做到这一点:代理识别到客户端发出的整条语句是 commit 时,转发给 MySQL 并保留连接 500ms(让服务器完成提交),然后主动断开连接——响应包不保证送达客户端。客户端得到:
U61 commitResponseLost=PASS clientException=SQLNonTransientConnectionException serverCommitted=true replay=DUPLICATE effect=1三个事实必须分开:客户端异常(连接层)、服务器已提交(直连查询看得到那行订单)、业务可恢复(用原幂等键重放得到 DUPLICATE,效果仍是一次)。“没收到响应”不等于“没发生”;唯一可靠的裁决入口是稳定业务身份加幂等键,而不是重试时换一个新身份。
这个代理的开发过程本身是一个反例教材:首版用子串匹配 commit,结果把连接初始化时的 SET autocommit=0 也当成提交,丢掉的是 setAutoCommit 的响应——服务器那边根本没执行提交。修正为解析 MySQL COM_QUERY 包(第 5 字节 0x03 + 整条 SQL 等于 commit)后才得到真实结果。还要说明注入器的机制边界:它没有逐包过滤服务器的 OK 响应,而是在转发 commit 后主动断连,“响应未送达”由断连实现;这足以制造“客户端未知”,但不能声称拦截了特定响应包。故障注入器本身也要被验证,否则“实验失败”可能只是注入器在骗人。
四、Outbox:同一事务里的意图,领取必须带自己的令牌
下单事务里,orders 与 outbox(state=PENDING) 在同一物理事务提交;中途注入失败时两者一起回滚:
U61 midFailureRollback=PASS orderInsertedThenFailed stockUnchanged=true outboxUnchanged=truerelay 的领取与标记是两段独立事务。领取事务用 SELECT ... FOR UPDATE SKIP LOCKED 选一条到期事件,在同一事务里递增 claim_version、写入自己的 claim_id 与租约,并把 (event_id, claim_version, claim_id, payload) 一起返回。实验中 A 领取得到 version 1,B 用 A 的令牌标记得到 0 行;A 的租约过期后 B 接管得到 version 2,此后 A 的旧令牌永远 0 行:
U61 outboxFencing=PASS version1=A version2=B oldCas=0 newCas=1 state=SENT这条把领取事务必须返回自己的令牌从纸面变成本机可查的事实:“醒来读最新 claim_version 再冒认”在这里是不可行的,因为 CAS 在 WHERE 里同时校验了 claim_version 与 claim_id。注意 CAS 只保护本地状态:A 在租约期内已经发出的网络请求无法撤回,所以下游还要幂等。
五、消费者:Inbox 与效果同事务,重投必须安全
消费者收到事件后,Inbox 去重行与业务效果(notification)在同一事务写入。提交前故障时整个事务回滚,重投成功;再投一次得到 DUPLICATE,异参得到 CONFLICT:
U61 consumerInbox=PASS rollbackThenReplay=SUCCEEDED duplicate=true conflict=true newEffects=1把这条和第二节的幂等放在一起,形成完整的重复防线:发送侧用 tenant+idempotency_key 保证一次请求只产生一个业务事实;接收侧用 (consumer_name, event_id) 保证一条事件只产生一次效果。任何一侧缺失,另一侧都无法弥补。
六、至少一次投递:投递两次、效果一次
把整条链跑完:下单→Outbox→relay 发送→消费者→标记 SENT。在“broker 已投递、relay 尚未标记 SENT”处模拟进程退出(租约过期),B 接管后重发同一 event_id:
U61 atLeastOnceRecovery=PASS deliveries=2 businessEffects=1 finalState=SENT两次投递、一次业务效果、终态 SENT。这不是“恰好一次投递”,而是 at-least-once 投递 + 恰好一次效果——分布式系统里真正能承诺的组合。如果这个窗口里消费者没有幂等,deliveries=2 就会变成 businessEffects=2,这正是四个崩溃窗口里“broker 成功后、SENT 提交前”的窗口。
七、结果未知与重试预算:deadline 决定是否再发
结果未知(UNKNOWN)不是“失败”,而是“此刻无法裁决”。处理顺序是:先按原业务身份查证,再决定是否重试;重试前还要检查剩余 deadline 是否够一次完整的“退避 + 尝试”。实验用确定性计数与算例把两层放大摆在一起:
U61 retryBudget=PASS amplification=2x3x2=12 deadlineAdmission=false clientTimeoutBeforeServerFinished=true serverHits=13- 网关 2 次 × 服务 3 次 × 客户端 2 次 = 最坏 12 次下游尝试,而不是“重试 2 次”;这正是 PRD §7.2 EX2 的算例(网关 2 × 服务 3 × 客户端 2);RPC 可靠性页给了同一结论的调用树推导。
- 剩余预算 300ms、已花 150ms 时,“退避 80 + 尝试 150”放不进剩余 150ms,发送前检查必须拒绝;只数次数不看预算的策略会超支。
- 真实 loopback 上游设 800ms 延迟、客户端 300ms 超时:客户端抛
HttpTimeoutException,但服务器仍然收到并处理了请求(hits 增加)。这就是“读超时不回滚对端事务”的真实版本,也是为什么 UNKNOWN 写请求必须先查单。
熔断与限流的完整状态机、半开探测与舱壁隔离归 超时重试、熔断限流降级与 U33 的 R4j 实验;本页只把“重试预算 + 查证 + 幂等”串成可执行顺序。
八、缓存一致性:水位拒绝旧回填,坏脚本留下可达坏状态
缓存侧用真实 Redis 的 Lua 脚本验证三件事:
U61 cacheWatermark=PASS staleFill=0 legitFill=2 sameVersionConflict=-1 partialScript=floor3/cache2 atomicGuard=MISS- 旧回填被拒:F=1/C=1,写方提交 v2 并原子失效(F 升到 2、删除旧 C),迟到的 fill1 返回 0,缓存仍为空;
- 合法 fill 与冲突:fill2 写入
2|price-200,重复相同内容幂等返回 1,同版本异参返回 −1(冲突); - 坏脚本的可达坏状态:先写 F=3,再对非整数键
INCR报错,脚本最后的DEL没有执行——留下 F=3/C=2 的坏状态。真实执行的部分到“坏状态已留下并持久”为止:C的版本低于F(2<3)正是守卫的拒绝条件,原子guarded_read同时看到 F/C 返回MISS。“分步读会放行旧值”是纸面推演(需要一个先读到旧 F、后读 C 的真实交错),本实验没有执行这条路径,与正文第九节矩阵一样按推演标注,不冒充实测。
这正是缓存与数据库一致性讲的历史,只是这次在真实 Redis 上跑出来。它说明两件事:正常协议下分步读可以合法(前提是 F 和 C 都不丢且不绕过),坏状态需要原子守卫兜底;而“脚本报错不会回滚已写入的值”不是假设,是本机 8.6.4 的实际行为。
九、确认矩阵:谁提交了什么,谁负责恢复
| 确认 | 已证明的事实 | 未知/待恢复 | 恢复责任 |
|---|---|---|---|
| 下单响应 | 客户端收到订单号 | 响应可能丢失 | 用原幂等键重放,返回原结果 |
| 本地 COMMIT 响应 | 数据库已提交 | 客户端不一定收到 | 原业务身份查询;不要换键重试 |
| Outbox 领取 | 自己拿到了 claim_version | 租约期内他人可能发起过投递 | 令牌 CAS + 下游幂等 |
| broker 发送确认 | broker 已接收 | SENT 未标记时可能重发 | 租约过期后重发,下游 Inbox 去重 |
| 消费者处理 | 效果与 Inbox 同事务提交 | 位点可能未提交 | 重放 + Inbox 去重 |
| 外部 HTTP 200 | 对端已处理 | 本方可能已超时 | 先查单,再按原幂等键重试 |
| 缓存 fill | 本地水位接受 | 旧读可能回填、脚本可能半执行 | 水位 + 原子守卫 + 回源 |
矩阵的用法不是背诵,而是排障顺序:先确定哪个确认缺失,再确定谁持有可恢复的事实。缺少哪一层,就在那一层补幂等或查证,而不是在入口处“重试三次”。
十、证明边界:真实 MySQL 与 Redis 能证明什么
| 类别 | 本页有什么 | 不能推出什么 |
|---|---|---|
| 真实组件 | 本机 MySQL 8.4.7 专用库、Redis 8.6.4 standalone、真实 JDBC 事务与 Lua 执行 | 生产复制、备份恢复、Cluster/主从切换、OS 断电 |
| 故障注入 | 受控 TCP 代理丢弃 COMMIT 响应(服务器真实提交);提交前显式回滚;租约过期 | 网络分区、半开连接、真实进程强杀 |
| 建模部分 | 2×3×2 重试放大与 deadline 准入是确定性计数 | 真实压测与流量 |
| 未运行 | Kafka 真实投递链(U57/U80)、真实外部支付契约、MySQL 崩溃恢复(U52)、Redis 持久化丢失 | 不能标为已通过 |
开发中的真实失败也已登记:首版代理子串匹配误伤 SET autocommit=0;首版并发同键测试把唯一约束败者当成未捕获异常;首版 cleanup 观察行在 @AfterAll 中与专用库 DROP 的先后顺序需要确认(现由 ownDbRemaining=0 观察行固定)。教材完成不等于学习者掌握:下面的题目检查改变条件后的裁决。
闭卷验收
本页 12 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。