title: 分布式事务选型:从 2PC 到 Outbox,我为什么选了最朴素的一个 date: 2026-10-14 tags: [分布式事务, 架构, RabbitMQ]
分布式事务选型:从 2PC 到 Outbox,我为什么选了最朴素的一个
单据审核成功后,下游的记账、库存、打印要跟着动。这些下游动作部分经由 RabbitMQ 通知,于是有一个绕不开的问题:审核落库和”通知下游”这两件事,怎么保证要么都发生、要么都不发生? 评估了一圈教科书方案,最后我选了最不起眼的本地消息表。这篇把推导过程写清楚——比结论本身更值得复用。
逐个过一遍候选方案
2PC / 3PC:协调者先 prepare(参与者锁住资源应答),再统一 commit/rollback。三宗罪:prepare 后参与者持锁干等,同步阻塞;协调者是单点,它宕在 commit 前后就是脑裂;二阶段指令部分到达时数据不一致。3PC 加了 canCommit/preCommit 和参与者超时自决,缓解了阻塞,但网络分区下超时自决依旧可能选错,工程里很少真用。
TCC(Try-Confirm-Cancel):业务层面的两阶段,每个参与方写三个接口。难的不是框架,是三个经典坑:空回滚——Try 因网络没到达,Cancel 先来了,得靠事务控制表知道”Try 没执行过”从而空转返回成功;悬挂——Cancel 执行完,迟到的 Try 才到,预留资源从此没人释放,得让 Try 先查回滚记录、存在即拒绝;幂等——Confirm/Cancel 都会被重试,状态表防重复执行。三个坑每一个都要存储支撑,等于把复杂度从框架挪回了业务代码。
Saga:长事务拆成一串本地事务加逆向补偿,放弃隔离性。编排式(中心协调器驱动状态机)集中可控、易观测;协同式(事件驱动)松耦合但流程散落各处、排障像拼图。补偿本身还要处理脏读、语义锁这些隔离性缺失的衍生问题。
RocketMQ 事务消息:half 消息先投出去对消费者不可见,本地事务提交后再确认投递,宕机靠回查接口兜底。机制优雅,但能力绑定在 RocketMQ 上——换 MQ 就丢;而且回查逻辑照样要自己写,本质还是”扫未完成态”,只是换了个执行者。
Seata AT:代理数据源拦截 SQL,一阶段在本地事务里写前后镜像 undo_log 即提交,二阶段提交是异步批量删日志、回滚按镜像反向补偿。它释放的是本地锁,拿的是表+主键粒度的全局锁,还有镜像存储和 TC 的运维成本——是为跨库跨服务准备的重量级方案。
我的推导
我的系统当时是单体多模块:事务边界完整地落在一个 MySQL 库里,MQ 只是出站通知,下游业务(记账、打印)对秒级延迟不敏感,最终一致可接受。把这些条件摆出来,答案其实已经浮出来了:
- 2PC/TCC/Saga/Seata 解决的是跨库、跨服务的强一致或长流程——我没有这个问题,引入它们等于把简单问题复杂化;
- 事务消息把一致性押在特定 MQ 的可用性与回查实现上,而我已有 RabbitMQ 在跑;
- 剩下的本地消息表(Outbox):业务与消息同库同事务写入,轮询投递——零新增组件,消息必达与业务成功严格原子,代价只是秒级投递延迟和业务库多一张表。
闭环不只在生产端
选 Outbox 只解决了”消息必发”,端到端的一致还需要消费侧配合:至少一次投递 + 消费端幂等,才是工程上唯一可行的组合。我的消费端三层防线:手动 ack(处理完才确认)、幂等表(重复消息先查后写,库内唯一键做最终兜底)、死信队列(处理不了的消息 reject 不 requeue,转人工)。生产端的指数退避与毒消息分类在上一篇 Outbox 实现篇里展开过,这里不重复。
后来渠道支付事件也需要同样的保证,Outbox 模式被第二个轮询器原样复用——一个方案的最好佐证,是它被第二次使用时的成本几乎为零。
收尾一句话
一致性方案的复杂度,应该匹配事务边界实际跨越的距离:边界在一个库里时,一张消息表胜过一套协调框架。