第 7 章 · Seata AT 模式分布式事务入门
本章目标:理解微服务场景下的分布式事务问题;掌握 Seata 架构(TC、TM、RM)与 AT 模式原理(一阶段提交、undo log);在 svc-spring-demo 搭建教学级「下单扣库存」跨服务事务演示;对比 AT 与 TCC、Saga、本地消息表;知晓性能开销与使用边界,避免滥用全局事务。
学时建议:6~7 小时(含 2.5 小时 Seata Server 与双服务联调)
前置:spring-cloud-web ch01~ch06;spring-boot-web ch05 JPA 事务;MySQL InnoDB 基础。
7.1 问题:跨服务数据一致性
ch04 order-svc 写订单库,ch06 inventory-svc 异步扣库存——最终一致,但存在短暂「订单已建、库存未扣」窗口。
部分业务要求强一致(教学简化场景):
用户下单成功 ⇔ 订单已创建 且 库存已扣减
单体 shop-spring-demo 用一个 @Transactional 搞定;微服务跨库跨进程需要 分布式事务。
| 方案 | 一致性 | 复杂度 | 适用 |
|---|---|---|---|
| 本地事务 | 单库强一致 | 低 | 单体 |
| Seata AT | 强一致(默认读未提交隔离讨论) | 中 | 短事务、关系型 DB |
| TCC | 高 | 高 | 需预留、确认、取消 |
| Saga | 最终一致 | 中 | 长流程 |
| 本地消息表 | 最终一致 | 中 | 高吞吐写路径 |
本章聚焦 Seata AT 入门;生产需评估是否真的需要全局事务。
虚构环境:Seata TC 使用 seata.example.com:8091 或本地 Docker;严禁连接公司生产 TC 或真实业务库。
7.2 Seata 组件架构
┌─────────────┐ 注册 ┌──────────────────┐
│ Seata TC │◄─────────────│ Nacos │
│ (Server) │ │ nacos.example.com│
└──────┬──────┘ └──────────────────┘
│ 协调全局事务
│
┌───┴───┐ ┌──────────────┐
│ order │ │ inventory │
│ -svc │ │ -svc │
│ (TM) │────────►│ (RM) │
└───┬───┘ Feign └──────┬───────┘
│ │
order_db inventory_db
| 角色 | 全称 | 职责 |
|---|---|---|
| TC | Transaction Coordinator | 维护全局/分支事务状态,驱动提交或回滚 |
| TM | Transaction Manager | 开启、提交或回滚全局事务(通常订单服务) |
| RM | Resource Manager | 管理分支事务,上报 TC,执行本地提交/回滚 |
客户端通过 Nacos 发现 TC 地址(与 ch02 一致)。
7.3 AT 模式原理(两阶段简化理解)
一阶段(业务 SQL 执行时):
- 解析 SQL,查询前镜像(before image)
- 执行业务 SQL
- 查询后镜像(after image)
- 本地事务提交业务数据 + 插入 undo_log(同一本地事务)
- 向 TC 注册分支,申请全局锁(防脏写)
二阶段提交:
- 全局提交:异步删除 undo_log
- 全局回滚:根据 undo_log 生成反向 SQL 恢复数据
一阶段 二阶段
order-svc: INSERT order ──► TC 决定 COMMIT ──► 删 undo_log
inventory: UPDATE stock ──► └──► 或 ROLLBACK ──► 用 undo 恢复
| 特点 | 说明 |
|---|---|
| 对业务侵入 | 低,主要加 @GlobalTransactional |
| 数据源 | 需代理数据源(Seata 自动) |
| 隔离级别 | 默认 READ UNCOMMITTED 读,靠全局锁防写冲突 |
| 限制 | 不支持部分 NoSQL;跨服务调用链不宜过长 |
7.4 启动 Seata Server(Docker 教学)
docker-compose.dev.yml:
seata-server:
image: seataio/seata-server:2.0.0 # 官方镜像为 seataio/seata-server
ports:
- "8091:8091"
environment:
SEATA_PORT: 8091
STORE_MODE: file
depends_on:
- nacos
Seata 向 Nacos 注册,配置 registry.conf / application.yml 指向 nacos.example.com:8848(本地 127.0.0.1)。
store.mode 教学可用 file;生产用 db 存事务会话。
7.5 业务库准备:undo_log 表
每个参与 AT 的 MySQL 库执行:
CREATE TABLE IF NOT EXISTS undo_log (
id BIGINT NOT NULL AUTO_INCREMENT,
branch_id BIGINT NOT NULL,
xid VARCHAR(100) 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,
PRIMARY KEY (id),
UNIQUE KEY ux_undo_log (xid, branch_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
order_db、inventory_db 各一份。
7.6 order-svc 集成 Seata(TM)
7.6.1 依赖
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
7.6.2 配置
seata:
enabled: true
application-id: order-svc
tx-service-group: svc-spring-demo-tx-group
registry:
type: nacos
nacos:
server-addr: ${NACOS_ADDR:127.0.0.1:8848}
namespace: ${NACOS_NAMESPACE:dev}
group: SEATA_GROUP
config:
type: nacos
nacos:
server-addr: ${NACOS_ADDR:127.0.0.1:8848}
namespace: ${NACOS_NAMESPACE:dev}
group: SEATA_GROUP
spring:
datasource:
url: jdbc:mysql://localhost:3306/order_db?useSSL=false&serverTimezone=UTC
username: ${DB_USER:demo}
password: ${DB_PASSWORD:demo}
在 Nacos 配置 Seata 服务端识别的 service.vgroupMapping.svc-spring-demo-tx-group=default 等(按 Seata 2.x 文档模板,教学 compose 可预置)。
7.6.3 全局事务入口
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderSeataService {
private final OrderRepository orderRepository;
private final InventoryClient inventoryClient;
private final ProductClient productClient;
@GlobalTransactional(name = "create-order-tx", rollbackFor = Exception.class)
public OrderDto createOrderWithStock(CreateOrderCommand cmd) {
Result<ProductDto> productRes = productClient.getById(cmd.getProductId());
ProductDto product = productRes.getData();
if (product == null || !product.isOnSale()) {
throw new BusinessException(ErrorCode.PRODUCT_UNAVAILABLE);
}
Order order = new Order();
order.setUserId(cmd.getUserId());
order.setProductId(cmd.getProductId());
order.setQuantity(cmd.getQuantity());
order.setAmount(product.getPrice().multiply(BigDecimal.valueOf(cmd.getQuantity())));
order.setStatus(OrderStatus.PAID);
orderRepository.save(order);
Result<Void> deductRes = inventoryClient.deduct(
cmd.getProductId(), cmd.getQuantity(), order.getId());
if (!deductRes.isSuccess()) {
throw new BusinessException(ErrorCode.STOCK_DEDUCT_FAILED);
}
log.info("Global tx success orderId={}", order.getId());
return OrderMapper.toDto(order);
}
}
@GlobalTransactional 标记 TM;方法内 Feign 调用 inventory 成为同一 XID 下的分支。