Administrator
发布于 2020-04-15 / 1471 阅读
19

Seata 分布式事务 AT 模式踩坑记录

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 减一次。

一阶段

  1. TM(事务发起方)向 TC(Seata Server)申请一个全局事务 XID。
  2. XID 通过 Feign 调用传到下游(靠 Seata 的 RequestInterceptor 塞进 HTTP Header)。
  3. 每个 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 = 30retryInterval = 10ms,默认会自旋重试。但我们把重试间隔调到了 20 ms,因为 10 ms 太密集反而加剧冲突。
  • 业务层拆分热点:把一行库存拆成 10 行(stock_0stock_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 ms183 ms+138 ms
下单 RT(P99)120 ms410 ms+290 ms
单机 QPS890320-64%
库存行冲突时 P99-4200 ms热点行问题

多出来的 138 ms 主要花在:before/after 镜像的两次额外 SELECT、undo_log 的一次 INSERT、TC 的分支注册和全局锁申请(两次 RPC)。

这个代价是明摆着的,所以我们最终只保留 4 条链路用 AT,其余 5 条改成"本地消息表 + 定时任务补偿"。

和 TCC 怎么取舍

维度ATTCC
代码侵入一个注解每个操作三个方法
锁粒度Seata 全局锁(行级)业务自己定义(可以做到更细)
中间态一阶段提交,可见Try 阶段是预留,不可见
性能4 次 DB 操作 + 2 次 RPC2 次 RPC(Try + Confirm)
适用场景关系数据库、能反向生成 SQL 的操作涉及非关系资源、需要精确控制预留

我们的资金提现链路最后用了 TCC。原因是它要操作余额表、冻结表,还要调第三方支付,三方支付没有"反向 SQL"这回事,只能自己写 Confirm 和 Cancel。AT 搞不定。

判断标准我总结成一句:能不能自动生成反向 SQL,能就用 AT,不能就必须 TCC

就写到这。如果哪天你也被《Seata 分布式事务 AT 模式踩坑记录》里同一个坑绊住,回来翻这篇,能省半小时。

参考