合同提交成功以后,发布进程可能尚未发送消息就退出。接收方已经记账以后,也可能尚未确认消息就退出。前一种情况可能漏发,后一种情况会引发重复投递。只在正常流程里调用一次发送函数,无法说明这两个故障窗口怎样恢复。

本实验把业务、待发记录、传输队列和接收方结果分别写入真实文件数据库。业务事务原子写合同与 Outbox,独立发布 JVM 把消息写进持久 SQL 队列,独立消费 JVM 用 Inbox 与记账更新组成另一个事务。驱动在三个已提交位置调用进程级强制退出,再启动新进程读取和恢复,每一步保留实际 SQL 与退出码。

同一事务能够解决的范围

Transactional Outbox 模式要求业务更新与消息记录在同一个数据库事务中持久化,再由另外的发布器发送。这样避免了“业务已提交但没有任何待发依据”的空隙。发布器本身仍可能重复发送,因此消费者需要幂等处理。Transactional Outbox 原文

本例 sender 数据库中有合同表与 Outbox 表。合同编号 R1 对应整数金额二十五,Outbox 的消息编号 M1 携带相同金额。一次事务插入两条记录并提交,然后进程立即退出。恢复进程断言合同数是一、未发送 Outbox 数也是一,传输队列仍为空。这个状态说明消息尚未发送,却有可继续执行的持久工作。

金额在此是固定教学单位的整数,没有实现完整币种与计费模型。消息只有编号和金额两个业务字段,传输队列另外添加投递编号与确认状态。前面冻结租赁基线没有被更改,本章关注的是一次已确定副作用的传递,不把这个整数示例称为完整租赁账务系统。

发送与发送确认之间

发布进程查询尚未发送的 Outbox,把消息写入另一份 H2 文件中的持久队列并提交。随后它才把发送方记录标记为已发送。两个数据库不在同一事务中,因此中间存在明确窗口:队列已经有消息,发送方仍显示待发。实验在这个窗口强制终止 JVM,恢复查询确认该状态确实存在。

再次启动发布进程时,它会重新发送同一条消息,队列里因此出现两条物理投递记录。两条记录有不同的投递编号,但相同的业务消息编号 M1。发送方随后标记完成。把投递编号和业务消息编号分开,是理解去重范围的关键:传输发生了两次,业务事实仍然只有一次。

不能在真正发送之前标记 Outbox 已完成。若先标记,再在发送途中失败,后续扫描不会再找到这条记录,消息就可能永久遗漏。相反,发送后标记允许重复,但可以通过稳定消息编号在接收方消除重复业务效果。这种选择接受了重复投递成本,并没有消除网络和进程故障。

本例传输层是有真实磁盘状态的 SQL spool,不是 Kafka、JMS 或其他消息代理,也没有声称验证代理的持久订阅、事务确认和故障转移。它用三个独立数据库明确展示确认窗口。下一步替换消息运行时时,必须按该运行时的发送完成和消费确认语义重新做故障注入,不能沿用 SQL 队列结果推断代理保证。

Inbox 与业务更新一起提交

消费者读取一条未确认投递,先按业务消息编号查询 Inbox。如果尚未处理,它在同一个接收方事务中插入 Inbox,再把账本增加二十五并提交。如果已经处理且载荷一致,则返回已有结果,不再增加金额。Idempotent Consumer 模式的核心就在于把已处理消息的判定与业务事务组合起来。Idempotent Consumer 原文

实验在 Inbox 和账本提交以后、传输队列确认以前退出。新进程读回接收方时,Inbox 已有一条,账本已经是二十五;队列仍有两条未确认投递。恢复消费会再次遇到第一条,再遇到发布重试产生的第二条,二者都匹配同一个 Inbox,因此都不会追加金额。最终两条投递已确认,账本仍为二十五。

如果只把 Inbox 放在内存集合里,进程退出后集合就会丢失,恢复消费会再次记账。如果先提交 Inbox,再用另一个事务更新账本,中间失败又可能让后续重试认为已经处理而永远不记账。检查“重复请求返回成功”不足以发现这两类错误,必须检查接收方持久状态和事务边界。

本例只启动一个消费者,顺序读取队列。并发消费者同时查询不存在的 Inbox 时,还需要依靠唯一约束竞争、事务回滚和重新读取决定谁已成功处理。本实验没有做这项并发消费测试,所以结果只覆盖顺序重投递与跨进程恢复;不能据此宣称任意并发下的消费幂等都已验证。

相同编号不同载荷

同一个幂等键并不意味着所有后续请求都可以忽略。若 M1 原来表示二十五,后来又被用于九十九,系统需要区分重试和身份冲突。实验在最终恢复后故意调用这种冲突载荷,消费进程必须以非零退出,错误信息包含载荷冲突,随后再启动验证进程确认账本仍为二十五。

实际载荷可能包含多字段、浮点表达差异和不同序列化顺序。可以保存规范化后的关键字段,或保存规范化载荷的摘要再比较,但规范化规则本身必须稳定。直接对任意 JSON 原始字节求摘要,会把字段顺序不同而语义相同的请求当成不同输入。反过来只比较编号,又可能掩盖错误复用。

幂等记录还需要保留策略。Inbox 一旦删除,旧消息再次出现就可能重新产生副作用;保留得无限长,又会增加存储和索引成本。应结合消息最长重投递周期、上游补偿方式和业务数据生命周期决定清理时间。这里的实验不执行清理,因此验证的是记录仍保留时的去重,不涵盖跨清理窗口的重复。

故障注入怎样发生

三个故障点都位于真实 commit 返回以后,使用 Runtime.halt(73) 直接终止 JVM。驱动期望退出码恰好是七十三,并检查输出中的故障位置,再继续启动恢复进程。H2 连接使用零写延迟配置,以减少缓存写入对本机进程退出实验的干扰;该实验仍没有模拟主机断电或磁盘控制器故障。

业务退出场景的原始文件是 business-crash.stdout.txt,发布退出是 publish-crash.stdout.txt,消费退出是 consume-crash.stdout.txt。各自后面的读回进程保存 after-business、after-publish 和 after-consume 日志。故障位置、实际退出码和读回状态三者应同时成立,单独看某条“崩溃已模拟”的字符串不能证明恢复路径运行过。

最后一个验证 JVM 同时读取发送方、队列和接收方。判定要求发送完成记录一条、已确认物理投递两条、Inbox 一条、账本金额二十五。这个组合故意保留重复传输的事实,避免用业务去重成功掩盖发生过两次发送。日志中的绑定参数还能把 M1 从 Outbox 一直追踪到 Inbox。

独立运行

下载本章累计源码 · 校验清单

进入解压目录,设置 Java 21 后执行 bash run-lab.sh 14。Python 驱动编译源码,解析固定 H2 2.1.214,再为每个阶段启动新的 JVM。三个故障阶段的非零退出都是预期结果,载荷冲突也必须失败;只有恢复断言全部通过,最外层入口才返回零。不要把日志中的非零子进程直接当成实验整体失败。

processes.json 保存实际命令、期望退出码和捕获文件名;database-files.json 保存临时文件数据库的大小与摘要。数据库在驱动结束后清理,原始 SQL、提交、退出和新进程读回输出留下。源码中的故障点可直接定位,读者可以把它移到提交前,再观察后续断言为何不再成立。

新增业务字段时,要同时修改 Outbox 载荷、队列持久格式和 Inbox 冲突比较;只修改发送端会使接收端缺乏足够的信息判断重复。新增消费者实例时,要补充分区或领取机制,并重新验证唯一约束冲突。当前发布器也没有多实例争抢、退避、死信和监控,不能直接作为生产消息框架使用。

局部一次效果的边界

最终金额只增加一次,是接收方数据库内的业务效果约束。物理消息已经发送两次,发送方与接收方也没有一个跨系统原子提交点。因此结果支持“持久重试下的局部幂等”,不支持“全局恰好一次”。区分这两句话,会直接影响重试、告警和运维补偿的设计。

如果副作用是调用外部支付或发送短信,Inbox 与本地账本事务无法包住外部系统。外部接口还需要稳定幂等键、状态查询或补偿协议,否则本地已经去重不等于远端没有重复动作。Outbox 解决待发事实的保存,Inbox 解决接收方本地重复效果;它们的保证都止于各自可控制的事务边界。

参考资料