学完这篇,你能分清 Seata AT、TCC、可靠消息最终一致三种方案的适用边界,并照着配置和代码把每种方案的最小可用链路跑起来。
第一步:先想清楚「事务边界到底断在哪」
单体应用里 @Transactional 能兜住一切,是因为只有一个数据源。一旦变成「订单库扣库存 + 账户库扣钱」,本地事务就失效了。三种方案的差别,本质是一致性强度和业务侵入度的权衡:
| 方案 | 一致性 | 侵入度 | 适用 |
|---|
| Seata AT | 强一致(最终回滚) | 极低 | 内部服务、普通 CRUD |
| TCC | 强一致 | 高 | 资金、库存等核心链路 |
| 可靠消息 | 最终一致 | 中 | 跨系统、长流程、通知类 |
注意:不要一上来就选 TCC。绝大多数业务用 Seata AT 就够,TCC 的 Confirm/Cancel 要自己写,工作量是 AT 的 3 倍以上。
第二步:可靠消息最终一致(最容易落地,建议先做)
核心思路是「本地事务 + 消息表」同库同事务,再异步投递。
1. 建本地消息表
CREATE TABLE t_local_msg (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_id VARCHAR(64) NOT NULL,
topic VARCHAR(64) NOT NULL,
payload TEXT NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0待发送 1已发送 2已消费
retry_count INT NOT NULL DEFAULT 0,
next_retry_time DATETIME,
UNIQUE KEY uk_biz (biz_id, topic)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2. 业务方法里把业务写入和消息写入放进同一个 @Transactional,然后由定时任务扫描 status=0 投递 MQ,投递成功改 status=1。
3. 如果用的是 RocketMQ,可以省掉消息表,直接用事务消息:
TransactionMQProducer producer = new TransactionMQProducer("tx_producer_group");
producer.setNamesrvAddr("127.0.0.1:9876");
producer.setTransactionListener(new TransactionListener() {
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
return LocalTransactionState.COMMIT_MESSAGE; // 本地事务成功
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
return LocalTransactionState.COMMIT_MESSAGE; // 回查本地事务状态
}
});
producer.sendMessageInTransaction(msg, null);
4. 消费端必须幂等。 用 biz_id 建唯一索引,消费前先 INSERT ... ON DUPLICATE KEY 或查状态表,重复消息直接 ACK。
注意:MQ 只能保证「至少一次」,不保证「只消费一次」。幂等不做,重试一次就多扣一次钱。
第三步:TCC —— 把资源「预留」而不是「提交」
TCC 把一次操作拆成 Try(预留)、Confirm(确认)、Cancel(取消)三个方法。
以转账为例:
- Try:
UPDATE account SET frozen = frozen + 100, balance = balance - 100 WHERE user_id=? AND balance >= 100
- Confirm:
UPDATE account SET frozen = frozen - 100 WHERE user_id=?
- Cancel:
UPDATE account SET frozen = frozen - 100, balance = balance + 100 WHERE user_id=?
Seata TCC 模式写法:
@LocalTCC
public interface AccountTccAction {
@TwoPhaseBusinessAction(name = "accountTccAction",
commitMethod = "confirm", rollbackMethod = "cancel")
boolean tryReserve(BusinessActionContext ctx,
@BusinessActionContextParameter(paramName = "userId") Long userId,
@BusinessActionContextParameter(paramName = "amount") BigDecimal amount);
boolean confirm(BusinessActionContext ctx);
boolean cancel(BusinessActionContext ctx);
}
TCC 有三个经典坑,必须用一张事务记录表处理:
- 空回滚:Try 没执行,Cancel 先到了 → Cancel 时先判断是否存在 Try 记录,没有就直接记一条并返回成功。
- 悬挂:Cancel 比 Try 先到 → Try 执行前先查事务记录表,若已有 Cancel 记录,直接拒绝执行。
- 幂等:Confirm/Cancel 都要用
xid + branch_id 做唯一键去重。
注意:Try 阶段一定要做业务校验(比如余额充足),Confirm 阶段只做资源确认,不要再写业务判断——Confirm 失败会一直重试。
第四步:Seata AT 模式 —— 改代码最少
AT 模式靠 undo_log 表记录数据快照,回滚时自动生成反向 SQL。
1. 启动 Seata Server
docker run -d --name seata-server \
-p 8091:8091 -p 7091:7091 \
-e SEATA_PORT=8091 \
seataio/seata-server:1.7.0
2. 每个业务库建 undo_log 表
CREATE TABLE undo_log (
branch_id BIGINT NOT NULL,
xid VARCHAR(128) NOT NULL,
context VARCHAR(128) NOT NULL,
rollback_info LONGBLOB NOT NULL,
log_status INT NOT NULL,
log_created DATETIME(6) NOT NULL,
log_modified DATETIME(6) NOT NULL,
UNIQUE KEY ux_undo_log (xid, branch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3. 引入依赖并配置
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.7.0</version>
</dependency>
seata:
enabled: true
application-id: order-service
tx-service-group: my_tx_group
service:
vgroup-mapping:
my_tx_group: default
registry:
type: file
config:
type: file
4. 在全局事务入口方法上加 @GlobalTransactional,注意是加在最外层方法上,内层方法不用加。
注意:AT 模式依赖数据源代理。如果用了多数据源或自己 new 了 DataSource,必须用 DataSourceProxy 包一层,否则分支事务注册不上,回滚时会报 branch register failed。
第五步:怎么选
- 内部服务、普通增删改查 → Seata AT,零侵入。
- 资金、库存、优惠券核销 → TCC,资源预留更安全。
- 跨公司、跨 MQ、需要人工介入的长流程 → 可靠消息最终一致。
- 实际项目常常混用:主链路 AT,资金链路 TCC,通知链路走消息。
小结
- 三种方案的取舍点是「一致性强度 vs 业务侵入度」,不是越强越好。
- 可靠消息的关键是本地消息表同事务写入 + 消费端幂等 + 定时补偿。
- TCC 必须处理空回滚、悬挂、幂等三件事,靠事务记录表兜底。
- Seata AT 只需
undo_log 表 + 数据源代理 + @GlobalTransactional,改代码最少。
- 无论哪种方案,都要有对账或补偿任务,分布式事务没有「银弹」。