Seata AT 模式上线两周,我们踩了七个坑
订单库拆出去之后,"创建订单"和"扣减库存"就不再同一个数据库事务里了。以前靠一个 @Transactional 保证的事,现在要跨两个服务两个库。
3 月底到 4 月,我用 Seata 1.1.0 把这条链路补上了。这篇记一下选型过程、AT 模式到底做了什么,以及上线后踩的坑。
先说选型
摆出来的方案一共五种,我做了个表:
| 方案 | 一致性 | 侵入性 | 性能 | 我们的判断 |
|---|---|---|---|---|
| XA(二阶段) | 强一致 | 低,数据库支持即可 | 差,锁持有到二阶段结束 | MySQL 5.7 的 XA 有 prepare 后连接断开丢事务的问题,排除 |
| TCC | 最终一致 | 高,每个操作要写 Try/Confirm/Cancel 三个方法 | 好 | 留给资金相关的链路 |
| Saga | 最终一致 | 中,要写补偿逻辑 | 好 | 适合长流程,我们场景太短 |
| 本地消息表 | 最终一致 | 中,要建消息表加定时任务 | 好 | 备选 |
| AT | 最终一致(一阶段提交,有短暂中间态) | 低,加一个注解 | 中 | 选择 |
选 AT 的理由很实在:我们要改的链路有 9 个,涉及 4 个服务的 30 多个数据库写操作。如果上 TCC,每个写操作都要拆成三个方法,工作量是 AT 的好几倍,而且那套 Try/Confirm/Cancel 很容易写错。AT 模式只要加一个 @GlobalTransactional。
代价是 AT 的一阶段就提交了本地事务,中间态对外可见。这个下面会展开讲,也是最容易出问题的地方。
AT 模式到底干了什么
我用我们的场景画一遍。下单要改两个库:order_db.t_order 插一条,inventory_db.t_stock 减一次。
一阶段
- TM(事务发起方)向 TC(Seata Server)申请一个全局事务 XID。
- XID 通过 Feign 调用传到下游(靠 Seata 的
RequestInterceptor塞进 HTTP Header)。 - 每个 RM(参与者)执行 SQL 时,Seata 通过 数据源代理 拦截 SQL,做三件事:
-- 1. 解析 SQL,查出修改前的镜像(before image)
SELECT id, sku_id, available FROM t_stock WHERE sku_id = 10037;
-- 结果:available = 200
-- 2. 执行业务 SQL
UPDATE t_stock SET available = available - 1 WHERE sku_id = 10037;
-- 3. 再查一次,得到修改后的镜像(after image)
SELECT id, sku_id, available FROM t_stock WHERE sku_id = 10037;
-- 结果:available = 199
-- 4. 把 before/after 镜像序列化成 JSON,和业务 SQL 一起,
-- 在同一个本地事务里写进 undo_log 表
INSERT INTO undo_log (xid, branch_id, rollback_info, log_status, ...)
VALUES ('10.0.0.5:8091:2037382910', 2037382911, '{压缩后的镜像}', 0, ...);
-- 5. 向 TC 注册分支,并申请全局锁:t_stock 的主键 id=10037
-- 6. 本地事务提交(到这里数据已经落库了,其他会话能读到 available=199)
关键点:一阶段结束时本地事务已经提交了,锁也释放了(指数据库的行锁)。Seata 另外加了一层"全局锁",由 TC 管理。
二阶段
- 提交:TC 通知每个 RM 异步删除
undo_log记录。这一步失败也没关系,Seata 会重试,反正数据已经对了。 - 回滚:RM 取出
undo_log里的 before image,生成反向 SQL 执行(UPDATE t_stock SET available = 200 WHERE ...),然后再做一次"脏校验"——如果当前数据和 after image 对不上,说明有别的操作改过了,Seata 会报错让人介入。
部署和接入
Seata Server 1.1.0,两节点,用 Nacos 做注册中心、MySQL 做存储(store.mode = db,file 模式不支持集群)。
# registry.conf
registry {
type = "nacos"
nacos {
serverAddr = "10.0.0.21:8848"
namespace = ""
cluster = "default"
}
}
config {
type = "nacos"
nacos {
serverAddr = "10.0.0.21:8848"
namespace = ""
}
}
# file.conf 关键项
store {
mode = "db"
db {
datasource = "druid"
url = "jdbc:mysql://10.0.0.5:3306/seata?useUnicode=true"
user = "seata"
password = "xxx"
}
}
service {
vgroupMapping.order-service-group = "default"
vgroupMapping.inventory-service-group = "default"
default.grouplist = "10.0.0.31:8091,10.0.0.32:8091"
}
每个业务库都要建 undo_log 表:
CREATE TABLE `undo_log` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`branch_id` BIGINT(20) NOT NULL,
`xid` VARCHAR(100) NOT NULL,
`context` VARCHAR(128) NOT NULL,
`rollback_info` LONGBLOB NOT NULL,
`log_status` INT(11) NOT NULL,
`log_created` DATETIME NOT NULL,
`log_modified` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
客户端依赖和数据源代理:
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DruidDataSource druidDataSource() {
return new DruidDataSource();
}
// 必须包一层 DataSourceProxy,否则 Seata 拦不到 SQL
@Bean
@Primary
public DataSource dataSource(DruidDataSource druidDataSource) {
return new DataSourceProxy(druidDataSource);
}
}
业务代码只加一个注解:
@GlobalTransactional(name = "create-order", timeoutMills = 60000, rollbackFor = Exception.class)
public Long createOrder(OrderDTO dto) {
inventoryClient.deduct(dto.getSkuId(), dto.getQty()); // 远程,库存库
couponClient.use(dto.getCouponId()); // 远程,促销库
orderMapper.insert(buildOrder(dto)); // 本地,订单库
return orderId;
}
七个坑
1. undo_log 表没建
第一次跑就报:
java.sql.SQLSyntaxErrorException: Table 'inventory_db.undo_log' doesn't exist
我只给订单库建了表,忘了库存库。注意每个参与全局事务的数据库都要建,不管它是主库还是被调用方的库。
2. vgroupMapping 配错
io.seata.common.exception.FrameworkException: can not get cluster name
in registry config 'service.vgroupMapping.order-service-group',
please make sure registry config correct
这个 vgroupMapping.xxx-tx-group 的 key 要和客户端配置对上:
spring:
cloud:
alibaba:
seata:
tx-service-group: order-service-group # 和 file.conf 里的 key 一致
注意是 vgroupMapping.order-service-group,不是 vgroupMapping.order_service_group。中划线和下划线分不清会浪费半小时。
3. 全局锁冲突
上线第二天,日志里开始出现:
io.seata.rm.datasource.exec.LockConflictException: get global lock fail,
xid = 10.0.0.5:8091:2037382910, lockKeys = t_stock:10037
这是 AT 模式最典型的问题。全局锁是行级的,两个全局事务同时改 t_stock 的同一行,后到的那个拿不到锁,抛异常。
我们的热点商品(SPU 10037)在秒杀时每秒有 200 个请求改同一行,全都撞在这把锁上。表现是下单接口 P99 从 180 ms 飙到 4.2 秒,成功率掉到 71%。
处理办法三个,按优先级:
- 重试:Seata 客户端有
client.rm.lock.retryTimes = 30和retryInterval = 10ms,默认会自旋重试。但我们把重试间隔调到了 20 ms,因为 10 ms 太密集反而加剧冲突。 - 业务层拆分热点:把一行库存拆成 10 行(
stock_0到stock_9),随机取一行扣减。这个改动让单行冲突概率降到十分之一。真正的解决方案是这个,重试只是缓解。 - 去掉不必要的全局事务:我们盘点了一下,9 条链路里有 5 条其实可以用本地消息表做最终一致,只有 4 条(涉及钱和库存)真的需要 AT。链路越短,锁持有时间越短。
4. 一阶段的中间态对外可见
这是 AT 模式和 XA 最大的差别,也是最容易埋雷的地方。
一阶段提交之后、二阶段回滚之前,如果有另一个不走 Seata 的事务读到了 available = 199,然后全局事务回滚把它改回 200,那这个读就是脏读,更糟的是可能基于 199 做了别的写入(脏写)。
Seata 的解决办法是 @GlobalLock:
// 在不在全局事务里、但要读被全局锁保护的数据时,加这个注解
@GlobalLock
@Transactional
public Stock getStock(Long skuId) {
return stockMapper.selectById(skuId);
}
它的作用是:查询前先去 TC 检查一下这行有没有全局锁,有的话就等(或抛异常)。
我们线上的实际做法是:凡是读库存做业务判断的地方,SQL 统一改成 SELECT ... FOR UPDATE。加了行锁之后,别的事务就进不来了。代价是这些查询变慢了,库存查询 P99 从 6 ms 涨到 14 ms,能接受。
5. @GlobalTransactional 和 @Transactional 混用
@GlobalTransactional
public Long createOrder(OrderDTO dto) {
// 错误:这里又开了本地事务,嵌套之后行为很微妙
doCreate(dto);
}
@Transactional
public void doCreate(OrderDTO dto) { ... }
问题在于本地事务提交时机和 Seata 分支注册的时序对不上,会出现分支注册了但本地事务回滚了这类情况。我们的规范是:加了 @GlobalTransactional 的方法及其调用链里,不再使用 @Transactional。需要本地事务保证的地方拆成独立方法,通过 Propagation.REQUIRES_NEW 隔离。
6. undo_log 表暴涨
上线一周后,undo_log 表涨到了 340 万行、11 GB。原因是二阶段提交失败的记录会被保留下来重试,而有些失败是永久性的(比如数据被人工改过导致脏校验不通过)。
处理:配了 Seata Server 侧的清理任务,同时加了个自己的定时任务,删 3 天前且 log_status = 1(已正常完成)的记录:
DELETE FROM undo_log
WHERE log_created < DATE_SUB(NOW(), INTERVAL 3 DAY)
AND log_status = 1
LIMIT 5000;
分批删,一次 5000,每 30 秒跑一次。直接一把删会把数据库打挂。
7. Seata Server 挂了,事务悬着
TC 是有状态的,它自己挂了之后,正在进行的全局事务会卡住。1.1.0 里 store.mode = db 的情况下,另一个 TC 节点能接管,但那个卡住的全局锁要等事务超时(我们设的 60 秒)才释放。
我们遇到的实际情况是:TC 重启后的 3 分钟内,下单接口全部失败。所以 TC 必须做高可用(我们部署了两个节点),并且业务侧要有熔断兜底。
性能数据
| 指标 | 本地事务(拆库前) | Seata AT | 变化 |
|---|---|---|---|
| 下单 RT(P50) | 45 ms | 183 ms | +138 ms |
| 下单 RT(P99) | 120 ms | 410 ms | +290 ms |
| 单机 QPS | 890 | 320 | -64% |
| 库存行冲突时 P99 | - | 4200 ms | 热点行问题 |
多出来的 138 ms 主要花在:before/after 镜像的两次额外 SELECT、undo_log 的一次 INSERT、TC 的分支注册和全局锁申请(两次 RPC)。
这个代价是明摆着的,所以我们最终只保留 4 条链路用 AT,其余 5 条改成"本地消息表 + 定时任务补偿"。
和 TCC 怎么取舍
| 维度 | AT | TCC |
|---|---|---|
| 代码侵入 | 一个注解 | 每个操作三个方法 |
| 锁粒度 | Seata 全局锁(行级) | 业务自己定义(可以做到更细) |
| 中间态 | 一阶段提交,可见 | Try 阶段是预留,不可见 |
| 性能 | 4 次 DB 操作 + 2 次 RPC | 2 次 RPC(Try + Confirm) |
| 适用场景 | 关系数据库、能反向生成 SQL 的操作 | 涉及非关系资源、需要精确控制预留 |
我们的资金提现链路最后用了 TCC。原因是它要操作余额表、冻结表,还要调第三方支付,三方支付没有"反向 SQL"这回事,只能自己写 Confirm 和 Cancel。AT 搞不定。
判断标准我总结成一句:能不能自动生成反向 SQL,能就用 AT,不能就必须 TCC。
就写到这。如果哪天你也被《Seata 分布式事务 AT 模式踩坑记录》里同一个坑绊住,回来翻这篇,能省半小时。