想象这个场景:凌晨两点报警响起——下单服务成功扣了库存,但订单服务超时了,积分也没加上。用户没买到东西,库存却少了。系统拆成多个服务多个库之后,这种"一半成功一半失败"的事故就注定会来:单个数据库里靠事务就能原子提交,拆开了,@Transactional 就管不到了。
这就是分布式事务要解决的问题。这篇按演进顺序把方案捋一遍:本地消息表 → 事务消息 → TCC → Seata AT。每一版解决什么、代价是什么,最后给你一张选型表。代码按 Spring Boot 落地。
一、问题先讲清楚:跨库事务为什么难
下单链路有四个参与方:订单库、库存库、用户余额库、积分服务。理想状态是"要么全成功、要么全失败":
| 参与方 | 做什么 | 失败影响 |
|---|---|---|
| 订单服务 | 写订单表 | 用户没订单 |
| 库存服务 | 扣库存 | 超卖 |
| 余额服务 | 扣钱 | 多扣/少扣 |
| 积分服务 | 加积分 | 用户少拿积分 |
如果还挤在一个库里,一个 @Transactional 包住就完事。拆开后问题来了:分布式系统里没有全局事务管理器,没法让四个库同时提交/回滚。业界把解决方案分成两类,理解这个分类比背方案重要:
- 强一致(XA/TCC):提交时所有参与方一起成功或一起失败,代价是性能、复杂度
- 最终一致(本地消息表/事务消息/SAGA):先保证主业务成功,副业务异步补做,失败靠重试兜底,代价是一段时间内数据不一致
电商下单这种链路,90% 场景用最终一致就够了——用户等 1 秒看到积分到账,完全能接受。
二、方案 v1:本地消息表(最终一致的教科书做法)
思路一句话:把"要发给别人的消息"和"自己的业务"放在同一个本地事务里,消息落库,别人异步来取。
核心表结构:
CREATE TABLE `t_local_message` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型',
`biz_no` VARCHAR(64) NOT NULL COMMENT '业务单号',
`payload` TEXT NOT NULL COMMENT '消息内容(JSON)',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=待发送 1=已发送 2=发送失败',
`retry_count` INT NOT NULL DEFAULT 0,
`next_retry` DATETIME NOT NULL COMMENT '下次重试时间',
`create_time` DATETIME NOT NULL,
UNIQUE KEY `uk_biz` (`biz_type`, `biz_no`)
) COMMENT '本地消息表';关键代码:订单和消息同事务:
@Transactional
public void createOrder(CreateOrderCmd cmd) {
// 1. 写订单 —— 和消息表同一个本地事务
orderMapper.insert(buildOrder(cmd));
// 2. 写消息 —— 状态待发送,事务提交后由定时任务/队列投递
localMessageMapper.insert(LocalMessage.builder()
.bizType("ORDER_CREATED")
.bizNo(cmd.getOrderNo())
.payload(JsonUtils.toJson(cmd))
.status(0)
.build());
}// 定时重试:捞"待发送 + 到重试时间"的消息,投递成功后改状态
@Scheduled(fixedDelay = 30_000)
public void resend() {
List<LocalMessage> pending = localMessageMapper.selectPending(30);
for (LocalMessage msg : pending) {
try {
mqTemplate.send(msg.getBizType(), msg.getPayload());
localMessageMapper.markSent(msg.getId()); // 成功才改状态
} catch (Exception e) {
localMessageMapper.bumpRetry(msg.getId()); // 失败累计重试次数
}
}
}为什么说它可靠:订单和消息在同一个本地事务里,要么都成、要么都败——不可能出现"订单成功了但消息没落库"。下游没消费,消息就一直躺在表里,定时任务重试,直到成功为止。这就是"最终一致"的保证来源。
代价:
- 消息表是额外的状态,得自己维护"待发送/已发送/重试"的生命周期
- 需要定时任务扫表,实时性差(秒级到分钟级)
- 消息积压时,
selectPending要控制好扫描范围,别全表扫
三、方案 v2:事务消息(RocketMQ 把本地消息表藏进中间件)
本地消息表方案,麻烦在要自己维护表状态。RocketMQ 的事务消息把这套逻辑搬进 MQ 内部:
- 发送方先发一条「半消息」(half message),MQ 存着但不投递
- 发送方执行本地事务
- 本地事务成功 → 提交半消息(MQ 开始投递);失败 → 回滚半消息(不投递)
- 如果 MQ 一直没收到提交/回滚决定 → 反向询问发送方(check 回查),发送方查本地事务结果再决定
@Transactional
public void createOrder(CreateOrderCmd cmd) {
// 本地业务先执行(还没提交)
orderMapper.insert(buildOrder(cmd));
// 事务消息:本地事务提交后,MQ 才真正投递
TransactionSendResult result = rocketMQTemplate.sendMessageInTransaction(
"order_tx_topic", payload, cmd.getOrderNo());
}// 事务回查实现:MQ 不知道本地事务结果时问过来
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
String orderNo = msg.getUserProperty("orderNo");
// 订单存在 = 本地事务已提交 → 消息该投递
return orderMapper.exists(orderNo)
? LocalTransactionState.COMMIT_MESSAGE
: LocalTransactionState.ROLLBACK_MESSAGE;
}对比本地消息表:
| 对比点 | 本地消息表 | RocketMQ 事务消息 |
|---|---|---|
| 状态管理 | 自己建表维护 | MQ 内部处理 |
| 实时性 | 定时扫表,秒~分钟级 | 提交即投递,毫秒级 |
| 事务回查 | 无(靠重试) | 内置 check 机制 |
| 复杂度 | 低(就一张表 + 定时任务) | 中(要理解半消息/回查语义) |
注意:RabbitMQ 原生不支持事务消息(有
txSelect但那不是半消息语义,别混)。如果中间件是 RabbitMQ/Kafka,就用本地消息表方案,或者让下游自己幂等(配合《接口幂等与防重提交》)。
四、方案 v3:TCC(Try-Confirm-Cancel,手动补偿)
最终一致方案的问题是:下游可能一直失败(积分服务挂了),订单状态就永远差一口气。有些场景(比如扣钱)不能容忍"对不上账",需要两阶段确认——这就是 TCC。
TCC 把一个操作拆成三步:
| 阶段 | 干什么 | 例子(扣余额) |
|---|---|---|
| Try | 检查资源 + 冻结/预留 | 检查余额够不够,冻结 100 元 |
| Confirm | 确认执行(用预留的资源) | 把冻结的 100 元真正扣掉 |
| Cancel | 回滚(释放预留) | 解除冻结,余额恢复 |
// 扣余额服务实现 TCC 三接口
public boolean tryDeduct(Long userId, BigDecimal amount) {
// 检查余额 + 冻结(不动真实余额)
return accountService.freeze(userId, amount);
}
public boolean confirmDeduct(Long userId, BigDecimal amount) {
// 真正扣款:把冻结转成扣款
return accountService.confirmFreeze(userId, amount);
}
public boolean cancelDeduct(Long userId, BigDecimal amount) {
// 解冻
return accountService.unfreeze(userId, amount);
}关键点:
- Try 里只冻结不真扣,这样 Confirm 一定成功(资源已预留)
- Confirm/Cancel 必须幂等(框架可能重试)
- 冻结要有超时释放(用户取消下单,冻结余额要能自动解)
代价:每个服务都要写三个接口,业务侵入极大;冻结/解冻的账务处理复杂。能用最终一致解决的场景,别轻易上 TCC。
五、方案 v4:Seata AT(框架替你干脏活)
Seata AT 模式是 TCC 的"自动化版本":业务代码几乎不用改,框架通过代理拦截 SQL,自动记录数据快照(undo_log),出错时自动反向生成补偿 SQL 回滚。
// 业务代码:看起来就是普通事务,Seata 在背后记录 undo_log
@GlobalTransactional
public void createOrder(CreateOrderCmd cmd) {
orderService.insert(cmd); // 框架记录订单表快照
stockService.deduct(cmd); // 框架记录库存表快照
accountService.deduct(cmd); // 框架记录余额表快照
}- 下单链路每个服务配
@GlobalTransactional(全局事务入口) - 各服务数据源接 Seata 的
undo_log表 - 任一服务失败 → 全局事务回滚 → 每个服务执行反向 SQL 还原
对比一览:
| 方案 | 一致性 | 业务侵入 | 实时性 | 适用 |
|---|---|---|---|---|
| 本地消息表 | 最终 | 低 | 秒~分钟 | 大多数业务 |
| 事务消息 | 最终 | 中 | 毫秒 | RocketMQ 场景 |
| TCC | 强(两阶段) | 高 | 毫秒 | 资金类强一致 |
| Seata AT | 强 | 低 | 毫秒 | 单体→微服务过渡 |
六、我的选型结论
回到开头的下单链路,我最后用的组合是:
- 下单主链路:本地消息表(订单 + 消息同事务),消息给库存/积分异步消费——订单成功即保证消息必达
- 消费方幂等:每个消费者用业务单号做消费幂等键,重投不重复处理
- 资金类强一致(真扣钱的场景)才上 Seata AT,且只圈住必要的几个服务
一个核心认知:分布式事务没有银弹。强一致方案(TCC/Seata)保证的是"全成或全败",但性能和侵入成本高;最终一致方案牺牲"即时一致性",换来简单可靠。先问业务能不能接受最终一致——绝大多数能。
想深入了解消息侧的基础设施,看《RabbitMQ 快速入门》《Kafka 快速入门》和《AMQP 协议详解》;消费幂等的完整做法见《接口幂等与防重提交》。
验证记录:本地消息表的核心语义("订单和消息同一本地事务、消息靠定时任务重试直到成功")依赖 Spring + MySQL 完整环境,文中已按标准语义静态核对(
@Transactional同事务提交、selectPending条件扫描均为 Spring/MyBatis 标准 API)。TCC 的"冻结/确认/取消"三步语义与 Seata 的 undo_log 机制为框架标准行为,标注为静态核对,未实跑。
