面试官:分布式事务怎么保证一致性?别再说 2PC 和 TCC 了——Seata AT 模式的正确打开方式
引言
面试现场,面试官抛出经典问题:"分布式事务怎么保证一致性?"
90% 的候选人脱口而出:"2PC 和 TCC。"
面试官追问:"你项目里用的哪个?"
候选人:"……了解过原理,没用过。"
这篇文章不讲理论,只讲实战。从一个转账场景出发,带你走完 2PC 的坑、TCC 的重、最后落地 Seata AT 模式——对业务零侵入,自动回滚,这才是生产环境真正在用的方案。
一、问题背景:跨服务转账
1.1 场景描述
用户 A 向用户 B 转账 100 元,涉及两个服务:
转账服务(Transfer Service)
├── 账户服务 A(Account Service A):扣减 A 的余额
└── 账户服务 B(Account Service B):增加 B 的余额
两个服务连的是不同的数据库,本地事务管不了跨库操作。
1.2 问题在哪
@Service
public class TransferService {
@Autowired
private AccountServiceA accountServiceA; // RPC 调用
@Autowired
private AccountServiceB accountServiceB; // RPC 调用
@Transactional // 本地事务,只能管自己的库
public void transfer(Long fromId, Long toId, BigDecimal amount) {
accountServiceA.decrease(fromId, amount); // A 扣钱
// 异常发生在这里 ↓
int i = 1 / 0;
accountServiceB.increase(toId, amount); // B 加钱(没执行)
}
}
问题:A 扣了钱,B 没加上,钱凭空消失了。
1.3 三个候选方案
| 方案 | 一致性 | 侵入性 | 性能 | 复杂度 |
|---|---|---|---|---|
| 2PC(XA) | 强一致 | 低 | 差(同步阻塞) | 中 |
| TCC | 强一致 | 高(手写 3 个方法) | 中 | 高 |
| Seata AT | 强一致 | 低(注解) | 中 | 低 |
二、2PC:教科书里的理想主义
2.1 原理
两阶段提交:
阶段一:准备(Prepare)
协调者 → 所有参与者:"准备提交了吗?"
参与者:"准备好了" / "不行"
阶段二:提交/回滚(Commit/Rollback)
如果都准备好了 → 协调者发"提交"
如果有任何一个没准备好 → 协调者发"回滚"
2.2 为什么生产环境很少用
缺陷 1:同步阻塞
参与者执行 Prepare 后,会锁住资源,直到收到 Commit/Rollback。
如果协调者挂了,参与者会一直锁着,等其他参与者也卡住。
转账场景下:A 账户的钱被锁住,整个账户服务卡死。
缺陷 2:单点故障
协调者挂了,所有参与者都处于"等待"状态,无法自行决定提交还是回滚。
缺陷 3:数据不一致
阶段二中,协调者发送 Commit,部分参与者收到并提交了,部分没收到。
此时数据就不一致了。
MySQL XA 的真实表现:
| 指标 | 本地事务 | XA 事务 |
|---|---|---|
| 单次转账耗时 | 5ms | 50-100ms |
| 并发能力 | 高 | 低(资源锁定) |
| 死锁概率 | 低 | 高 |
结论:2PC 理论完美,工程上几乎不用。
三、TCC:能做但太重
3.1 原理
Try-Confirm-Cancel,把一个操作拆成三个:
// 转账的 TCC 实现
public interface TransferTccService {
// Try:预留资源(冻结金额)
boolean tryTransfer(Long fromId, Long toId, BigDecimal amount);
// Confirm:确认提交(扣减冻结,实际转账)
boolean confirmTransfer(Long fromId, Long toId, BigDecimal amount);
// Cancel:取消(解冻)
boolean cancelTransfer(Long fromId, Long toId, BigDecimal amount);
}
3.2 业务侵入严重
一个转账要写三个方法,每个方法都要实现:
// Try:冻结 A 的 100 元
public boolean tryTransfer(Long fromId, Long toId, BigDecimal amount) {
// 1. 检查 A 余额是否足够
Account account = accountMapper.selectById(fromId);
if (account.getBalance().compareTo(amount) < 0) {
throw new RuntimeException("余额不足");
}
// 2. 扣减余额,增加冻结金额
accountMapper.decreaseBalance(fromId, amount);
accountMapper.increaseFrozen(fromId, amount);
// B 的 Try 可以空操作,也可以预先增加
return true;
}
// Confirm:实际转账
public boolean confirmTransfer(Long fromId, Long toId, BigDecimal amount) {
// 1. A:扣减冻结金额
accountMapper.decreaseFrozen(fromId, amount);
// 2. B:增加余额
accountMapper.increaseBalance(toId, amount);
return true;
}
// Cancel:解冻
public boolean cancelTransfer(Long fromId, Long toId, BigDecimal amount) {
// A:解冻,恢复余额
accountMapper.decreaseFrozen(fromId, amount);
accountMapper.increaseBalance(fromId, amount);
return true;
}
3.3 还要解决的坑
- 空回滚:Try 没执行,Cancel 却被调用了
- 幂等性:Confirm/Cancel 可能被重试多次
- 悬挂:Cancel 先于 Try 执行
每个坑都要写代码解决。一个转账业务,TCC 要写 3 倍的代码 + 额外的容错逻辑。
3.4 适用场景
只有资源需要"预留"的场景才适合 TCC:
| 场景 | 是否适合 TCC | 原因 |
|---|---|---|
| 转账 | 不适合 | 钱可以直接扣,不需要预留 |
| 库存扣减 | 适合 | 真实库存有限,需要预占 |
| 机票预订 | 适合 | 座位有限,需要锁座 |
大部分业务场景,并不需要"预留"资源,直接扣减就行。这时候 AT 模式更合适。
四、Seata AT 模式:生产首选
4.1 为什么选 AT
| 对比项 | 2PC | TCC | AT |
|---|---|---|---|
| 业务侵入 | 无 | 高(3 方法) | 无(注解) |
| 一致性 | 强 | 强 | 强 |
| 性能 | 差 | 中 | 中 |
| 实现复杂度 | 中 | 高 | 低 |
| 学习成本 | 低 | 高 | 低 |
AT = Automatic Transaction,自动事务。核心思想:自动生成回滚 SQL,对业务零侵入。
4.2 核心概念
┌─────────────────────────────────────────────┐
│ TC(Transaction Coordinator) │
│ 事务协调器(独立部署) │
└─────────────────────────────────────────────┘
▲ ▲
│ 注册/上报 │ 注册/上报
│ │
┌────────────┴──────┐ ┌──────────┴──────────┐
│ TM(事务管理器) │ │ RM(资源管理器) │
│ 开启/提交/回滚 │ │ 分支事务执行+回滚 │
│ @GlobalTransactional │ │ 自动生成 undo_log │
└───────────────────┘ └─────────────────────┘
转账服务 账户服务 A/B
三个角色:
- TC(Transaction Coordinator):事务协调器,独立部署,负责维护全局事务状态
- TM(Transaction Manager):事务管理器,开启全局事务,决定提交还是回滚
- RM(Resource Manager):资源管理器,每个微服务都是一个 RM,负责本地事务执行和回滚
4.3 工作流程
1. TM 向 TC 开启全局事务,获得 XID(全局事务 ID)
2. XID 通过 RPC 请求传播到各个 RM
3. 每个 RM 执行本地事务前,先记录 undo_log(前镜像)
4. RM 执行本地业务 SQL
5. RM 记录 undo_log(后镜像),向 TC 注册分支事务
6. 所有 RM 执行完毕,TM 向 TC 发起提交/回滚
7. 如果提交:异步删除 undo_log
8. 如果回滚:根据 undo_log 生成反向 SQL 执行回滚
4.4 undo_log:自动回滚的魔法
以扣减 A 账户余额为例:
执行前(前镜像):
SELECT * FROM account WHERE id = 1;
-- id=1, balance=500, frozen=0
执行业务 SQL:
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- id=1, balance=400, frozen=0
执行后(后镜像):
SELECT * FROM account WHERE id = 1;
-- id=1, balance=400, frozen=0
生成的 undo_log(存储在 undo_log 表):
{
"branchId": 123,
"beforeImage": {"id": 1, "balance": 500, "frozen": 0},
"afterImage": {"id": 1, "balance": 400, "frozen": 0},
"sqlType": "UPDATE"
}
如果全局事务回滚,Seata 根据 beforeImage 自动生成反向 SQL:
UPDATE account SET balance = 500 WHERE id = 1;
完全自动,业务代码不用管。
五、实战:Seata AT 转账
5.1 环境搭建
docker-compose.yml:
version: '3.8'
services:
# Seata Server(TC)
seata-server:
image: seataio/seata-server:2.0.0
container_name: seata-server
ports:
- "8091:8091"
- "7091:7091"
environment:
- SEATA_IP=seata-server
- STORE_MODE=db
- SEATA_CONFIG_NAME=file:/root/seata-config/registry
# 账户服务 A 的数据库
mysql-a:
image: mysql:8.0
container_name: mysql-a
environment:
MYSQL_ROOT_PASSWORD: root123
MYSQL_DATABASE: account_a
ports:
- "3306:3306"
# 账户服务 B 的数据库
mysql-b:
image: mysql:8.0
container_name: mysql-b
environment:
MYSQL_ROOT_PASSWORD: root123
MYSQL_DATABASE: account_b
ports:
- "3307:3306"
# Nacos(Seata 配置中心)
nacos:
image: nacos/nacos-server:v2.3.0
container_name: nacos
ports:
- "8848:8848"
environment:
- MODE=standalone
5.2 数据库准备
每个业务库都要建 undo_log 表:
-- account_a 库和 account_b 库都要执行
CREATE TABLE undo_log (
branch_id BIGINT NOT NULL COMMENT '分支事务ID',
xid VARCHAR(128) NOT NULL COMMENT '全局事务ID',
context VARCHAR(128) NOT NULL COMMENT '上下文',
rollback_info LONGBLOB NOT NULL COMMENT '回滚信息',
log_status INT NOT NULL COMMENT '状态',
log_created DATETIME(6) NOT NULL COMMENT '创建时间',
log_modified DATETIME(6) NOT NULL COMMENT '修改时间',
UNIQUE KEY ux_undo_log (xid, branch_id)
) ENGINE=InnoDB COMMENT='AT模式 undo_log 表';
-- 账户表
CREATE TABLE account (
id BIGINT PRIMARY KEY,
user_id BIGINT,
balance DECIMAL(10,2),
frozen DECIMAL(10,2) DEFAULT 0
) ENGINE=InnoDB;
5.3 添加依赖
<!-- Seata Spring Boot Starter -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<version>2023.0.1.0</version>
</dependency>
<!-- Seata AT 模式依赖 -->
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>2.0.0</version>
</dependency>
5.4 配置文件
seata:
enabled: true
application-id: transfer-service
tx-service-group: my_tx_group
service:
vgroup-mapping:
my_tx_group: default
grouplist:
default: seata-server:8091
data-source-proxy-mode: AT # 关键:AT 模式
spring:
datasource:
url: jdbc:mysql://mysql-a:3306/account_a
username: root
password: root123
driver-class-name: com.mysql.cj.jdbc.Driver
5.5 业务代码
转账服务(TM):
@Service
public class TransferService {
@Autowired
private AccountClient accountClient; // Feign 调用账户服务
/**
* 转账入口,开启全局事务
*/
@GlobalTransactional // 关键:开启全局事务
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 1. A 账户扣钱(调用账户服务 A)
accountClient.decrease(fromId, amount);
// 2. 模拟异常
if (amount.compareTo(new BigDecimal("1000")) > 0) {
throw new RuntimeException("超过单笔限额");
}
// 3. B 账户加钱(调用账户服务 B)
accountClient.increase(toId, amount);
}
}
账户服务 A(RM):
@Service
public class AccountServiceA {
@Autowired
private AccountMapper accountMapper;
/**
* 注意:这里用本地事务注解即可
* Seata 会自动代理 DataSource,记录 undo_log
*/
@Transactional
public void decrease(Long userId, BigDecimal amount) {
Account account = accountMapper.selectByUserId(userId);
if (account.getBalance().compareTo(amount) < 0) {
throw new RuntimeException("余额不足");
}
accountMapper.decreaseBalance(userId, amount);
}
}
账户服务 B(RM):
@Service
public class AccountServiceB {
@Autowired
private AccountMapper accountMapper;
@Transactional
public void increase(Long userId, BigDecimal amount) {
accountMapper.increaseBalance(userId, amount);
}
}
Feign 客户端(XID 自动传播):
@FeignClient(name = "account-service")
public interface AccountClient {
@PostMapping("/account/decrease")
void decrease(@RequestParam Long userId, @RequestParam BigDecimal amount);
@PostMapping("/account/increase")
void increase(@RequestParam Long userId, @RequestParam BigDecimal amount);
}
XID 自动通过 HTTP Header 传播,不需要手动传。
5.6 验证
# 正常转账
curl -X POST 'http://localhost:8080/transfer?fromId=1&toId=2&amount=100'
# A 余额 500→400,B 余额 300→400
# 异常转账(超过 1000 触发回滚)
curl -X POST 'http://localhost:8080/transfer?fromId=1&toId=2&amount=1500'
# A 扣了钱 → 触发异常 → 全局事务回滚 → A 余额恢复
查看 Seata 控制台 http://localhost:7091 :
全局事务 XID: 192.168.1.10:8091:123456789
分支事务 1: account_a.decrease → 已回滚
分支事务 2: account_b.increase → 未执行
状态:Rollbacked
六、@GlobalTransactional 使用误区
6.1 误区一:所有方法都加 @GlobalTransactional
// ❌ 错误:内层方法也加
@Service
public class TransferService {
@GlobalTransactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
accountClient.decrease(fromId, amount);
accountClient.increase(toId, amount);
}
}
// 内层服务也加(错误)
@Service
public class AccountServiceA {
@GlobalTransactional // ❌ 不应该加!
@Transactional
public void decrease(Long userId, BigDecimal amount) {
accountMapper.decreaseBalance(userId, amount);
}
}
正确做法:只有入口方法(TM)加 @GlobalTransactional,内层方法(RM)只加 @Transactional。
@Service
public class AccountServiceA {
@Transactional // ✅ 本地事务即可
public void decrease(Long userId, BigDecimal amount) {
accountMapper.decreaseBalance(userId, amount);
}
}
原因:内层方法加 @GlobalTransactional 会开启新的全局事务,导致事务传播混乱。
6.2 误区二:忘记加 @Transactional
// ❌ 错误:RM 方法没有本地事务
@Service
public class AccountServiceA {
// 漏了 @Transactional
public void decrease(Long userId, BigDecimal amount) {
accountMapper.decreaseBalance(userId, amount);
}
}
原因:AT 模式的 undo_log 是在本地事务提交时一起记录的。如果没有 @Transactional,undo_log 不会和业务 SQL 在同一事务里,回滚时数据不一致。
6.3 误区三:手动控制连接
// ❌ 错误:手动获取连接,绕过了 Seata 代理
@Service
public class AccountServiceA {
public void decrease(Long userId, BigDecimal amount) throws SQLException {
Connection conn = DriverManager.getConnection(url, user, pwd);
PreparedStatement ps = conn.prepareStatement("UPDATE account SET balance = balance - ? WHERE user_id = ?");
ps.setBigDecimal(1, amount);
ps.setLong(2, userId);
ps.executeUpdate();
conn.close();
}
}
原因:Seata AT 通过代理 DataSource 来记录 undo_log。手动获取连接绕过了代理,Seata 感知不到,回滚失败。
6.4 误区四:在 @GlobalTransactional 中执行耗时操作
// ❌ 错误:全局事务中调用外部接口,耗时长
@GlobalTransactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
accountClient.decrease(fromId, amount);
// 调用短信通知(耗时 5 秒)
smsClient.notify(fromId, "转出成功"); // ❌ 全局事务超时
accountClient.increase(toId, amount);
}
原因:全局事务默认超时 60 秒。在事务中调用慢接口会导致超时回滚。
正确做法:非核心操作放到事务外。
@GlobalTransactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
accountClient.decrease(fromId, amount);
accountClient.increase(toId, amount);
}
// 事务外调用
public void transferAndNotify(Long fromId, Long toId, BigDecimal amount) {
transfer(fromId, toId, amount); // 事务
smsClient.notify(fromId, "转出成功"); // 事务外
}
6.5 误区五:嵌套调用同一个 RM
// ❌ 错误:A 调 B,B 又调 A
@Service
public class ServiceA {
@GlobalTransactional
public void methodA() {
serviceB.methodB(); // 调 B
}
}
@Service
public class ServiceB {
public void methodB() {
serviceA.methodC(); // 又调回 A
}
}
原因:循环依赖会导致死锁或事务状态混乱。
七、AT 模式的坑与注意点
7.1 写隔离(脏写问题)
场景:
事务 T1:UPDATE account SET balance = 400 WHERE id = 1
事务 T2:UPDATE account SET balance = 300 WHERE id = 1 ← 脏读 T1 未提交的数据
AT 模式的解决:全局锁。
T1 执行时,先获取 id=1 的全局锁。
T2 执行前,也要获取全局锁,发现被 T1 占用 → 等待。
T1 提交/回滚后,释放全局锁,T2 才能执行。
7.2 读隔离(脏读问题)
场景:
事务 T1:UPDATE account SET balance = 400 WHERE id = 1(未提交)
事务 T2:SELECT balance FROM account WHERE id = 1 ← 读到 400(脏读)
AT 模式默认是读未提交。
解决方案:用 @GlobalLock + @Transactional 强制读已提交。
@GlobalLock // 先查后改的场景,加全局锁
@Transactional
public Account selectForUpdate(Long id) {
return accountMapper.selectById(id);
}
7.3 不支持的 SQL
AT 模式通过解析 SQL 生成 undo_log,以下 SQL 不支持:
| SQL 类型 | 是否支持 | 原因 |
|---|---|---|
| INSERT | ✅ | - |
| UPDATE | ✅ | - |
| DELETE | ✅ | - |
| SELECT FOR UPDATE | ✅ | - |
| 带子查询的 UPDATE | ❌ | 解析复杂 |
| 存储过程 | ❌ | 无法解析 |
| 多表 JOIN UPDATE | ❌ | 无法确定回滚范围 |
7.4 性能影响
| 操作 | 本地事务 | AT 模式 | 说明 |
|---|---|---|---|
| 单次 SQL | 5ms | 15-20ms | 多了 undo_log 记录 |
| 远程 RPC | - | 50-100ms | TC 通信 |
| 回滚 | 5ms | 20-30ms | 解析 undo_log + 反向 SQL |
全局事务不要包太多操作,控制在 3-5 个分支事务内。
八、AT vs TCC vs SAGA:选哪个
| 维度 | AT | TCC | SAGA |
|---|---|---|---|
| 一致性 | 强 | 强 | 最终一致 |
| 侵入性 | 无 | 高 | 中 |
| 性能 | 中 | 中 | 高 |
| 适用场景 | 大部分业务 | 资源预留 | 长事务 |
| 复杂度 | 低 | 高 | 中 |
8.1 AT 适合
- 90% 的常规业务(转账、订单、库存)
- 不想改业务代码
- 团队对分布式事务不熟
8.2 TCC 适合
- 库存扣减(需要预占)
- 机票/酒店预订(需要锁资源)
- 资源有限,需要先占后用
8.3 SAGA 适合
- 长事务(流程长,跨度大)
- 涉及第三方系统(无法 undo)
- 对性能要求高,容忍最终一致
九、总结
选择决策树
是否需要强一致性?
├── 否 → 消息队列 + 本地事务表(最终一致)
└── 是 → 业务能否接受零侵入?
├── 是 → Seata AT 模式(生产首选)
└── 否 → 是否需要资源预留?
├── 是 → TCC
└── 否 → SAGA
三种方案一句话总结
| 方案 | 一句话 |
|---|---|
| 2PC | 理论完美,工程不用 |
| TCC | 能用但太重,业务侵入大 |
| AT | 生产首选,零侵入自动回滚 |
面试正确回答
面试官问:"分布式事务怎么保证一致性?"
回答:"我们生产环境主要用 Seata AT 模式。它通过全局事务协调器+分支事务+undo_log 表实现,对业务零侵入,只需要在入口方法加 @GlobalTransactional 注解。相比 2PC 没有同步阻塞问题,相比 TCC 不用手写 try/confirm/cancel 三个方法。核心原理是 RM 执行本地事务时自动记录前镜像和后镜像,如果全局事务回滚,根据 undo_log 自动生成反向 SQL 回滚。需要注意全局事务超时、写隔离用全局锁、以及不要在事务里做耗时操作。"
这个回答,比一句"2PC 和 TCC"强 10 倍。
互动话题:你们项目用的哪种分布式事务方案?有没有踩过 Seata 的坑?欢迎留言讨论!
参考资料
标题:面试官:分布式事务怎么保证一致性?别再说 2PC 和 TCC 了——Seata AT 模式的正确打开方式
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/08/08/1786155894220.html
公众号:服务端技术精选
- 引言
- 一、问题背景:跨服务转账
- 1.1 场景描述
- 1.2 问题在哪
- 1.3 三个候选方案
- 二、2PC:教科书里的理想主义
- 2.1 原理
- 2.2 为什么生产环境很少用
- 三、TCC:能做但太重
- 3.1 原理
- 3.2 业务侵入严重
- 3.3 还要解决的坑
- 3.4 适用场景
- 四、Seata AT 模式:生产首选
- 4.1 为什么选 AT
- 4.2 核心概念
- 4.3 工作流程
- 4.4 undo_log:自动回滚的魔法
- 五、实战:Seata AT 转账
- 5.1 环境搭建
- 5.2 数据库准备
- 5.3 添加依赖
- 5.4 配置文件
- 5.5 业务代码
- 5.6 验证
- 六、@GlobalTransactional 使用误区
- 6.1 误区一:所有方法都加 @GlobalTransactional
- 6.2 误区二:忘记加 @Transactional
- 6.3 误区三:手动控制连接
- 6.4 误区四:在 @GlobalTransactional 中执行耗时操作
- 6.5 误区五:嵌套调用同一个 RM
- 七、AT 模式的坑与注意点
- 7.1 写隔离(脏写问题)
- 7.2 读隔离(脏读问题)
- 7.3 不支持的 SQL
- 7.4 性能影响
- 八、AT vs TCC vs SAGA:选哪个
- 8.1 AT 适合
- 8.2 TCC 适合
- 8.3 SAGA 适合
- 九、总结
- 选择决策树
- 三种方案一句话总结
- 面试正确回答
- 参考资料
评论