背景与问题
微服务拆开之后,单体时代那种“一个本地事务包住所有写操作”的日子就结束了。
最典型的场景是:下单、扣库存、冻结余额、生成物流单,这些动作分散在不同服务、不同数据库里。只要其中一步失败,就会出现经典问题:
- 订单创建成功,但库存没扣
- 库存扣了,支付冻结失败
- 支付成功了,但订单状态没更新
- 某个服务超时重试,导致数据被重复修改
如果系统只追求“功能先跑起来”,往往会走到两种极端:
- 全靠业务补偿:写起来快,但补偿链路越来越复杂,最后谁都不敢动。
- 强上 2PC/XA:理论上强一致,但性能、锁持有时间、数据库兼容性经常不适合互联网业务。
这也是 Seata 出场的地方:它不是银弹,但在很多“数据库 + 微服务”的业务里,能在一致性、性能、侵入性之间找到一个比较务实的平衡点。
本文我会从架构视角讲清楚几件事:
- Seata 到底解决了什么问题
- AT / TCC / Saga / XA 在实践里怎么选
- 一个订单场景怎么真正落地
- 出问题时该看哪里、怎么排查
- 一致性设计与性能之间如何做取舍
先讲结论:不是所有业务都该上分布式事务
在落地之前,我建议先做一层分类。因为很多团队不是“不会用 Seata”,而是用错了场景。
适合使用 Seata 的场景
- 多个服务分别操作自己的数据库
- 核心链路要求较高一致性
- 可以接受一点性能损耗
- 服务之间已是同步调用,链路相对清晰
- 数据库主要是 MySQL,且 SQL 规范较稳定
不太适合直接上 AT 的场景
- 长事务、人工审核、跨小时流程
- 大量复杂 SQL、批量更新、异步写入
- 高并发热点行更新
- 非关系型存储为主
- 服务已经是事件驱动、最终一致为主的系统
一个很实际的经验是:核心资金、库存预占这类短事务,可以考虑 Seata;长流程审批、营销任务编排,更适合 Saga 或消息最终一致。
方案对比与取舍分析
Seata 不只有一种模式。别一上来就把 AT 当成唯一答案。
四种常见模式对比
| 模式 | 一致性 | 侵入性 | 性能 | 适用场景 |
|---|---|---|---|---|
| AT | 最终接近强一致 | 低 | 中 | 基于关系型数据库的短事务 |
| TCC | 高 | 高 | 中高 | 核心业务、强业务控制 |
| Saga | 最终一致 | 中 | 高 | 长流程、可补偿业务 |
| XA | 强一致 | 低到中 | 低 | 少量高一致场景,数据库支持好 |
一个简化判断方法
- 能容忍短时间不一致,并且想快速落地:优先 AT
- 业务动作天然有预留/确认/取消:优先 TCC
- 链路很长,节点很多,有人工环节:优先 Saga
- 场景极少但必须严格原子:谨慎评估 XA
我自己做架构设计时,通常会问三个问题:
- 失败后能不能补?
- 补偿动作是不是可靠且幂等?
- 热点数据会不会被全局锁打爆?
这三个问题,基本决定了你该用哪种模式。
核心原理
本文重点讲落地最常见的 Seata AT 模式,因为它对业务代码侵入最小,也是很多团队第一步实践的入口。
Seata 的角色
- TC(Transaction Coordinator):事务协调器,负责全局事务状态管理
- TM(Transaction Manager):事务管理器,负责开启、提交、回滚全局事务
- RM(Resource Manager):资源管理器,负责分支事务注册、提交、回滚
AT 模式怎么工作
AT 的关键思路是:
- 业务方法开启全局事务
- 各服务执行本地 SQL 时,Seata 代理数据源
- 在提交前后记录 before image / after image
- 分支事务向 TC 注册
- 全局成功则提交;全局失败则根据镜像自动回滚
也就是说,AT 并不是把所有数据库操作变成“一个真正跨库的大事务”,而是:
- 每个分支先走本地事务提交
- Seata 通过 undo log + 全局锁来保证可回滚与隔离性
这就是它性能比 XA 好很多,但又不是绝对强一致的根本原因。
一张整体流程图
flowchart LR
A[订单服务 TM\n开启全局事务] --> B[库存服务 RM\n执行扣减库存]
B --> C[账户服务 RM\n执行冻结金额]
C --> D[订单服务 RM\n写订单状态]
B --> E[TC 注册分支事务]
C --> E
D --> E
E --> F{全局事务结果}
F -->|成功| G[各分支提交]
F -->|失败| H[根据 undo_log 回滚]
一次正常提交的时序
sequenceDiagram
participant Client as Client
participant Order as OrderService(TM)
participant TC as Seata TC
participant Stock as StockService(RM)
participant Account as AccountService(RM)
Client->>Order: 创建订单
Order->>TC: begin global tx
TC-->>Order: XID
Order->>Stock: 扣库存(XID透传)
Stock->>TC: 注册分支
TC-->>Stock: OK
Stock-->>Order: success
Order->>Account: 冻结金额(XID透传)
Account->>TC: 注册分支
TC-->>Account: OK
Account-->>Order: success
Order->>TC: commit global tx
TC-->>Order: committed
Order-->>Client: success
回滚的关键点
AT 模式依赖两样东西:
- undo_log:用于回滚数据
- 全局锁:防止脏写
如果没有 undo_log 表,或者数据源没被 Seata 代理,那你看到“全局事务注解写了”,其实事务并没有真正生效。
一致性设计:别只盯着技术框架
Seata 只是事务协调工具,真正决定系统可靠性的,是你的一致性设计边界。
我建议把业务拆成三层:
1. 强一致核心
必须一起成功或一起失败,例如:
- 订单创建
- 库存预占/扣减
- 账户冻结
这部分适合 Seata AT 或 TCC。
2. 可延迟一致部分
允许稍后补齐,例如:
- 发短信
- 发送站内信
- 推送营销券
- 写搜索索引
这部分不要硬塞进全局事务,应该走 MQ 或异步任务。
3. 最终校准部分
用于兜底,例如:
- 对账任务
- 库存与订单定时校验
- 死信重放
- 补偿任务扫描
一个成熟系统不是“靠分布式事务解决一切”,而是: 短链路强约束 + 长链路异步解耦 + 后台对账兜底。
实战代码(可运行)
下面给一个简化但可运行的 Spring Boot + Seata AT 示例。场景是:
order-service:创建订单stock-service:扣减库存
为了控制篇幅,这里重点展示核心代码。你可以按这个结构扩展到账户服务。
依赖配置
<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<version>2021.0.5.0</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
数据库表结构
订单表
CREATE TABLE `t_order` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`product_id` BIGINT NOT NULL,
`count` INT NOT NULL,
`amount` DECIMAL(10,2) NOT NULL,
`status` VARCHAR(20) NOT NULL,
PRIMARY KEY (`id`)
);
库存表
CREATE TABLE `t_stock` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`product_id` BIGINT NOT NULL,
`total` INT NOT NULL,
`used` INT NOT NULL DEFAULT 0,
`residue` INT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_product_id` (`product_id`)
);
undo_log 表
每个参与 AT 的数据库都要建。
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 NOT NULL,
`log_modified` DATETIME NOT NULL,
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
);
订单服务配置
# application.yml
server:
port: 8081
spring:
application:
name: order-service
datasource:
url: jdbc:mysql://127.0.0.1:3306/order_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
seata:
enabled: true
tx-service-group: my_tx_group
service:
vgroup-mapping:
my_tx_group: default
grouplist:
default: 127.0.0.1:8091
库存服务配置
server:
port: 8082
spring:
application:
name: stock-service
datasource:
url: jdbc:mysql://127.0.0.1:3306/stock_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
seata:
enabled: true
tx-service-group: my_tx_group
service:
vgroup-mapping:
my_tx_group: default
grouplist:
default: 127.0.0.1:8091
DataSource 代理配置
很多人最容易漏这里。AT 模式下必须让业务走代理数据源。
// DataSourceProxyConfig.java
package com.example.order.config;
import com.zaxxer.hikari.HikariDataSource;
import io.seata.rm.datasource.DataSourceProxy;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
@Configuration
public class DataSourceProxyConfig {
@Bean
public DataSource dataSource(
@Value("${spring.datasource.url}") String url,
@Value("${spring.datasource.username}") String username,
@Value("${spring.datasource.password}") String password,
@Value("${spring.datasource.driver-class-name}") String driverClassName) {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(url);
ds.setUsername(username);
ds.setPassword(password);
ds.setDriverClassName(driverClassName);
return new DataSourceProxy(ds);
}
}
库存服务
// StockController.java
package com.example.stock.controller;
import com.example.stock.service.StockService;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/stock")
public class StockController {
private final StockService stockService;
public StockController(StockService stockService) {
this.stockService = stockService;
}
@PostMapping("/deduct")
public String deduct(@RequestParam Long productId, @RequestParam Integer count) {
stockService.deduct(productId, count);
return "ok";
}
}
// StockService.java
package com.example.stock.service;
public interface StockService {
void deduct(Long productId, Integer count);
}
// StockServiceImpl.java
package com.example.stock.service.impl;
import com.example.stock.service.StockService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@Service
public class StockServiceImpl implements StockService {
private final JdbcTemplate jdbcTemplate;
public StockServiceImpl(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public void deduct(Long productId, Integer count) {
int updated = jdbcTemplate.update(
"UPDATE t_stock SET used = used + ?, residue = residue - ? WHERE product_id = ? AND residue >= ?",
count, count, productId, count
);
if (updated == 0) {
throw new RuntimeException("库存不足");
}
}
}
订单服务调用库存服务
// RestTemplateConfig.java
package com.example.order.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
// OrderService.java
package com.example.order.service;
public interface OrderService {
void create(Long userId, Long productId, Integer count, Double amount);
}
// OrderServiceImpl.java
package com.example.order.service.impl;
import io.seata.spring.annotation.GlobalTransactional;
import com.example.order.service.OrderService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class OrderServiceImpl implements OrderService {
private final JdbcTemplate jdbcTemplate;
private final RestTemplate restTemplate;
public OrderServiceImpl(JdbcTemplate jdbcTemplate, RestTemplate restTemplate) {
this.jdbcTemplate = jdbcTemplate;
this.restTemplate = restTemplate;
}
@Override
@GlobalTransactional(name = "create-order-tx", rollbackFor = Exception.class)
public void create(Long userId, Long productId, Integer count, Double amount) {
jdbcTemplate.update(
"INSERT INTO t_order(user_id, product_id, count, amount, status) VALUES(?,?,?,?,?)",
userId, productId, count, amount, "CREATING"
);
restTemplate.postForObject(
"http://127.0.0.1:8082/stock/deduct?productId=" + productId + "&count=" + count,
null,
String.class
);
// 模拟异常,观察全局回滚
if (amount > 1000) {
throw new RuntimeException("金额超限,触发回滚");
}
jdbcTemplate.update(
"UPDATE t_order SET status = ? WHERE user_id = ? AND product_id = ? ORDER BY id DESC LIMIT 1",
"SUCCESS", userId, productId
);
}
}
// OrderController.java
package com.example.order.controller;
import com.example.order.service.OrderService;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/order")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping("/create")
public String create(@RequestParam Long userId,
@RequestParam Long productId,
@RequestParam Integer count,
@RequestParam Double amount) {
orderService.create(userId, productId, count, amount);
return "ok";
}
}
如何验证回滚是否生效
启动 Seata Server、订单服务、库存服务后,调用:
curl -X POST "http://127.0.0.1:8081/order/create?userId=1&productId=1001&count=2&amount=1200"
预期现象:
- 接口报错
t_order不保留最终成功数据t_stock的库存扣减被回滚undo_log中会产生对应记录
如果你发现订单回滚了、库存却没回滚,基本就是以下几类问题:
- XID 没透传
- 库存服务没接入 Seata
- 没有使用代理数据源
- undo_log 缺失
- SQL 不满足 AT 解析条件
容量估算与性能权衡
架构设计不能只讲“能不能做”,还要讲“能跑多快”。
Seata AT 的性能成本来自哪里
- 额外 SQL 解析
- undo_log 写入
- 分支注册与全局事务协调
- 全局锁竞争
- 失败场景下回滚成本
这意味着,在高并发场景里,AT 一定比纯本地事务慢。
一个简单的量化思路
假设某个下单接口原本只做本地事务,平均 RT 是 25ms。接入 Seata AT 后,常见变化可能是:
- 本地 SQL 与代理开销:+5~10ms
- TC 通信:+3~8ms
- undo_log:+2~5ms
- 库存热点冲突:波动增加
最终 RT 可能来到 35~60ms。如果再叠加多个下游服务,尾延迟会更明显。
哪些情况下性能会突然恶化
- 同一商品被高频扣减,形成热点行
- 一个全局事务里串行调用太多服务
- SQL 过于复杂,AT 解析或镜像生成成本高
- 事务时间过长,锁持有时间拉长
- 失败率高,导致大量回滚
一个建议的链路拆分方式
flowchart TD
A[下单请求] --> B[强一致主链路]
B --> C[订单创建]
B --> D[库存预占]
B --> E[账户冻结]
A --> F[异步链路]
F --> G[通知消息]
F --> H[优惠券发放]
F --> I[搜索索引更新]
核心建议就一句话:
把必须原子成功的步骤留在全局事务里,把可以补偿的动作移出去。
常见坑与排查
这一部分我尽量写得“接地气”一点,因为很多坑不是文档没写,而是排查时容易想偏。
1. @GlobalTransactional 写了,但根本没生效
常见原因
- 注解加在
private方法上 - 同类内部自调用,AOP 没代理到
- 引用的是普通数据源,不是
DataSourceProxy - 服务没正确注册到 Seata TC
排查方式
先看日志里有没有全局事务 XID,例如:
Begin new global transaction
xid=192.168.1.10:8091:2012345678
如果连 XID 都没有,说明事务压根没开启。
2. 订单回滚了,库存没回滚
常见原因
- 调用下游时 XID 没透传
- 下游服务没有 Seata 依赖或配置错误
- 下游数据库没建
undo_log
排查顺序
- 看上游日志是否生成 XID
- 看下游日志是否拿到相同 XID
- 查下游是否有 branch register 日志
- 查
undo_log是否生成记录
3. 出现全局锁冲突,吞吐突然下降
现象
- 接口 RT 飙升
- 日志里出现 lock conflict / retrying
- 某些商品、账户成为热点
根因
AT 模式需要对修改的数据加全局锁。热点行竞争时,冲突会放大。
处理建议
- 避免多个请求频繁更新同一行
- 库存设计从“单行总库存”改为“分段库存”或“预扣桶”
- 缩短事务执行时间
- 将非核心更新移出全局事务
4. 回滚失败,出现脏数据
常见原因
- 业务 SQL 提交后,被外部事务改写
- 使用了 Seata 不支持或不稳定解析的 SQL 形式
- 手工改数据导致 before image / after image 不一致
我的建议
AT 模式下,关键业务表尽量:
- SQL 简单化
- 不要随意混用触发器
- 避免绕开应用直接改库
- 保持主键更新路径稳定
5. 空回滚与悬挂问题
这类问题在 TCC 更常见,但做模式切换时经常被忽略。
- 空回滚:Try 没执行成功,Cancel 先到了
- 悬挂:Cancel 执行后,迟到的 Try 又来了
如果你后续转向 TCC,一定要在业务表中设计事务状态幂等控制字段。
安全/性能最佳实践
这里我把“安全”和“性能”放一起讲,因为工程里这两件事经常互相影响。
1. 不要把所有操作都塞进一个全局事务
这是最常见的误区。全局事务不是越大越稳,而是越大越慢、越脆弱。
建议控制在:
- 2~4 个核心服务以内
- 单次事务耗时尽量低于 200ms
- 不要夹杂远程查询、复杂计算、第三方调用
2. 外部系统调用不要放进全局事务
比如短信、支付网关、物流接口,这些系统:
- 不受你控制
- 响应不稳定
- 很难回滚
正确做法:
- 事务内只写核心业务状态
- 事务后发消息
- 由异步消费者执行业务扩展动作
3. 热点数据要提前做拆分设计
如果库存表只有一行 product_id=1001,在大促时它一定会变成冲突中心。
可选优化:
- 分桶库存
- 预占库存池
- 本地缓存 + 异步扣减
- 活动库存和日常库存隔离
4. 幂等要独立建设
Seata 能协调事务,但不能替你解决重复请求问题。
至少要有:
- 业务唯一号
- 幂等键
- 防重表或唯一索引
- 消息消费幂等控制
5. 监控指标必须补齐
上线前至少监控这些指标:
- 全局事务数 / 成功率 / 回滚率
- 分支事务注册失败数
- 全局锁冲突次数
- undo_log 增长速度
- 接口 RT P95 / P99
- TC 节点 CPU / 内存 / 连接数
6. 日志里一定打印 XID
这是排查跨服务事务问题的生命线。
我的习惯是把 XID 放进 MDC,这样整个调用链日志都能串起来。
import io.seata.core.context.RootContext;
import org.slf4j.MDC;
String xid = RootContext.getXID();
MDC.put("xid", xid);
7. 数据库层面的安全建议
- 生产环境限制直接改核心业务表
- 核心表变更前做 Seata 兼容性验证
- undo_log 不要随意清理
- 做好备份与回滚预案
什么时候该从 AT 升级到 TCC
很多团队在 Seata AT 跑一段时间后,会遇到边界:
- 库存热点太重
- 回滚逻辑不够可控
- SQL 复杂度越来越高
- 业务需要更明确的预留、确认、取消语义
这时可以把关键链路升级为 TCC:
- Try:预留库存/冻结金额
- Confirm:确认扣减
- Cancel:释放库存/解冻金额
TCC 的代价
- 业务侵入高
- 需要自己写 Confirm/Cancel
- 幂等、空回滚、防悬挂都要处理
TCC 的好处
- 业务可控性更强
- 不依赖 SQL 解析与 undo_log
- 更适合核心高价值链路
所以一个成熟演进路线通常是:
- 先用 AT 快速统一事务框架
- 找出热点和高风险链路
- 对关键路径逐步改造为 TCC
- 非关键链路继续走异步最终一致
总结
Seata 的价值,不在于“帮你神奇地解决分布式事务”,而在于给了你一套工程上可落地的折中方案。
落地时请记住这几个核心判断:
- 先分层,不要所有链路都追求强一致
- AT 适合短事务、关系型数据库、标准 SQL
- 热点行、高并发、长事务,是性能和稳定性的主要敌人
- 异步解耦、幂等、防重、对账,必须和 Seata 配套建设
- 关键高价值链路,可以从 AT 逐步演进到 TCC
如果你现在正准备在订单、库存、账户这类场景接入 Seata,我的建议很直接:
- 第一阶段:只覆盖最核心的 2~3 个写服务
- 第二阶段:补齐 XID 日志、监控、压测、回滚演练
- 第三阶段:识别热点链路,再决定是否升级到 TCC 或拆成异步流程
最后一句经验话:
分布式事务从来不是“框架选型题”,而是“一致性边界设计题”。框架只是工具,边界划对了,系统才稳。