Seata 分布式事务
定位
在微服务架构中,一个业务操作往往需要跨越多个服务、多个数据库。比如飞翔科技电商系统的下单流程:
用户点击"提交订单" → 订单服务(孔蓝负责)创建订单 → 库存服务(白歌负责)扣减库存 → 账户服务(小崔负责)扣减余额
这三个操作分布在三个独立的数据库上。如果库存扣减成功但余额扣减失败,就会出现"库存少了、钱没扣"的数据不一致问题——这正是分布式事务要解决的跨服务数据一致性难题。
Seata(Simple Extensible Autonomous Transaction Architecture)是阿里巴巴开源的分布式事务解决方案,核心设计目标是对业务零侵入——仅需一个 @GlobalTransactional 注解即可实现跨服务事务协调。
Seata 的架构围绕三种角色展开:
| 角色 | 全称 | 职责 |
|---|---|---|
| TC | Transaction Coordinator(事务协调器) | Seata Server,维护全局事务和分支事务的状态,驱动全局提交或回滚 |
| TM | Transaction Manager(事务管理器) | 事务发起方,定义全局事务边界(标注 @GlobalTransactional 的方法所在服务) |
| RM | Resource Manager(资源管理器) | 管理分支事务处理的资源,向 TC 注册分支事务并上报状态,每个参与的微服务都是一个 RM |
Seata 提供四种事务模式,覆盖不同场景需求:
| 模式 | 侵入性 | 性能 | 核心机制 | 适用场景 |
|---|---|---|---|---|
| AT(Automatic Transaction) | 低(零侵入) | 中 | 自动 UNDO LOG + 全局锁 | 基于关系型数据库的通用微服务 |
| TCC(Try-Confirm-Cancel) | 高(手写补偿) | 高 | 资源预留 → 确认/取消 | 对性能要求高的金融/支付场景 |
| Saga | 中 | 高 | 正向流程 + 补偿流程 | 长流程、老系统改造 |
| XA | 中 | 低 | 数据库原生二阶段提交 | 需要强一致性的金融场景 |
本文重点讲解 AT 模式(默认模式)的原理和实践,同时覆盖 TCC 模式的核心要点。
核心概念
XID(Global Transaction ID)
XID 是全局事务的唯一标识,由 TC 在收到 TM 的注册请求时生成,格式通常为 ip:port:transactionId。XID 通过 RPC 上下文(如 Feign Header)透明传递到下游服务,下游 RM 使用同一个 XID 向 TC 注册自己的分支事务。全局提交或回滚时,TC 通过 XID 定位所有关联的分支事务。
XID 示例:192.168.1.1:8091:2034567890
Branch ID(Branch Transaction ID)
每个参与全局事务的 RM 在向 TC 注册时,TC 为其分配一个唯一的 Branch ID。一个全局事务(一个 XID)下包含多个分支事务(多个 Branch ID),对应"订单库操作 → 库存库操作 → 账户库操作"的每一步。
UNDO LOG
UNDO LOG 是 AT 模式回滚的核心数据结构,记录数据修改前后的快照:
- 前镜像(Before Image):执行业务 SQL 之前的数据行快照
- 后镜像(After Image):执行业务 SQL 之后的数据行快照
- 补偿 SQL:回滚时根据前镜像自动生成的反向 SQL(如
UPDATE的补偿是还原原值的UPDATE)
每个参与 AT 模式的业务数据库都必须创建 undo_log 表来持久化这些回滚信息。
全局锁(lock_table)
AT 模式通过全局锁防止多个全局事务并发修改同一行数据。全局锁存储在 Seata Server 的 lock_table 中,以 row_key(resourceId + 表名 + 主键)+ xid 为唯一约束。一阶段提交前 RM 向 TC 申请全局锁,TC 检查是否有其他 XID 持有同一行锁——无冲突则放行,有冲突则等待重试或超时抛出 LockConflictException。
全局事务状态表与分支事务状态表
Seata Server(TC)内部使用三张核心表维护事务状态:
- global_table:记录全局事务状态(XID、状态、超时时间等)
- branch_table:记录分支事务状态(Branch ID、所属 XID、状态等)
- lock_table:记录全局锁持有情况(row_key、持有 XID、Branch ID 等)
核心原理(重点:AT 模式)
AT 模式基于两阶段提交协议,但与传统 XA 有本质区别:AT 模式在一阶段就提交了本地事务,二阶段只是异步清理或补偿。
AT 模式二阶段提交流程
关键点:一阶段每个 RM 的本地事务已经提交,全局提交的二阶段只是异步删除 UNDO LOG,非常轻量。
AT 模式二阶段回滚流程
关键点:回滚时 RM 根据 UNDO LOG 的前镜像自动生成反向补偿 SQL——INSERT 变 DELETE,UPDATE 变还原原值的 UPDATE,DELETE 变 INSERT。全程无需开发者编写补偿代码。
TM / TC / RM 交互架构全视图
全局锁工作原理详解
当两个全局事务同时修改同一行数据时,Seata 通过全局锁保证写隔离。整个流程如下:
全局事务 A(XID=XA) 全局事务 B(XID=XB)
│ │
│ 1. UPDATE stock SET count=5 │
│ WHERE product_id=100 │
│ │
│ 2. 向 TC 申请全局锁 │
│ row_key = "stock_db:stock:100" │
│ TC 检查 lock_table → 无冲突 │
│ TC 插入 (row_key, XA) → 获取锁 │
│ │
│ 3. 本地事务提交 │
│ │ 4. UPDATE stock SET count=3
│ │ WHERE product_id=100
│ │
│ │ 5. 向 TC 申请全局锁
│ │ row_key = "stock_db:stock:100"
│ │ TC 检查 lock_table → 有冲突!
│ │ XA 持有该行锁
│ │
│ │ 6. 等待重试(默认最多重试 30 次)
│ │ 每次间隔 10ms
│ │
│ 7. 全局事务提交 │
│ TC 释放全局锁(删除 lock_table 记录) │
│ │
│ │ 8. 重试成功,获取锁
│ │ TC 插入 (row_key, XB) → 获取锁
│ │ 9. 执行 SQL + 保存 UNDO LOG
配置参数:
client.rm.lock.retryInterval:重试间隔(默认 10ms)client.rm.lock.retryTimes:最大重试次数(默认 30 次)client.rm.lock.retryPolicyBranchRollbackOnConflict:重试耗尽后是否回滚分支(默认 true)
UNDO LOG 脏写检查机制
回滚时,Seata 必须确认当前数据库中的行数据与后镜像一致,否则说明在全局事务提交到回滚之间发生了脏写(其他事务修改了同一行数据)。检查流程:
回滚时 RM 的操作顺序:
1. 从 undo_log 表读取 rollback_info(前镜像 + 后镜像)
2. 用后镜像的主键 + 所有字段值查询数据库当前行
3. 比对:当前行数据 == 后镜像?
├── 一致 → 安全,执行反向补偿 SQL
└── 不一致 → 脏写发生 → 抛出异常,需要人工介入
4. 执行反向补偿 SQL
5. 删除该 UNDO LOG 记录
最佳实践:对于需要严格写隔离的场景,在业务 SQL 中使用
SELECT ... FOR UPDATE获取数据库行锁,配合全局锁形成双重保护。
环境准备
Seata Server 下载与启动
Seata Server(TC)需要独立部署,推荐使用 db 模式存储事务日志。
# 下载 Seata Server(以 2.1.0 版本为例)
wget https://github.com/seata/seata/releases/download/v2.1.0/seata-server-2.1.0.zip
# 解压
unzip seata-server-2.1.0.zip -d /opt/seata
cd /opt/seata/bin
# Windows 启动(db 模式 + Nacos 注册)
seata-server.bat -p 8091 -m db
使用 Nacos 作为 Seata 注册中心和配置中心
在 Seata Server 的 conf/application.yml 中配置:
seata:
registry:
type: nacos
nacos:
application: seata-server
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
namespace: ""
username: nacos
password: nacos
config:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
namespace: ""
data-id: seataServer.properties
store:
mode: db
db:
datasource: druid
db-type: mysql
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&rewriteBatchedStatements=true
user: root
password: root
Seata Server 所需的三张元数据表(在 TC 所在数据库中创建):
-- global_table
CREATE TABLE IF NOT EXISTS `global_table` (
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`status` TINYINT NOT NULL,
`application_id` VARCHAR(32),
`transaction_service_group` VARCHAR(32),
`transaction_name` VARCHAR(128),
`timeout` INT,
`begin_time` BIGINT,
`application_data` VARCHAR(2000),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`xid`),
KEY `idx_status_gmt_modified` (`status`, `gmt_modified`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
-- branch_table
CREATE TABLE IF NOT EXISTS `branch_table` (
`branch_id` BIGINT NOT NULL,
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`resource_group_id` VARCHAR(32),
`resource_id` VARCHAR(256),
`branch_type` VARCHAR(8),
`status` TINYINT,
`client_id` VARCHAR(64),
`application_data` VARCHAR(2000),
`gmt_create` DATETIME(6),
`gmt_modified` DATETIME(6),
PRIMARY KEY (`branch_id`),
KEY `idx_xid` (`xid`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
-- lock_table
CREATE TABLE IF NOT EXISTS `lock_table` (
`row_key` VARCHAR(128) NOT NULL,
`xid` VARCHAR(128),
`transaction_id` BIGINT,
`branch_id` BIGINT NOT NULL,
`resource_id` VARCHAR(256),
`table_name` VARCHAR(32),
`pk` VARCHAR(36),
`status` TINYINT NOT NULL DEFAULT 0,
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`row_key`, `xid`),
KEY `idx_branch_id` (`branch_id`),
KEY `idx_xid` (`xid`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
业务库创建 undo_log 表
每个参与 AT 模式全局事务的数据库都必须创建此表(否则回滚时会因找不到表而失败):
CREATE TABLE `undo_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`branch_id` BIGINT NOT NULL COMMENT '分支事务 ID',
`xid` VARCHAR(128) NOT NULL COMMENT '全局事务 XID',
`context` VARCHAR(128) NOT NULL COMMENT '序列化后的上下文',
`rollback_info` LONGBLOB NOT NULL COMMENT '回滚信息(前镜像 + 后镜像的序列化数据)',
`log_status` INT NOT NULL COMMENT '日志状态:0-正常,1-已全局提交',
`log_created` DATETIME NOT NULL COMMENT '日志创建时间',
`log_modified` DATETIME NOT NULL COMMENT '日志修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
seata-server 配置要点
file.conf(Seata Server 端,存储在 Nacos 配置中心时无需此文件):
# 事务日志存储模式
store.mode=db
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.cj.jdbc.Driver
store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true
store.db.user=root
store.db.password=root
# 服务配置
service.vgroupMapping.default_tx_group=default
service.default.grouplist=127.0.0.1:8091
registry.conf(Seata Server 端注册中心配置):
registry {
type = "nacos"
nacos {
application = "seata-server"
serverAddr = "127.0.0.1:8848"
group = "SEATA_GROUP"
namespace = ""
cluster = "default"
username = "nacos"
password = "nacos"
}
}
config {
type = "nacos"
nacos {
serverAddr = "127.0.0.1:8848"
group = "SEATA_GROUP"
namespace = ""
dataId = "seataServer.properties"
username = "nacos"
password = "nacos"
}
}
客户端 application.yml(每个微服务都需要配置):
seata:
registry:
type: nacos
nacos:
application: seata-server
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
namespace: ""
username: nacos
password: nacos
config:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
namespace: ""
data-id: seataServer.properties
tx-service-group: default_tx_group
service:
vgroup-mapping:
default_tx_group: default
完整示例 1:AT 模式——电商下单(最经典场景)
场景描述
飞翔科技电商系统,CTO 大翔提出硬性要求:用户下单操作必须同时完成以下三步,数据必须一致:
- 订单服务(孔蓝负责):创建订单记录 → 订单库
- 库存服务(白歌负责):扣减商品库存 → 库存库
- 账户服务(小崔负责):扣减用户余额 → 账户库
三个服务各自连接独立的数据库,任何一个环节失败,前面的操作必须全部回滚。
Maven 依赖
<!-- 每个微服务都需引入 Seata Starter -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
<!-- 远程调用使用 OpenFeign -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
订单服务(孔蓝)——TM + RM
订单服务作为全局事务的发起方(TM),同时也管理自己的分支事务(RM)。
// Feign 客户端:调用库存服务
@FeignClient(name = "storage-service", path = "/storage")
public interface StorageFeignClient {
@PostMapping("/deduct")
Result deduct(@RequestParam("productId") Long productId,
@RequestParam("count") Integer count);
}
// Feign 客户端:调用账户服务
@FeignClient(name = "account-service", path = "/account")
public interface AccountFeignClient {
@PostMapping("/debit")
Result debit(@RequestParam("userId") Long userId,
@RequestParam("amount") BigDecimal amount);
}
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StorageFeignClient storageClient;
@Autowired
private AccountFeignClient accountClient;
/**
* 下单核心方法——@GlobalTransactional 是全局事务入口。
* 任何一步抛出异常,前面已提交的本地事务会被 Seata 自动回滚。
*/
@GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
public void createOrder(Order order) {
// 分支事务 1:订单库——创建订单
orderMapper.insert(order);
// 分支事务 2:远程调用库存服务——扣减库存
Result storageResult = storageClient.deduct(
order.getProductId(), order.getCount());
if (!storageResult.isSuccess()) {
throw new RuntimeException("库存扣减失败");
}
// 分支事务 3:远程调用账户服务——扣减余额
Result accountResult = accountClient.debit(
order.getUserId(), order.getAmount());
if (!accountResult.isSuccess()) {
throw new RuntimeException("余额扣减失败");
}
}
}
库存服务(白歌)——RM
@RestController
@RequestMapping("/storage")
public class StorageController {
@Autowired
private StorageMapper storageMapper;
@PostMapping("/deduct")
@Transactional // 本地事务
public Result deduct(@RequestParam Long productId,
@RequestParam Integer count) {
// Seata 代理会在此处自动:
// 1. 查询前镜像
// 2. 执行 SQL
// 3. 查询后镜像
// 4. 保存 UNDO LOG 到 undo_log 表
// 5. 向 TC 注册分支事务
int rows = storageMapper.deduct(productId, count);
if (rows <= 0) {
throw new RuntimeException("库存不足");
}
return Result.success();
}
}
对应的 Mapper SQL:
<update id="deduct">
UPDATE storage
SET count = count - #{count}
WHERE product_id = #{productId} AND count >= #{count}
</update>
账户服务(小崔)——RM
@RestController
@RequestMapping("/account")
public class AccountController {
@Autowired
private AccountMapper accountMapper;
@PostMapping("/debit")
@Transactional
public Result debit(@RequestParam Long userId,
@RequestParam BigDecimal amount) {
int rows = accountMapper.debit(userId, amount);
if (rows <= 0) {
throw new RuntimeException("余额不足");
}
return Result.success();
}
}
<update id="debit">
UPDATE account
SET balance = balance - #{amount}
WHERE user_id = #{userId} AND balance >= #{amount}
</update>
操作前后对比
没有 Seata 时的典型事故:
1. 订单服务 → INSERT INTO orders → 成功,本地事务提交
2. 库存服务 → UPDATE storage SET count = count - 1 → 成功,本地事务提交
3. 账户服务 → UPDATE account SET balance = balance - 100 → 失败!余额不足
结果:订单创建了,库存扣了,钱没扣——数据永久不一致。
除非手写补偿代码逐一回滚前两步,否则数据库永远处于错误状态。
有 Seata 之后:
1. 订单服务 → INSERT INTO orders + 保存 UNDO LOG → 本地事务提交
2. 库存服务 → UPDATE storage + 保存 UNDO LOG → 本地事务提交
3. 账户服务 → UPDATE account → 失败!抛出异常
4. @GlobalTransactional 捕获异常 → TM 通知 TC 全局回滚
5. TC 通知库存服务的 RM → 读取 UNDO LOG → 执行反向补偿 SQL
UPDATE storage SET count = count + 1 WHERE product_id = ?
6. TC 通知订单服务的 RM → 读取 UNDO LOG → 执行反向补偿 SQL
DELETE FROM orders WHERE id = ?
7. 全局回滚完成,数据回到下单前的状态
测试方法
在账户服务中故意抛出异常,验证库存是否成功回滚:
// 测试代码:在 AccountController.debit() 中注入异常
@PostMapping("/debit")
@Transactional
public Result debit(@RequestParam Long userId,
@RequestParam BigDecimal amount) {
int rows = accountMapper.debit(userId, amount);
if (rows <= 0) {
throw new RuntimeException("余额不足");
}
// === 故意抛出异常,模拟故障 ===
throw new RuntimeException("模拟账户服务故障,测试全局回滚");
}
验证步骤:
- 查看库存表:确认库存数量已恢复(说明 UNDO LOG 补偿成功)
- 查看订单表:确认测试订单已被删除(说明订单库也回滚成功)
- 查看
undo_log表:确认对应的 UNDO LOG 记录已被删除(Seata 回滚后自动清理) - 在 Seata Server 日志中查看回滚链路
完整示例 2:TCC 模式——自定义补偿逻辑
场景描述
账户服务(小崔)使用 TCC 模式处理余额扣减。与 AT 模式不同,TCC 需要手动编码实现 Try(预留资源)→ Confirm(确认提交)→ Cancel(补偿回滚) 三个阶段。扣款操作需要先冻结资金(Try),二阶段再确认扣款(Confirm)或解冻(Cancel)。
@LocalTCC + @TwoPhaseBusinessAction 注解
// TCC 接口:声明三阶段行为
@LocalTCC
public interface AccountTccAction {
@TwoPhaseBusinessAction(
name = "accountTccAction",
commitMethod = "commit",
rollbackMethod = "rollback"
)
boolean prepareDebit(
@BusinessActionContextParameter(paramName = "userId") Long userId,
@BusinessActionContextParameter(paramName = "amount") BigDecimal amount
);
boolean commit(BusinessActionContext context);
boolean rollback(BusinessActionContext context);
}
Try / Confirm / Cancel 方法实现
@Component
public class AccountTccActionImpl implements AccountTccAction {
@Autowired
private AccountMapper accountMapper;
/**
* Try 阶段:冻结余额。
* available_amount - N,frozen_amount + N。
* 此时用户余额尚未真正减少,只是被"锁定"。
*/
@Override
@Transactional
public boolean prepareDebit(Long userId, BigDecimal amount) {
// 防悬挂:检查是否存在 Cancel 记录(Cancel 先于 Try 到达的情况)
if (accountMapper.existsTccRecord(userId, "CANCEL")) {
return true; // Cancel 已先行到达,拒绝执行 Try
}
// 幂等:检查是否已经 Try 过
if (accountMapper.existsTccRecord(userId, "TRY")) {
return true;
}
// 插入 TCC 控制记录(用于防悬挂和幂等)
accountMapper.insertTccRecord(userId, amount, "TRY");
// 冻结余额
int rows = accountMapper.freeze(userId, amount);
if (rows == 0) {
throw new RuntimeException("余额不足");
}
return true;
}
/**
* Confirm 阶段:确认扣款。
* frozen_amount - N,真正扣减冻结的余额。
*/
@Override
@Transactional
public boolean commit(BusinessActionContext context) {
Long userId = Long.valueOf(context.getActionContext("userId").toString());
BigDecimal amount = new BigDecimal(context.getActionContext("amount").toString());
// 幂等:已确认过则直接返回
if ("CONFIRM".equals(accountMapper.getTccRecordStatus(userId))) {
return true;
}
accountMapper.commitDebit(userId, amount);
accountMapper.updateTccRecordStatus(userId, "CONFIRM");
return true;
}
/**
* Cancel 阶段:解冻余额。
* frozen_amount - N,available_amount + N。
*/
@Override
@Transactional
public boolean rollback(BusinessActionContext context) {
Long userId = Long.valueOf(context.getActionContext("userId").toString());
BigDecimal amount = new BigDecimal(context.getActionContext("amount").toString());
String status = accountMapper.getTccRecordStatus(userId);
if (status == null) {
// 空回滚:Try 从未执行,Cancel 却到达(网络超时后 TC 补发 Cancel)
accountMapper.insertTccRecord(userId, BigDecimal.ZERO, "CANCEL_EMPTY");
return true;
}
if ("CANCEL".equals(status)) {
return true; // 幂等:已回滚过
}
accountMapper.unfreeze(userId, amount);
accountMapper.updateTccRecordStatus(userId, "CANCEL");
return true;
}
}
与 AT 模式对比
| 维度 | AT 模式 | TCC 模式 |
|---|---|---|
| 侵入性 | 低,只需 @GlobalTransactional + undo_log 表 | 高,需手动实现 Try / Confirm / Cancel |
| 性能 | 中,有全局锁开销 | 高,无全局锁,资源预留后可并行 |
| 补偿代码 | 自动生成(UNDO LOG) | 手写 |
| 适用场景 | 通用数据库操作 | 对性能要求高的金融/支付场景 |
| 数据库要求 | 关系型数据库 + undo_log 表 | 无特殊要求 |
| 回滚语义 | 物理回滚(还原数据行) | 业务回滚(解冻/退款等业务操作) |
完整示例 3:事务回滚失败——并发冲突场景解析
场景描述
飞翔科技爆款新品上线,大翔和小崔同时下单购买同一种商品。两个全局事务同时扣减同一条库存记录:
- 全局事务 A(大翔下单):扣减 product_id=100 的库存
- 全局事务 B(小崔下单):扣减 product_id=100 的库存
全局锁冲突演示
时间线 →
全局事务 A(XID=XA) 全局事务 B(XID=XB)
│ │
T1: │ UPDATE stock SET count=count-1 │
│ WHERE product_id=100 │
│ │
T2: │ 向 TC 申请全局锁 │
│ row_key = "stock_db:stock:100" │
│ TC 无冲突 → 获锁 │
│ │
T3: │ 本地事务提交 │
│ │
T4: │ │ UPDATE stock SET count=count-1
│ │ WHERE product_id=100
T5: │ │ 向 TC 申请全局锁
│ │ row_key = "stock_db:stock:100"
│ │ TC → 冲突!XA 持有该锁
│ │
T6: │ │ 进入等待重试循环:
│ │ retry 1... retry 2... retry 3...
│ │
T7: │ 全局提交 → TC 释放锁 │
│ │
T8: │ │ 重试成功!获锁 → 执行 SQL
│ │ → 保存 UNDO LOG → 本地事务提交
lock_table 表数据查看
在并发冲突发生时,可以查询 Seata Server 的 lock_table 观察锁持有情况:
-- 查看当前持有的全局锁
SELECT row_key, xid, transaction_id, branch_id,
table_name, pk, gmt_create
FROM lock_table
WHERE row_key LIKE '%stock%100%';
结果示例:
| row_key | xid | branch_id | table_name | pk |
|---|---|---|---|---|
stock_db^^^stock^^^100 | 192.168.1.1:8091:2012345678 | 2012345679 | stock | 100 |
解决方案
方案一:调大全局锁重试次数
seata:
client:
rm:
lock:
retryInterval: 10 # 重试间隔(ms)
retryTimes: 60 # 最大重试次数(默认 30)
retryPolicyBranchRollbackOnConflict: true
方案二:在业务 SQL 中使用 SELECT FOR UPDATE(推荐)
在扣减库存前先锁定行,配合全局锁形成双重保护:
<!-- 在扣减前先 SELECT FOR UPDATE 获取数据库行锁 -->
<select id="lockAndGetStock" resultType="int">
SELECT count FROM storage
WHERE product_id = #{productId}
FOR UPDATE
</select>
<update id="deduct">
UPDATE storage
SET count = count - #{count}
WHERE product_id = #{productId} AND count >= #{count}
</update>
@PostMapping("/deduct")
@Transactional
public Result deduct(@RequestParam Long productId,
@RequestParam Integer count) {
// 先获取数据库行锁
Integer currentStock = storageMapper.lockAndGetStock(productId);
if (currentStock < count) {
throw new RuntimeException("库存不足");
}
// 再执行扣减
storageMapper.deduct(productId, count);
return Result.success();
}
SELECT FOR UPDATE在@Transactional内获取的是数据库行锁,与 Seata 的全局锁形成互补:前者保证一阶段内不被同库事务干扰,后者保证跨全局事务的隔离性。
易错场景
1. undo_log 表未创建导致回滚失败(最常见的初学问题)
现象:发起全局事务后,业务正常提交,但回滚时报 Table 'xxx.undo_log' doesn't exist。
原因:AT 模式回滚时需要从 undo_log 表读取前镜像生成反向补偿 SQL,但目标数据库中没有这张表。
解决:在每个参与 AT 模式全局事务的数据库中都执行 DDL 创建 undo_log 表。特别注意:新增业务库时容易遗漏。
2. 全局事务日志表数据膨胀未清理
现象:Seata Server 的 global_table、branch_table、lock_table 持续增长,查询变慢。
原因:Seata Server 默认不会自动清理历史事务日志。高并发场景下每天可能产生数十万条记录。
解决:配置定期清理任务。Seata 1.5+ 支持配置 store.db.globalTable / store.db.branchTable 的保留天数。或在运维层面设置定时任务:
-- 清理 7 天前的全局事务日志
DELETE FROM global_table WHERE gmt_modified < DATE_SUB(NOW(), INTERVAL 7 DAY);
DELETE FROM branch_table WHERE gmt_modified < DATE_SUB(NOW(), INTERVAL 7 DAY);
DELETE FROM lock_table WHERE gmt_modified < DATE_SUB(NOW(), INTERVAL 7 DAY);
3. @GlobalTransactional timeout 设置过短
现象:正常的长时间业务操作被 TC 判定超时并强制回滚。
原因:@GlobalTransactional 默认超时时间为 60 秒(Seata 默认 server.max.commit.retry.timeout 为 -1 表示无限,但实际受全局事务超时控制)。复杂的业务流程(如批量导入、大文件处理)可能超过默认超时。
解决:根据业务特点适当增大超时:
@GlobalTransactional(name = "batch-import", timeoutMills = 300000) // 5 分钟
public void batchImport(List<Record> records) { ... }
4. TCC 模式空回滚和悬挂问题
空回滚:Try 阶段因网络超时未执行,但 TC 仍然触发了 Cancel。此时 Cancel 到达时,Try 的资源预留从未发生——如果 Cancel 方法不做判断直接解冻,会导致数据错误。
悬挂:Cancel 先于 Try 到达。网络延迟导致 Try 请求超时,TC 先发出 Cancel 指令,Cancel 执行完毕后 Try 请求才到达——此时 Try 会错误地冻结已被解冻的资源。
解决:在 Try 方法中检查是否已有 Cancel 记录(悬挂判断),在 Cancel 方法中检查 Try 是否执行过(空回滚判断)。通过一张 TCC 控制表记录 Try / Confirm / Cancel 的状态:
// Try: 防悬挂
if (tccMapper.existsRecord(bizId, "CANCEL")) {
return true; // Cancel 已先行到达,拒绝执行 Try
}
// Cancel: 防空回滚
if (tccMapper.getStatus(bizId) == null) {
tccMapper.insertRecord(bizId, "CANCEL_EMPTY");
return true;
}
// Try / Confirm / Cancel: 幂等
if (tccMapper.getStatus(bizId).equals(currentPhase)) {
return true;
}
5. AT 模式 SELECT FOR UPDATE 的必要性
问题:AT 模式的默认隔离级别是读已提交(Read Committed)。在以下场景会出现脏读:
全局事务 A 全局事务 B
UPDATE stock SET count=5 SELECT count FROM stock
WHERE product_id=100 WHERE product_id=100
(count 变为 5,但尚未全局提交) → 读到了 5(脏读!)
全局回滚,count 恢复为 10
解决:对需要严格一致性的读操作使用 SELECT ... FOR UPDATE。此语句会等待全局锁释放后才返回数据,从而保证读到的是已全局提交的数据。
6. @Transactional 与 @GlobalTransactional 混合使用
问题:在同一个方法或调用链中混合使用声明式事务和分布式事务,回滚行为可能不一致。
// 错误示例
@Transactional // 本地事务
@GlobalTransactional // 全局事务
public void createOrder(Order order) {
orderMapper.insert(order); // 受两个事务管理
accountClient.debit(...); // 只受全局事务管理
}
// 当 debit 失败时,@Transactional 已提交或已在独立事务中,
// 可能出现 insert 未被正确回滚的情况
最佳实践:AT 模式下,TM 所在方法只使用 @GlobalTransactional,RM 所在方法使用 @Transactional(管理本地事务)。不要在同一个方法上同时标注两个注解。
// 正确做法
@Service
public class OrderService {
@GlobalTransactional // TM:只标注全局事务
public void createOrder(Order order) { ... }
}
@RestController
public class StorageController {
@PostMapping("/deduct")
@Transactional // RM:只标注本地事务
public Result deduct(...) { ... }
}
面试考点
1. AT 模式的二阶段提交与 XA 的二阶段提交有何区别?
| 维度 | Seata AT | XA |
|---|---|---|
| 一阶段行为 | 提交本地事务,释放数据库连接和本地锁 | 仅 prepare,不提交,持续持有数据库锁 |
| 二阶段提交 | 异步删除 UNDO LOG(极轻量) | 提交本地事务 |
| 二阶段回滚 | 根据 UNDO LOG 前镜像反向补偿 | 回滚本地事务 |
| 锁持有时间 | 短(一阶段提交即释放数据库本地锁,转为 Seata 全局锁) | 长(一阶段到二阶段之间始终持有数据库锁) |
| 性能 | 高(数据库连接和锁快速释放) | 低(长时间占用数据库资源) |
| 对数据库要求 | 需额外创建 undo_log 表 | 数据库本身需支持 XA 协议 |
核心差异:AT 在一阶段就提交了本地事务,把"未确定状态"从数据库层转移到 Seata 的 UNDO LOG 层。这使得数据库锁持有时间极短,性能远超 XA。
2. AT 模式如何保证读已提交隔离级别?
AT 模式默认全局事务的隔离级别为读已提交(Read Committed)。原因:
- 一阶段本地事务已提交,其他事务可以读到该数据(读已提交)
- 如果全局事务最终回滚,UNDO LOG 会将数据补偿回去,但中间时刻其他事务读到了"未全局提交"的数据
提升隔离级别:在 SELECT 语句后加 FOR UPDATE,该语句会向 TC 申请全局锁——如果该行数据被其他未完成的全局事务持有全局锁,则当前 SELECT FOR UPDATE 会等待,从而读到已全局提交的数据。
3. TCC 模式三大问题:空回滚、悬挂、幂等,如何解决?
| 问题 | 成因 | 解决方案 |
|---|---|---|
| 空回滚 | Try 因网络超时未执行,Cancel 却到达 | Cancel 方法检查是否已有 Try 记录,无则记录"空回滚"标记并直接返回 |
| 悬挂 | Cancel 先于 Try 到达(网络导致 Try 请求延迟) | Try 方法检查是否已有 Cancel 记录,有则拒绝执行 |
| 幂等 | 网络重试导致同一 Try / Confirm / Cancel 被多次调用 | 通过 TCC 控制表的状态字段做去重判断("TRY" → 不重复执行) |
核心手段:引入一张 TCC 事务控制表,记录业务主键 + 操作阶段(TRY / CONFIRM / CANCEL)+ 状态,所有阶段方法先查表再做判断。
4. Seata 事务分组(tx-service-group)和集群(cluster)的概念及路由机制
- tx-service-group:事务分组,是客户端的逻辑分组标识。客户端通过
seata.tx-service-group=default_tx_group配置。 - vgroup-mapping:事务分组到 TC 集群的映射。例如
default_tx_group→default集群。 - cluster(集群):TC 的物理部署单元。同一个 cluster 下的 TC 实例互为备份,共享事务日志。
路由流程:
客户端 tx-service-group → vgroup-mapping → TC cluster
↓ ↓ ↓
default_tx_group → default cluster → 从 Nacos 获取该 cluster
下的可用 TC 实例列表
这种设计使得同一个应用在不同环境(开发/测试/生产)只需修改 tx-service-group 的值,无需改动其他配置。
5. 全局锁 vs 数据库行锁的区别和配合
| 维度 | 全局锁(Seata) | 数据库行锁(如 InnoDB) |
|---|---|---|
| 作用范围 | 跨数据库、跨服务 | 单个数据库实例内 |
| 管理方 | Seata Server(TC) | 数据库引擎 |
| 存储位置 | lock_table(Seata 库) | 数据库内存 / 锁表 |
| 生命周期 | 一阶段注册 → 全局事务完成 | 事务内有效 |
| 获取时机 | 一阶段提交前向 TC 申请 | SQL 执行时自动获取 |
配合机制:一阶段执行业务 SQL 时,数据库行锁先获取;一阶段提交前,向 TC 申请全局锁。本地事务提交后数据库行锁释放,但全局锁继续持有直到全局事务完成。这样设计保证了:
- 一阶段内:数据库行锁防止同库并发修改
- 一阶段后:全局锁防止跨全局事务的并发修改
- SELECT FOR UPDATE 请求全局锁,将隔离级别提升到读已提交之上
6. 什么场景不适合用 AT 模式?
| 场景 | 原因 | 替代方案 |
|---|---|---|
| 使用不支持标准 SQL 解析的 ORM | Seata 需要解析 SQL 生成前/后镜像,非标准 ORM 或动态拼接的复杂 SQL 可能解析失败 | TCC |
| 频繁表结构变更 | 表结构变更期间,UNDO LOG 中序列化的前后镜像与当前表结构不匹配,导致回滚失败 | TCC 或 Saga |
| 超高并发热点数据 | 全局锁在热点数据行上成为瓶颈,大量事务排队等待全局锁 | TCC(预留资源方式) |
| 非关系型数据库 | AT 模式强依赖关系型数据库的 SQL 解析和本地事务 | TCC 或 Saga |
| 超长事务(数小时) | UNDO LOG 长期占用存储空间,且回滚时数据可能已被多次修改 | Saga(正向+补偿流水线) |
参考资源:Seata 官方文档 https://seata.io | Spring Cloud Alibaba 官方文档 https://sca.aliyun.com