ElIauk

Seata AT 模式分布式事务入门

本地事务管不到远程调用 一个下单接口要同时写订单库、扣库存、扣余额,三个动作分属三个服务、三个数据库。@Transactional 只能保证当前数据源内的原子性,远程调用一旦成功、本地事务再回滚,数据就歪了。Seata 的 AT 模式是目前落地成本最低的方案之一:业务代码基本不用改,靠代理数据源和

评论
Seata AT 模式分布式事务入门

本地事务管不到远程调用

一个下单接口要同时写订单库、扣库存、扣余额,三个动作分属三个服务、三个数据库。@Transactional 只能保证当前数据源内的原子性,远程调用一旦成功、本地事务再回滚,数据就歪了。Seata 的 AT 模式是目前落地成本最低的方案之一:业务代码基本不用改,靠代理数据源和 undo_log 做自动补偿。

这篇基于 Seata 2.x + Spring Cloud Alibaba 2023.0.3.x,讲清楚 AT 的执行过程、接入方式,以及那几个必须提前知道的限制。

AT 的两阶段到底做了什么

AT 把一次全局事务拆成两阶段:

一阶段:执行业务 SQL 时,RM 拦截并解析 SQL,记录修改前的数据(before image)和修改后的数据(after image),写入本库的 undo_log,然后直接提交本地事务,同时向 TC 注册分支、申请全局锁。

二阶段提交:TC 通知各分支异步删除 undo_log,全局锁释放。

二阶段回滚:RM 根据 undo_log 里的 before image 生成反向 SQL,校验当前数据是否仍等于 after image,没问题就恢复,然后删除日志。

关键点在一阶段:本地事务是立即提交的,不是等全局提交。所以 AT 拿到的是最终一致性,不是严格的两阶段锁。这个设计让吞吐远高于 XA,代价是全局隔离性变弱,默认是读未提交级别,别的全局事务可能看到中间状态。

接入:三样东西缺一不可

第一,每个参与方的业务库都要建 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;

第二,客户端配置要指向 TC,事务分组名和映射必须一致:

seata:
  enabled: true
  application-id: order-service
  tx-service-group: order-tx-group
  service:
    vgroup-mapping:
      order-tx-group: default
  registry:
    type: nacos
    nacos:
      server-addr: 127.0.0.1:8848
      namespace: dev-id
      group: SEATA_GROUP
      application: seata-server

tx-service-group 是客户端自己起的分组名,vgroup-mapping 把它映射到 TC 集群名。这两个名字对不上,启动后调用就报 no available service 'default' found。

第三,在全局事务入口加注解:

@GlobalTransactional(name = "create-order", rollbackFor = Exception.class, timeoutMills = 30000)
public void createOrder(CreateOrderCommand command) {
    orderMapper.insert(command.toOrder());
    inventoryClient.deduct(command.getSkuId(), command.getQuantity());
    accountClient.debit(command.getUserId(), command.getAmount());
}

timeoutMills 是 Seata 的历史拼写,不是笔误。超时后 TC 会主动回滚,所以它要大于整条链路的最长耗时,包括所有远程调用和重试。

隔离性:脏读和全局锁

AT 的默认隔离级别是读未提交。举个常见现象:A 事务扣了库存但还没提交全局,B 事务去查库存,看到的是已经扣减后的值;如果 A 最终回滚,B 就基于一个错误的值做了决策。

Seata 提供了两层缓解手段:写操作在提交本地事务前会拿全局锁,防止其他全局事务同时改同一行;读操作如果需要强一点的语义,可以用 @GlobalLock 配合 SELECT ... FOR UPDATE,先检查全局锁再读。代价是串行化,热点行上会明显拖慢吞吐。

所以 AT 适合“短事务、冲突少、能接受最终一致”的场景,比如下单主流程。对于秒杀库存这种热点行,AT 的全局锁会成为瓶颈,更适合用 Redis 预扣加异步落库。

常见坑与排查

坑一:undo_log 没建或建在错误的库。 一阶段写日志失败,全局回滚时无据可依,只能留下脏数据。上线前用脚本检查所有物理库。

坑二:数据源没有被代理。 Seata 靠 DataSourceProxy 拦截 SQL。如果项目里自己 new 了数据源、用了多数据源框架却没接 Seata 的代理,undo_log 根本不会写,事务看起来“提交成功”,回滚时什么都不会发生。检查启动日志里有没有 AutoProxy 相关记录。

坑三:@GlobalTransactional 自调用或方法非 public。 和 Spring 事务一样,走不到代理就不生效。入口方法放在 Controller 调用的 Service 上,别在同类内部转一圈。

坑四:XID 没传下去。 Seata 会自动包装 Feign,但用 RestTemplate、WebClient 或自建 HTTP 客户端时,需要手动把 RootContext.getXID() 放进请求头,下游再绑定回来。XID 断了,下游就是普通本地事务,回滚不到。

坑五:回滚时 after image 校验失败。 说明这一行在全局事务期间被非 Seata 的本地事务改过,Seata 为了避免覆盖别人的修改会拒绝回滚。这类问题只能靠“所有写操作都走全局事务”来避免。

排查顺序:先看 Seata Server 日志里这个 XID 的分支注册和回滚结果;再看各服务 logs/seata/seata.log;然后查业务库 undo_log 里有没有对应记录;最后确认数据源代理和 XID 透传。多数“没回滚”的问题,根因都在后两项。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者