@Transactional 在单库内靠数据库本地事务保证原子性。业务一旦跨两个库,本地事务立刻失效——没有任何一个数据库能替你管另一个库的 COMMIT。分布式事务要解决的就是:在没有跨库原子提交的前提下,让多个数据修改要么都算生效、要么都能退回"未生效"。
1. 两个无法消除的事实
事实一:没有跨库的原子提交。提交只对当前实例生效,跨实例只能靠协调者统一裁决(XA),或放弃原子性改做"本地提交 + 事后补偿"。
事实二:超时不等于失败。调用方等 3 秒没响应抛 SocketTimeoutException,对方却可能已提交成功,只是响应包丢在回程。
所有方案都在回答同一个问题:结果未知时,凭什么认定最终状态是对的。答案只有两种——留一份可查的裁决记录(控制表、事务消息、对账),或者允许人工介入。
2. XA/2PC:机制清楚,代价也清楚
XA 由 TM 驱动多个 RM(通常是数据库)分两阶段完成:
| 阶段 | 动作 | 失败会怎样 |
|---|---|---|
| 一阶段 prepare | 各 RM 执行并锁资源、写 undo/redo | 任一失败则全局回滚,代价小 |
| 二阶段 commit | 全部 prepare 成功后广播 commit |
RM 逐个提交,此阶段不可回滚 |
| 二阶段 rollback | 任一 prepare 失败则广播回滚 | 已 prepare 的 RM 释放锁 |
spring:
datasource:
jta.enabled: true # 每个库一个 XADataSource
orders:
url: jdbc:mysql://db1:3306/orders
driver-class-name: com.mysql.cj.jdbc.MysqlXADataSource
三个代价:
同步阻塞。prepare 后涉及的行全被锁,持锁时长等于"最慢那个 RM 的响应时间",跨机房 RTT 直接乘进时长。
协调者单点。TM 挂掉后处于 prepared 的 RM 全部悬空,只能等恢复或人工裁决——MySQL 用 XA RECOVER 列出 in-doubt 事务。
提交阶段的失败窗口。二阶段部分 RM 成功、部分失败后无回退路径,只能重试或人工补数。
结论:XA 只适合同机房、低并发、跨库少的内部系统。不是不会配,是不敢背锁与单点。
3. TCC:把两阶段挪进业务
TCC 的假设是业务自己知道怎么"预占资源":
| 方法 | 语义 | 必须满足 |
|---|---|---|
try |
检查并预占(冻结余额、锁库存、占券) | 只预占,不实扣 |
confirm |
把预占变成实扣 | 幂等,可重复调用 |
cancel |
释放预占 | 幂等 + 允许空回滚 |
public interface OrderTccAction {
@TwoPhaseBusinessAction(name = "orderTcc", commitMethod = "confirm", rollbackMethod = "cancel")
boolean tryCreate(@BusinessActionContextParameter(paramName = "orderNo") String orderNo,
BigDecimal amount);
boolean confirm(BusinessActionContext ctx);
boolean cancel(BusinessActionContext ctx);
}
TCC 的本质是把隔离性降级成业务可见的中间态:try 之后用户看到"可用余额减少、冻结金额增加",前端必须展示冻结字段。
4. TCC 的三个坑与一张控制表
三个坑共用一个解法:事务控制表记录每个分支状态。
CREATE TABLE tcc_branch_log (
xid VARCHAR(64) NOT NULL,
branch_id VARCHAR(64) NOT NULL,
status TINYINT NOT NULL COMMENT '1=TRIED 2=CONFIRMED 3=CANCELLED',
PRIMARY KEY (xid, branch_id)
) ENGINE=InnoDB;
| 坑 | 触发场景 | 表现 | 防护 |
|---|---|---|---|
| 空回滚 | try 没到(丢包/超时),协调者却发 cancel | 释放一个从未冻结的余额 | cancel 先查表:无 TRIED 记录则直接返回成功 |
| 幂等 | 网络重试、协调者重复下发 | 重复 confirm 扣两次 | (xid, branch_id) 唯一键 + 条件更新 |
| 悬挂 | cancel 先到、try 后到 | 预占永不释放 | try 执行前查表,已 CANCELLED 则拒绝本次 try |
防重复的核心是一条 UPDATE 定胜负,不要"先查后改":
int rows = jdbc.update("""
UPDATE tcc_branch_log SET status = ?
WHERE xid = ? AND branch_id = ? AND status = 1
""", STATUS_CONFIRMED, xid, branchId);
if (rows == 0) return true; // 已确认或已取消,重复调用直接成功
5. Saga:长流程的正向与反向
TCC 适合"资源可预占"(钱、库存、券),短板是参与方都要写三个方法。Saga 换思路:每步本地提交,失败用补偿抵消已提交步骤。
| 形态 | 谁决定下一步 | 优点 | 代价 |
|---|---|---|---|
| 编排 Orchestration | 中心协调器按状态机驱动 | 流程可见、易调试 | 协调器成为新中心 |
| 编制 Choreography | 各服务订阅事件自行推进 | 无中心、松耦合 | 流程散落,难回溯 |
SagaDefinition<OrderContext> saga = step()
.step().invokeParticipant(this::reserveStock, this::releaseStock)
.step().invokeParticipant(this::freezeBalance, this::unfreezeBalance)
.step().invokeParticipant(this::createShipping, this::cancelShipping)
.withCompensation(this::markOrderFailed)
.build();
补偿本身失败怎么办是 Saga 最容易被忽略处:补偿可重试但要有上限;超限不放弃,转"挂起待人工"并告警;补偿尽量天然可逆(改状态、反向流水),不可逆动作排链尾。
6. 本地消息表与最大努力通知
不要求立刻一致时,成本最低的两条路。本地消息表把"要发的消息"与业务数据写进同一事务,再由独立线程搬运:
CREATE TABLE outbox_msg (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
topic VARCHAR(64) NOT NULL,
biz_key VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status TINYINT NOT NULL DEFAULT 0 COMMENT '0=待发 1=已发',
UNIQUE KEY uk_biz (topic, biz_key)
) ENGINE=InnoDB;
搬运用 FOR UPDATE SKIP LOCKED 抓一批待发行,发送后按 id 标记已发,失败退避重排。发送与改状态无法原子,"先发后改"会重复、"先改后发"会丢,必须靠消费端幂等兜底。
最大努力通知:上游反复回调、下游自己来查,适合支付结果通知商户——通知不是合同,查询接口才是。姿势是通知 + 主动查询 + 对账。
7. 可靠消息最终一致的完整链路
@Transactional // 业务落库与发半消息同一本地事务
public void createOrder(OrderCmd cmd) {
orderMapper.insert(build(cmd));
rocketMQTemplate.sendMessageInTransaction("order-created", buildMsg(cmd), cmd);
}
@RocketMQMessageListener(topic = "order-created", consumerGroup = "inventory-cg")
public class StockConsumer implements RocketMQListener<OrderCreatedMsg> {
@Transactional public void onMessage(OrderCreatedMsg msg) {
if (!dedupMapper.tryInsert(msg.getMsgKey())) return; // 唯一键去重
stockService.deduct(msg.getSkuId(), msg.getQty());
}
}
| 环节 | 作用 | 缺失后果 |
|---|---|---|
| 本地事务 + 事务消息 | 业务成功则消息一定发得出 | 消息丢,下游永不触发 |
| 消费端幂等键 | 抵消重投 | 重复扣库存 |
| 重试 + 死信 + 对账 | 最终被处理、兜住漏网 | 静默丢失或永久不一致 |
对账最容易被砍,却是唯一能在"各方都以为自己成功"时发现问题的机制:
-- 已支付但库存未扣:每分钟扫最近一小时,留 5 分钟延迟窗口
SELECT o.order_no FROM t_order o
LEFT JOIN t_stock_deduct d ON d.order_no = o.order_no
WHERE o.status = 'PAID' AND d.order_no IS NULL
AND o.gmt_modified BETWEEN NOW() - INTERVAL 1 HOUR AND NOW() - INTERVAL 5 MINUTE;
8. 四种方案对照
| 维度 | XA/2PC | TCC | Saga | 可靠消息 |
|---|---|---|---|---|
| 一致性强度 | 强(提交后本地可读) | 最终,中间态可见 | 最终,中间态可见 | 最终,延迟最大 |
| 侵入性 | 低(改配置) | 高(三方法+控制表) | 中(每步写补偿) | 中(outbox+幂等) |
| 性能 | 差(锁 + 多轮 RPC) | 好(两轮短事务) | 好(本地事务串行) | 好(异步) |
| 运维复杂度 | 中(in-doubt 人工) | 高(悬挂排查) | 中高(补偿挂起) | 中(堆积与对账) |
| 典型场景 | 同机房结算 | 支付、库存、券 | 长流程履约 | 订单→积分/通知 |
9. 选型判据:先问"写几个库"
跨库写且必须同时成功 → XA(同机房低并发)或 TCC(高并发、可预占);跨服务编排且步骤多、有外部系统 → Saga,优先编排式;只需通知下游 → 本地消息表或事务消息,配幂等 + 对账。反向判据:两个操作其实同库,就不该上分布式事务。
10. 常见坑
- 以为 cancel 一定执行业务释放。空回滚下正解是"确认无事可做并返回成功",强行释放反而造资损。
- 补偿写成"反向操作"却不可逆。退款、发券可反,发短信、调第三方开户不可反。
- 没有对账。机制都依赖"各方都成功",某一方悄悄失败只有对账能发现。
- 强一致硬套最终一致。支付后立刻查订单必须已支付,读己之写给不了。
- 控制表与业务表分开事务。状态与实际不符,后续补偿判断全错。
11. 排错:五种典型不一致
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 提交后下游没收到 | 未实现消息回查,或发送失败被吞 | 实现 checkLocalTransaction + outbox |
| 库存被扣两次 | 消费端无幂等键 | 加 msgKey 唯一索引 |
| 冻结余额永不释放 | 悬挂:cancel 先到、try 后到 | try 前查表,已 CANCELLED 则拒绝 |
| 对账有差异但无报错 | 补偿吞异常、日志级别过高 | 补偿失败必须抛错并落"待人工"表 |
| XA 长期 in-doubt | TM 崩溃或提交时断网 | XA RECOVER 后人工 commit/rollback |
12. 边界:这些情况别硬上
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 同实例不同库 | 合并到一个库或用多表 | 本地事务够用,别引入协调者 |
| 只需通知不需回滚 | 消息 + 幂等 + 对账 | 无需补偿就无需事务 |
| 跨组织资源 | Saga + 人工兜底 | 对方库不受你控制 |
| 离线批处理 | 幂等重跑 + 覆盖写 | 天然可重放 |
一句话收束:能用本地事务就别拆库,能异步就别同步,能补偿就别 prepare,补偿一定要配对账。
13. 自测
- 为什么"调用超时"比"调用失败"更难处理?
- XA 的提交阶段为什么被称为不可回滚的窗口?出现 in-doubt 怎么定位?
- TCC 的 cancel 遇到空回滚时该做什么、不该做什么?
- 悬挂的成因是什么,"先查控制表"为什么能防住它?
- Saga 补偿自身失败且重试超限后,正确的处理动作是什么?
- 可靠消息链路里哪一环是最后的安全网,为什么不能省?