贯通案例:异步通知与故障恢复
U79 的下单案例停在“订单与库存同事务提交、幂等裁决重复”;本文把链路延长到异步通知:订单提交后,谁把“已发生的业务事实”可靠地告诉下游、投递可能在哪三类边界失败、恢复之后用什么验收。它复用 U79 的同一套工程(labs/order-flow:真实 Servlet + Security + MyBatis + 本机 MySQL 8.4.7),在同一数据库中增加 outbox、inbox、notification 三张表,并新增真实 Kafka 投递(spring-kafka-test 单节点 KRaft broker)。
机制权威不在这里:Outbox 表结构与领取协议见 Outbox,Inbox 与三种身份见 业务幂等,Kafka 位点/事务/可见性见 MQ 三篇与 U57 客户端配置,恢复责任与确认矩阵见 U61 事务恢复。本文只回答组合问题:这些机制接到同一笔订单上时,边界各在哪里、失败后各自恢复什么、业务不变量如何验收。
入口不变:python3 labs/order-flow/run.py,24 个测试(U79 19 + U80 5)、28 条观察行、exit 0,证据在 labs/order-flow/evidence/u80-2026-10-28.json。
一、上一案例结束后,链路缺什么
U79 结束时,订单已经提交、库存已经扣减、幂等记录已经落库。但“下单成功”这件事只有下单数据库知道:通知服务、积分服务、任何下游都还一无所知。把 kafkaTemplate.send() 直接写进 OrderService 的 @Transactional 方法,会同时打开两个新裂缝:
- 先发后提交:消息已到 Kafka、事务随后回滚——下游把一笔不存在的订单当成事实;
- 先提交后发:提交成功、发送前进程退出——这笔订单永远不会被通知。
两条顺序都有失败窗口,而且都不能靠把 send 放进事务解决:Kafka 连接不在 JDBC 事务里。这正是 Outbox 双写问题;本案例的答案是把“发布意图”当作第五个写入,与订单同事务落库。
U80 在 U79 的下单事务里只加了一行业务写入:
mapper.insertOutbox("e-" + orderId, orderId, 1,
"{\"orderId\":" + orderId + ",...}");e-<orderId> 是事件身份(event_id),1 是聚合版本(aggregate_version)。它在 insertOrder 的同一个 try 块内,任何一步的唯一约束冲突(订单、幂等记录、outbox)都会让整个本地事务回滚,包括已经扣掉的库存。
二、意图与订单同事务:Outbox 在下单里落在哪里
outbox 表沿用权威页的状态机:PENDING → CLAIMED → SENT,claim_version/claim_id/lease_until 是领取令牌。U80 的中途失败测试验证了这个设计的意义——在事务最后一步注入 outbox 的 event_id 唯一约束冲突:
U80 txBoundaryRollback=PASS stock=10 idemRows=0 outboxRollback=true seedUntouched=true按代码顺序,扣减、插订单、插幂等都已执行,最后 insertOutbox 撞 uk_outbox_event——整个事务回滚:库存回到 10、幂等记录 0、订单 0、除预置诱饵行外 outbox 无任何新行(aggregate_id != -1 计数为 0),预置行的 state/aggregate_id/payload 都未被污染。中间态(扣减后的 8)没有被另一个连接观察到:InnoDB 未提交写对其他事务不可见,所以“先扣后回滚”是从代码顺序与最终状态推得,不是直接观测。半执行状态为零。这条把“四个写入必须同生共死”(U79)扩展成“五个写入必须同生共死”:意图是业务事实的一部分,提交或回滚都整体发生。
三、relay 领取与发送:令牌、租约与真实 Kafka
relay(OutboxRelay)是两个短事务加一次网络调用:
- 领取事务:
SELECT ... FOR UPDATE SKIP LOCKED取一条到期意图,在同一事务内递增claim_version、写自己的claim_id与租约,并把(event_id, payload, claim_version, claim_id)一起返回; - 发送:事务之外调用
KafkaTemplate.send(topic, key=aggregateId, value=payload).get(10s)——broker 慢不会拖住数据库连接; - 标记事务:
UPDATE outbox SET state='SENT' WHERE event_id=? AND claim_version=? AND claim_id=?。
正常链的观察:
U80 normalChain=PASS outboxTx=PENDING→CLAIMED→SENT deliveries=1 effects=1 duplicateBlocked=uniquePENDING → CLAIMED → SENT 三态都由 SQL 查询断言(领取后查 CLAIMED、标记后查 SENT),消息经真实 KRaft broker 被消费者读回;消费者用一个 TransactionTemplate 把 Inbox 去重行与 notification 效果包在同一物理事务里,重复投递被 Inbox 唯一键拒绝。
一个实现细节值得记录:MySQL 的 JSON 列会规范化 payload。relay 从数据库读回的 payload 是 {"sku": "SKU-MSG", "orderId": 1, ...}(键排序、含空格),与下单时手工拼接的 {"orderId":1,...} 逐字符不同。消费端测试若按拼接串匹配会永远失配;即使改成同时匹配紧凑/带空格两种子串,"orderId":3 也会命中 "orderId":30。测试最终用 JSON 解析后比较数值,而不是任何形态的字符串比较——这正是“同一事实的两种序列化形态”,比较事实必须用解析后的身份字段。
四、发送后未标记 SENT:投递两次、效果一次
第一类恢复窗口:broker 已收到并投递、relay 在标记 SENT 前退出。测试让它真实发生:发送成功后不标记,把租约改成已过期,等 B 接管:
U80 sendBeforeMarkCrash=PASS deliveries=2 businessEffects=1 oldTokenCas=0 state=SENT时序:A 领取 v1 → 真实发送(消费者第一次读到)→ A“崩溃”→ 租约过期 → B 接管 v2 → B 真实发送(同一 event_id 二次到达 Kafka)→ A 的旧令牌标记 SENT 影响 0 行(CAS 同时校验 claim_version 与 claim_id)→ B 标记 1 行、状态 SENT。
结果是 2 次投递、1 次业务效果。这正是 U61 在 MySQL 层验证的“至少一次投递 + 恰好一次效果”,这次接上了真实 Kafka:投递层不承诺去重,去重发生在消费者自己的 Inbox 唯一键。
五、位点未提交:重启重放,Inbox 兜底
第二类恢复窗口:消费者处理完业务、提交了 Inbox 与效果,但位点没有提交(进程退出)。测试里第一轮消费不调用 commitSync,同组新实例“重启”:
U80 offsetReplay=PASS replay=1 inboxRejected=true effects=1新实例从已提交位点(无)重新拉取,同一 event_id 再次到达;Inbox 唯一键拒绝重复插入,效果计数仍是 1。这条与 U57 的位点重放实验互补:U57 证明“重放会发生”,本案例证明“重放到达后业务如何裁决”。
Inbox 与效果能同事务的前提是两者在同一个库。如果通知服务写的是自己的数据库,Inbox 去重行与效果行就分属两个原子域,“先写 Inbox 再写效果”与反序都会留下可重放但不可自动裁决的窗口。此时的方案不是假装有一个跨库事务,而是把两者共置(同一库/同一分片),或改成“持久意图 + 下游幂等键 + 查证”的跨资源协议——这正是 U61 的提交未知与确认矩阵覆盖的范围。本案例没有运行跨库变体。
六、意图在事务最后一步失败:整体回滚不留半执行
第三类边界是提交侧的(第二节已展开)。把三类边界放在一起:
| 边界 | 注入方式 | 恢复路径 | 恢复后验收 |
|---|---|---|---|
| 提交(本地事务) | outbox event_id 唯一约束冲突(最后一步失败) | 整体回滚,无意图残留 | 库存/订单/幂等/outbox 全部回到失败前 |
| 发送(relay 崩溃) | 发送成功后不标记 SENT,租约过期 | B 接管重发同一 event_id;旧令牌 CAS 0 行 | 投递 2 次、效果 1、终态 SENT |
| ack(消费位点) | 业务提交后不 commitSync | 同组新实例从 0 重放 | 重放 1 条、Inbox 拒绝、效果 1 |
没有覆盖的:死信/重试主题(消费失败分流)、调度表恢复(due-time 任务)、Kafka 事务把 retry 输出与源位点原子提交——这些协议在重试与死信页,本案例的消费链没有失败分支可分流(Inbox 唯一键裁决后不需要重试),如实列为未运行。
七、业务不变量核验:验收对象是效果,不是消息
AC08 的关键要求是“恢复后验证业务效果而非仅消息被消费”。U80 的收尾测试核验四条不变量:
U80 businessInvariants=PASS sent=3 effects=3 inbox=3 linkedOrders=3 orphanSent=0 sentWithoutEffect=0 effectWithoutInbox=0 duplicatedEffects=0 orphanEffects=0- SENT 意图与真实订单一一对应(与
orders关联的 SENT 计数为 3,孤儿 SENT 为 0); - 每条 SENT 都有对应业务效果(anti-join:SENT 无 notification 的计数为 0,诱饵行除外);
- 每条效果都有对应 Inbox 记录(anti-join:notification 无 inbox 的计数为 0),且同一
event_id没有多条效果(分组计数 > 1 为 0)——没有“去重成功但效果缺失”的空转,也没有重复效果; - 效果指向的订单都真实存在(anti-join 为 0)。
计数相等只是必要条件;上面四条是配对检查,能排除“A 的 SENT 对应 B 的效果”或同一事件两条效果这类计数相等但配对错误的情况。
“消息被消费”在这里从未作为验收对象:lag 为零、消费者打印过日志,都不能替代 notification 表里的那一行。这与固定水位对账的分类思想一致——用持久事实对账,不用进程状态。
八、证明边界:单节点 broker 与受控崩溃窗口
| 类别 | 本案例有什么 | 不能推出什么 |
|---|---|---|
| 真实组件 | 真实 Servlet/Security/MyBatis(U79 同栈)、本机 MySQL 8.4.7 专用库(study_u79_* 与 study_u80_* 分别 DROP)、spring-kafka-test 单节点 KRaft broker(真实 broker 代码) | 多 broker 副本/ISR、网络分区、broker 磁盘故障 |
| 故障注入 | 中途失败=唯一约束冲突(真实数据库行为);relay 崩溃=不标记 SENT + 租约改过期(受控窗口,非进程强杀);ack 丢失=不调用 commitSync(真实位点行为) | OS 崩溃/断电、kill -9 relay、Kafka 生产者重试的未知分支 |
| 合成部分 | HS256 token、SKU/金额、JSON payload | 生产事件 schema、真实消费者服务 |
| 未运行 | 死信/重试主题、调度表、观测指标、部署演进(U81) | 不能标为已通过 |
崩溃注入是受控窗口而不是进程强杀:租约过期由 SQL 改时间触发,等价于“relay 在该点退出后时钟流逝”,但没有真实的 kill 信号与连接断开。Kafka 是单节点(副本因子 1),acks=all 的复制语义在这个拓扑上退化为“leader 本地追加”——复制的确认边界归 U57 的 3 broker 实验,本案例只验证投递与恢复协议。
教材完成不等于学习者掌握:下面的题目检查改变条件后的裁决。
闭卷验收
本页 12 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。