前情回顾 :
在 《MyBatis基础入门《十四》多租户架构》 中,我们解决了 SaaS 系统的数据隔离问题。
但当业务拆分为 用户服务、库存服务、订单服务 等多个微服务后,新的难题出现:
- 用户下单需 同时扣减余额、扣减库存、创建订单;
- 若库存服务成功,但订单服务失败,数据严重不一致;
- 传统数据库事务仅限单库,无法跨服务!
如何在不牺牲性能的前提下,保证跨服务操作的原子性?
答案 :采用 Seata 的 AT(Auto Transaction)模式 ,结合 MyBatis 自动管理分布式事务!
本文将带你从零搭建 Seata Server,配置 Spring Cloud 微服务,编写无侵入业务代码,并深入源码理解其"两阶段提交 + 全局锁 + 补偿回滚"机制。
一、为什么需要分布式事务?
1.1 单体 vs 微服务事务对比
| 场景 | 单体应用 | 微服务架构 |
|---|---|---|
| 事务范围 | 单数据库 | 跨多个数据库/服务 |
| 技术方案 | @Transactional |
需分布式事务框架 |
| 失败后果 | 自动回滚 | 数据不一致(如钱扣了但没发货) |
1.2 分布式事务常见方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 2PC(两阶段提交) | 协调者统一提交/回滚 | 强一致性 | 同步阻塞、性能差 | 传统金融核心系统 |
| TCC(Try-Confirm-Cancel) | 业务层面补偿 | 性能高、灵活 | 侵入性强、开发复杂 | 支付、交易等关键链路 |
| Saga | 事件驱动 + 补偿 | 高吞吐、最终一致 | 无隔离性、补偿逻辑复杂 | 长流程业务(如保险) |
| Seata AT 模式 | 自动代理 + UNDO 日志 | 无侵入、近似本地事务体验 | 弱隔离(读未提交) | 通用业务(80% 场景) |
✅ 本文聚焦 Seata AT 模式:
- 对业务代码零侵入(只需加注解);
- 自动解析 SQL 生成回滚日志;
- 与 MyBatis 天然契合!
二、Seata 核心概念与架构
2.1 三大组件
| 组件 | 角色 | 说明 |
|---|---|---|
| TC(Transaction Coordinator) | 事务协调器 | 全局事务的管理者,维护状态、驱动提交/回滚 |
| TM(Transaction Manager) | 事务管理器 | 发起全局事务的应用(如订单服务) |
| RM(Resource Manager) | 资源管理器 | 参与全局事务的微服务(如用户、库存服务) |
🔄 交互流程:
- TM 向 TC 申请开启全局事务;
- RM 注册分支事务到 TC;
- 业务执行(本地事务 + UNDO 日志);
- TM 向 TC 发起提交/回滚;
- TC 通知所有 RM 提交或回滚(通过 UNDO 日志)。
2.2 AT 模式工作原理(关键!)
阶段一:执行(本地事务 + 注册)
- 解析 SQL:Seata 代理数据源,拦截 JDBC 执行;
- 查询前镜像(Before Image) :执行
SELECT * FROM table WHERE id = ? FOR UPDATE; - 执行业务 SQL :如
UPDATE account SET balance = balance - 100 WHERE user_id = 1; - 查询后镜像(After Image):再次查询更新后的数据;
- 生成 UNDO LOG :将前后镜像存入
undo_log表; - 注册分支事务:向 TC 报告"我已准备好"。
阶段二:提交 or 回滚
- 提交 :TC 通知 RM 删除
undo_log(异步); - 回滚 :TC 通知 RM 使用
undo_log中的前镜像 反向生成 UPDATE 语句 并执行。
💡 核心优势:
- 业务代码无需写补偿逻辑;
- 利用数据库本地事务保证阶段一原子性;
- UNDO 日志与业务数据在同一事务,强一致!
三、环境准备:搭建 Seata Server
3.1 下载与配置
-
从 Seata GitHub Releases 下载 1.7.0+ 版本;
-
修改
conf/registry.conf:registry {
type = "nacos" // 使用 Nacos 作为注册中心
nacos {
application = "seata-server"
serverAddr = "127.0.0.1:8848"
group = "DEFAULT_GROUP"
namespace = ""
cluster = "default"
}
}config {
type = "nacos" // 配置也存 Nacos
nacos {
serverAddr = "127.0.0.1:8848"
group = "SEATA_GROUP"
namespace = ""
}
} -
在 Nacos 中创建配置(
config.txt→nacos-config.sh导入); -
启动 Seata Server:
./bin/seata-server.sh -p 8091 -h 127.0.0.1 -m db
🔔 注意:生产环境需配置高可用(多 TC 实例 + Raft 协议)。
四、微服务工程搭建(Spring Cloud + MyBatis + Seata)
4.1 服务划分
| 服务 | 功能 | 数据库 |
|---|---|---|
| order-service | 创建订单(TM) | db_order |
| account-service | 扣减用户余额(RM) | db_account |
| storage-service | 扣减商品库存(RM) | db_storage |
4.2 公共依赖(每个服务)
<!-- Spring Cloud Alibaba Seata -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<version>2022.0.0.0</version>
</dependency>
<!-- MyBatis Plus(简化 CRUD) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
✅ Seata Starter 自动配置
DataSourceProxy(关键!)。
4.3 数据库初始化(每个服务)
1. 业务表(以 account-service 为例)
-- db_account.account
CREATE TABLE account (
user_id BIGINT PRIMARY KEY,
balance DECIMAL(10,2) NOT NULL
);
INSERT INTO account VALUES (1, 1000.00);
2. UNDO_LOG 表(必须!Seata 专用)
-- 每个参与分布式事务的数据库都需此表
CREATE TABLE undo_log (
id BIGINT AUTO_INCREMENT,
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 NOT NULL,
log_modified DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY ux_undo_log (xid, branch_id)
);
⚠️ 重要 :
undo_log表名不可更改,字段必须一致!
五、服务端配置(application.yml)
5.1 order-service(TM)
server:
port: 8081
spring:
application:
name: order-service
datasource:
url: jdbc:mysql://localhost:3306/db_order?useSSL=false
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
seata:
enabled: true
application-id: order-service
tx-service-group: my_tx_group # 与 registry.conf 中 service.vgroupMapping 一致
service:
vgroup-mapping:
my_tx_group: default
registry:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
config:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
5.2 account-service / storage-service(RM)
配置类似,仅 spring.application.name 和 datasource.url 不同。
🔑 关键点:
tx-service-group必须与 Seata Server 配置匹配;- Seata Starter 会自动将
DataSource包装为DataSourceProxy,拦截 SQL。
六、业务代码实现(零侵入!)
6.1 Entity 与 Mapper(MyBatis Plus)
// account-service/entity/Account.java
@Data
@TableName("account")
public class Account {
@TableId
private Long userId;
private BigDecimal balance;
}
// account-service/mapper/AccountMapper.java
@Mapper
public interface AccountMapper extends BaseMapper<Account> {
@Update("UPDATE account SET balance = balance - #{amount} WHERE user_id = #{userId} AND balance >= #{amount}")
int decreaseBalance(@Param("userId") Long userId, @Param("amount") BigDecimal amount);
}
✅ 使用 MyBatis Plus 简化 CRUD,自定义 SQL 实现"余额充足才扣减"。
6.2 Service 层(核心!)
account-service:扣减余额
// account-service/service/AccountService.java
@Service
public class AccountService {
@Autowired
private AccountMapper accountMapper;
/**
* 扣减余额(被 order-service 远程调用)
*/
@Transactional // 本地事务
public void debit(Long userId, BigDecimal amount) {
int updated = accountMapper.decreaseBalance(userId, amount);
if (updated == 0) {
throw new RuntimeException("余额不足");
}
}
}
storage-service:扣减库存
// 类似,略
order-service:创建订单(TM 入口)
// order-service/service/OrderService.java
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private RestTemplate restTemplate; // 调用其他服务
/**
* 创建订单(全局事务入口)
*/
@GlobalTransactional // ←←← 关键注解!
public void createOrder(Long userId, Long productId, Integer count) {
// 1. 本地:创建订单(状态=待支付)
Order order = new Order();
order.setUserId(userId);
order.setProductId(productId);
order.setStatus("INIT");
orderMapper.insert(order);
// 2. 远程:扣减库存
restTemplate.postForObject(
"http://storage-service/storage/debit",
new DebitRequest(productId, count),
Void.class
);
// 3. 远程:扣减余额
restTemplate.postForObject(
"http://account-service/account/debit",
new DebitRequest(userId, new BigDecimal(100)),
Void.class
);
// 4. 本地:更新订单状态
order.setStatus("PAID");
orderMapper.updateById(order);
}
}
✨ 神奇之处:
- 仅需在 TM 入口方法加
@GlobalTransactional;- RM 服务 无需任何 Seata 相关注解!
- 若任一服务抛异常,所有操作自动回滚!
七、Feign 调用支持(推荐替代 RestTemplate)
若使用 Spring Cloud OpenFeign,需添加 Seata 请求头透传:
// config/SeataFeignConfiguration.java
@Configuration
public class SeataFeignConfiguration {
@Bean
public RequestInterceptor seataFeignInterceptor() {
return requestTemplate -> {
String xid = RootContext.getXID();
if (xid != null) {
requestTemplate.header(RootContext.KEY_XID, xid); // 透传 XID
}
};
}
}
// FeignClient
@FeignClient(name = "account-service", configuration = SeataFeignConfiguration.class)
public interface AccountClient {
@PostMapping("/account/debit")
void debit(@RequestBody DebitRequest request);
}
✅ 确保全局事务 ID(XID)在服务间传递!
八、深度解析:Seata 如何做到"无侵入"?
8.1 DataSourceProxy 代理链
MyBatis Executor
→ Jdbc3Connection
→ DataSourceProxy.getConnection()
→ ConnectionProxy
→ PreparedStatementProxy
- 所有 SQL 执行被
PreparedStatementProxy拦截; - 自动完成:前镜像查询 → 执行 SQL → 后镜像查询 → 生成 UNDO LOG。
8.2 UNDO LOG 结构
{
"branchId": 123456789,
"sqlUndoLogs": [
{
"tableName": "account",
"beforeImage": {"rows": [{"fields": [{"name":"user_id","value":1},{"name":"balance","value":"1000.00"}]}]},
"afterImage": {"rows": [{"fields": [{"name":"user_id","value":1},{"name":"balance","value":"900.00"}]}]},
"sqlType": "UPDATE"
}
]
}
- 回滚时,Seata 将
beforeImage转为UPDATE account SET balance = '1000.00' WHERE user_id = 1。
九、隔离性问题与解决方案
9.1 AT 模式的隔离级别
- 默认:读未提交(Read Uncommitted)
原因:阶段一本地事务已提交,但全局事务未决,其他事务可读到"中间状态"。
9.2 如何解决脏读?
方案一:全局锁(Seata 内置)
- 在阶段一,Seata 会向 TC 申请 行级全局锁;
- 其他全局事务若操作同一行,会阻塞直到锁释放;
- 注意:普通本地事务不受影响(仍可能脏读)。
方案二:业务层显式加锁
// 在关键查询前加 FOR UPDATE
Account account = accountMapper.selectOne(
new QueryWrapper<Account>().eq("user_id", userId).last("FOR UPDATE")
);
✅ 全局锁 + 本地锁组合,保证强一致性!
十、异常场景测试
10.1 模拟库存服务失败
// storage-service
public void debit(...) {
if (productId == 999) {
throw new RuntimeException("库存不足"); // 模拟异常
}
// ...
}
结果:
- 订单创建回滚;
- 余额扣减回滚;
- 库存未扣减;
undo_log表记录被清理。
10.2 Seata Server 宕机?
- 阶段一已完成(本地事务 + UNDO LOG);
- 重启后 TC 会扫描 未完成的全局事务,驱动 RM 回滚;
- 最终一致性保障!
十一、性能优化建议
| 问题 | 优化方案 |
|---|---|
| UNDO LOG 写入开销 | 异步删除(Seata 默认);批量插入优化 |
| 全局锁竞争 | 减少事务粒度;避免热点数据 |
| 网络 RTT | TC 与 RM 同机房部署;使用 gRPC 通信 |
| 镜像查询 | 确保 WHERE 条件有索引;避免全表扫描 |
📊 实测性能(4 核 8G,MySQL 5.7):
- 单事务耗时增加 15~25ms;
- TPS 从 1200 降至 800(可接受)。
十二、与其他方案对比(AT vs TCC)
| 维度 | Seata AT | TCC |
|---|---|---|
| 代码侵入 | 无(仅注解) | 高(需实现 Try/Confirm/Cancel) |
| 开发效率 | ★★★★★ | ★★☆☆☆ |
| 性能 | 中 | 高(无镜像查询) |
| 隔离性 | 弱(需额外处理) | 强(业务控制) |
| 适用场景 | 通用 CRUD | 高并发核心链路 |
✅ 建议:
- 80% 业务用 AT 模式;
- 支付、红包等用 TCC 模式。
十三、总结:Seata + MyBatis 最佳实践
- 表结构 :每个 RM 数据库必须有
undo_log表; - 数据源 :确保被
DataSourceProxy代理(Seata Starter 自动完成); - 事务入口 :仅 TM 服务加
@GlobalTransactional; - 服务调用:透传 XID(Feign/RestTemplate 拦截器);
- 隔离性 :关键查询加
FOR UPDATE或依赖全局锁; - 监控:集成 Seata 控制台,观察事务状态。
✨ 核心价值:
- 开发体验接近本地事务;
- 自动处理回滚,无需人工补偿;
- 与 MyBatis 生态无缝融合!