Outbox:提交发布意图,再恢复交付
订单已提交却没通知下游,和下游收到两次通知,是两种不同问题。Outbox 用本地事务消除前者的双写裂缝,再用持久发送状态与业务幂等处理重试。本页按“谁持有什么事实、退出后如何恢复”推演,不把 SENT 当作用户业务已经完成。
SQL 沿用 MySQL 8.0.39,本地提交原码固定 Spring Framework v6.1.12。Kafka 3.7.1 的 producer、事务协调与 LSO 实现统一回查MQ 权威讲解。
双写为什么没有完美的先后顺序
先提交订单再发送事件,进程可能在两者之间退出,留下永远没有发布意图的订单。先发事件再提交订单,下游可能将最终回滚的订单当成事实。把 send() 放进数据库事务、等 Future 或增加重试,均没有把两个独立资源变成一个提交协议,还会拉长行锁与连接占用。
将要求改写成可裁决的不变量:每个已提交订单都有一个可恢复的 OrderCreated 意图;未提交订单不会留下被当成已提交事实的意图;同一意图可以多次交付,但下游同一业务效果只能提交一次。业务表与意图表在同一物理本地事务中提交,relay 再至少一次发送;外部支付与消费成功不在这次提交中。
事件必须声明是不可变事实还是待执行命令。业务事实不可在补偿时静默换掉 payload;补偿应产生有新身份、关联原操作的事实。若 payload 涉及敏感数据,可以保存受控引用,但引用对象的保留与读取权限必须覆盖发布及回放窗口。
“最终”还必须有期限和责任者。示例目标可以是 99.9% 在 30 秒内发布、意图年龄超过 5 分钟告警、超过 1 小时按业务风险暂停相关自动补偿入口并人工认领。这些是待业务确认的目标,不能用无限重试代替目标达成。
表结构与状态:意图提交后保持不变
以下为教学 DDL。生产应补齐实际租户作用域、payload 校验及归档字段;聚合唯一键表示“一次聚合版本一个事件”的本例契约,若一次版本发多种事件,需将事件种类纳入身份。
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
status VARCHAR(16) NOT NULL,
version BIGINT NOT NULL
);
CREATE TABLE outbox (
event_id VARCHAR(64) PRIMARY KEY,
aggregate_id BIGINT NOT NULL,
aggregate_version BIGINT NOT NULL,
payload JSON NOT NULL,
state VARCHAR(16) NOT NULL,
available_at DATETIME(6) NOT NULL,
claim_version BIGINT NOT NULL DEFAULT 0,
claim_id VARCHAR(64) NULL,
lease_until DATETIME(6) NULL,
UNIQUE KEY aggregate_event(aggregate_id, aggregate_version),
KEY dispatch(state, available_at)
);
START TRANSACTION;
INSERT INTO orders VALUES (7,'CREATED',1);
INSERT INTO outbox VALUES ('e-7-1',7,1,JSON_OBJECT('orderId',7),
'PENDING',NOW(6),0,NULL,NULL);
COMMIT;event_id、聚合身份、版本及 payload 是不可变意图;PENDING → CLAIMED → SENT 是可恢复的交付状态。available_at 控制重试退避,lease_until 使领取者退出后可以接管,claim_version 区分领取任期。永久 schema 错误应进入可追踪隔离并保留修复路径,不可标 SENT 清理报警。批量领取只是吞吐优化,每一项仍须保留自己的身份和结果。
领取事务必须返回自己的令牌
relay 用短事务领取到期 PENDING 或租约过期的 CLAIMED 行。MySQL 可用 SELECT ... FOR UPDATE SKIP LOCKED 跳过已锁记录,减少争抢;这是一种队列式领取,不是一致快照读取。锁定读机制回查MySQL 事务与锁。下面是教学协议,不是完整可复制的参数绑定 SQL:
生成本次唯一 claim_id
BEGIN
SELECT 一条到期记录 FOR UPDATE SKIP LOCKED
在持锁事务内递增 claim_version,写入 claim_id 与 lease_until
在同一事务内取回 (event_id, payload, claim_id, claim_version)
COMMIT
携带刚才返回的令牌,在事务外发送网络请求不能先提交领取、暂停后再无条件 SELECT claim_version。在暂停期间 B 可能已接管为 13,A 读到 13 后会冒认 B 的令牌,令 CAS 失去意义。若领取提交的响应丢失,使用预先生成的唯一 claim_id 查询自己这次领取是否仍存在;查到别人的 claim 或无法确认时,不把它作为自己的授权。即使刚查询确认,租约仍可能随后过期,业务仍需要下游幂等。
收到确认后,在新短事务中按原领取令牌更新:
UPDATE outbox
SET state='SENT', lease_until=NULL
WHERE event_id='e-7-1' AND state='CLAIMED'
AND claim_version=12 AND claim_id='relay-A-unique-claim';A 的 12 过期、B 已领取 13 时,A 的更新应影响零行。这个 CAS 只保护本地状态,A 恢复后已经发出的请求不能撤回;B 也可能重复发送同一 event_id。下游必须使用业务幂等裁决,claim_version 不能替代它。发送期间不持数据库行锁;否则 broker 慢几十秒就会变成锁和连接池故障。
SKIP LOCKED 也不保证聚合顺序。v1 被锁时另一个 relay 可以跳过它先发送 v2;按扫描时间排序不足以修复。可按 aggregate 分片串行、只领取无未完成前驱的事件,或让下游检测版本缺口。局部顺序的代价与旁路重试见日志顺序与业务顺序,不必为全系统串行化。
原码:同一本地事务最终落到同一连接 commit
原码连续摘录(仅统一公共缩进,截取方法局部):DataSourceTransactionManager.java,v6.1.12,L330–L342。
protected void doCommit(DefaultTransactionStatus status) {
DataSourceTransactionObject txObject = (DataSourceTransactionObject) status.getTransaction();
Connection con = txObject.getConnectionHolder().getConnection();
if (status.isDebug()) {
logger.debug("Committing JDBC transaction on Connection [" + con + "]");
}
try {
con.commit();
}
catch (SQLException ex) {
throw translateException("JDBC commit", ex);
}
}@Transactional 拦截器进入事务管理器后,连接被绑定到当前事务上下文;JDBC 访问需使用参与该管理器的连接,最终 doCommit 从 ConnectionHolder 取出这同一连接并调用 con.commit()。调用链与传播条件回查Spring 事务。两次 DAO 调用如果取了另一个非参与连接、使用独立数据源或 REQUIRES_NEW,就不能援引这段原码证明共同提交。
SQLException 被转换为数据访问异常,不代表远端必然没有提交。commit 响应丢失仍须使用订单与事件稳定身份查证。HTTP、Kafka 或支付调用不在该 Connection 的事务中,数据库回滚也不会撤回已经确认的外部效果。
图中三个编号各是独立提交;②之后③之前不受①的回滚控制。发送恢复使同一事件再次交付,③的同事务去重才保护接收端效果。
四个崩溃窗口与错误协议
| 退出位置 | 重开后的持久状态 | 恢复责任 |
|---|---|---|
| 业务事务提交前 | 订单与 Outbox 均未提交 | 原身份重新处理请求 |
| 提交后、relay 领取前 | 订单及 PENDING 均存在 | 扫描到期意图 |
| 领取后、broker 确认前 | CLAIMED,是否追加未知 | 租约后用同事件身份重发 |
broker 成功后、SENT 提交前 | 消息已存在,本地仍可能待恢复 | 接受重发,下游 Inbox 去重 |
SENT 提交后退出无需再发,但仍要保存对账映射与保留窗口。反过来“先 SENT 再发”会在中间退出时永久跳过未发事件,是丢失协议;先删除 PENDING 再发同样丢恢复意图。仅在内存记“正在发送”也不能跨重启恢复身份。
批量发送时,部分 item 成功、部分失败、部分未知是合法结果,不能用一个 batch 成功标记全体 SENT。清理应在确认、对账与保留期均满足后执行;清理失败一般增加成本,清理过早却会丢掉恢复依据。结果未知的重试继续使用原意图,不得修改 payload 或悄悄生成另一个事件身份。
Kafka 事务解决的是另一个原子域
Kafka 事务可以将输入 Kafka 消费位点与输出 Kafka 记录放进同一事务,消费者须使用相应隔离语义。客户端 API、producer 任期、协调器 marker 和 read_committed/LSO 的实现集中在Kafka 事务权威章节。
这不能替代订单数据库的 Outbox:数据库已提交、Kafka 事务中止,订单不会自动回滚;Kafka 已提交、数据库失败,消息也不会被本地回滚撤回。支付接口、邮件、任意外部数据库同样不在 Kafka 原子域内。“EOS”必须说出输入、状态、输出及失败恢复的范围,不能从两个 Kafka 资源推到全业务链恰好一次。
CDC、事务消息与长流程的选择
| 方案 | 协调的状态 | 仍须治理什么 |
|---|---|---|
| 轮询 Outbox | 业务与意图本地同提交,relay 领取重试 | 扫描索引、重复、意图年龄、归档 |
| CDC 读取 Outbox | 从已提交变更日志恢复发布意图 | binlog 保留、连接器位点、schema、快照切换与重复 |
| 事务消息 | 半消息与持久本地决议,经确认或回查决定可见 | 结果查询身份、回查期限、下游幂等 |
| XA/2PC | 支持协议的资源与事务管理器共同裁决 | prepare 后持锁、决议日志、恢复与可用性 |
| Saga/TCC | 多次本地提交及显式补偿或预留/确认/取消 | 不可逆效果、补偿失败、竞争与悬挂 |
CDC 不等于无需事件身份:恢复位点与发送确认仍会重复。直接将所有业务表行变化发布,会将内部 schema 泄漏给下游,行变化也未必表达业务意图。Kafka 位点与数据库 binlog 位点属于不同进度,不能只监控一个数就判断一致性。
RocketMQ 事务消息在此只按官方事务消息机制做协议对照:先半消息,再执行本地事务,随后提交/回滚;broker 可回查本地结果。应用必须持久记录并可按稳定身份查询,回查不是 broker 替你重做数据库事务,也不等于消费一次。这里没有固定 RocketMQ 实现版本,不标成原码解析;部署时必须固定实际版本及其恢复条件。
XA 在参与资源及故障模型内提供共同决议,代价是准备态锁定和恢复等待;Outbox 只发布本地已提交事实,不能自己解决跨服务不变量。Saga 的退款是新的业务动作,不是时间倒流,退款也可能失败。TCC 的空回滚、防悬挂、迟到确认以及补偿不可逆的具体状态见跨资源事务与长流程;Seata 2.5.0 的组件实现与实验见Seata:AT、TCC、XA 与 TC 恢复,其中 MySQL 8.4.7 实验条件下 XA prepare 期间其他连接读旧值、写入等待;fence 墓碑与 undo 补偿各有独立测试条件。
可运行推演:观察持久事实而非 relay 日志
完整下载入口见中间件实验。在仓库根运行:
python3 labs/middleware-senior/reliability_lab.py outbox --case all --json
python3 labs/middleware-senior/reliability_lab.py outbox --case rollback --json
python3 labs/middleware-senior/reliability_lab.py outbox --case committed --json
python3 labs/middleware-senior/reliability_lab.py outbox --case send-before-mark --json
python3 labs/middleware-senior/reliability_lab.py outbox --case mark-before-send --json
python3 labs/middleware-senior/reliability_lab.py outbox --case stale-claim --json
python3 labs/middleware-senior/reliability_lab.py outbox --case batch --json输入订单 7、事件 e-7-1,本地回滚时订单与意图都不存在;提交后重开连接仍能看到待发意图。发送成功但标记前退出,再恢复重发,预期同一事件有两次交付而 Inbox 保护效果一次。mark-before-send 故意展示已标成功但零交付的反例;stale-claim 展示 A 保留自己的旧令牌、B 接管后拒绝旧更新,但两次发送仍可能存在;batch 验证只确认成功项,失败项继续可恢复。
脚本使用真实临时 SQLite 文件及本地事务,外部接收端是独立文件模型,领取采用 SQLite 的事务能力;它没有启动 Kafka/CDC,也不模拟 MySQL 的 SKIP LOCKED 实现。本轮已运行上述公开命令及对应断言;未运行完整 Kafka/MySQL 联调。
MySQL 补充实验应使用专用 schema:两会话同时领取,记录 A 的 claim_id/version;让租约过期后 B 接管,再以 A 旧值标 SENT,预期零更新。Kafka 联调还需隔离 topic,真实发送同 event_id 两次,消费者同事务写 Inbox 和投影,核对最终效果一次。未部署并执行这些依赖前,这些只是预期步骤。
固定水位对账与容量变化
在固定业务水位上,对每个已提交订单按优先级归入互斥类别:无意图异常、已知业务失败、消费成功、已发布待业务终态、尚未发布。分类依据需使用稳定 operation_id/event_id,保存业务回执或提供可查终态;不能将连续增长中的订单数与较早消费快照直接相减。分类之和应覆盖水位内的全部订单,未知或跨快照不确定项必须显式列出,不能当成功。
SENT 仅证明发送侧拿到确认,不证明消费成功;broker lag 为零也可能是位点提前、消息进了 DLQ 或业务外部调用仍 UNKNOWN。同时观察意图最大年龄、未知敞口、对账差异、修复时长与重复效果,比只看 relay 进程存活更接近业务目标。
每秒 100 变成 10 万事件后,轮询索引、状态更新与归档会成为成本;按聚合分片、批量领取与 CDC 应分别压测。10 万/秒 × 10 KB × 86400 秒,约 86.4 TB/天十进制原始载荷,还没计索引、副本及协议。先核对平均/尾部大小与实际事件率,再讨论外置 payload 和保留期。
跨地域本地写时,单库事务不能覆盖两个地域的意图唯一性,需明确主写聚合、全局身份、冲突及灾备回放。资金必须立即可用时,30 秒最终一致可能超出风险预算,可能需要同步授权或预留,后续事件只做通知。选型结论应随延迟、敞口与恢复要求重新推导。
闭卷验收
本页 5 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。