用户下单,涉及三个微服务:
订单服务:创建订单,状态改为“待支付”
支付服务:扣用户余额
库存服务:扣减商品库存
订单创建成功、支付扣款成功,但库存扣减失败——用户的钱已经扣了,库存没扣,这就超卖了。
单机数据库一个事务就能搞定,但在分布式环境里,一个事务跨越三个服务、三个数据库,MySQL 的 BEGIN / COMMIT 管不了。
分布式事务就是来解决这种问题的。
白话版

两种主流方案,用两个生活场景来类比:
2PC(两阶段提交) — 像婚礼上的交换戒指仪式:
第一阶段(准备阶段):
司仪问:“新郎,你愿意娶新娘为妻吗?”
新郎回答:“我愿意”
司仪问:“新娘,你愿意嫁给新郎为妻吗?”
新娘回答:“我愿意”
第二阶段(提交阶段):
司仪宣布:“礼成!交换戒指!”
(双方都说了愿意,仪式才正式完成)
如果任何一方说“不愿意”,仪式取消
TCC(Try-Confirm-Cancel) — 像去餐厅订大餐:
Try(预订):提前打电话给餐厅,“我下周五要订一桌 10 人的包间,先帮我预留”
(餐厅锁定了包间,但还没正式消费)
Confirm(确认):下周五你到店了,“确认用餐,上菜吧!”
(正式扣款,真正消费)
Cancel(取消):如果你临时取消,“不好意思去不了了”
(餐厅释放包间,不需要付钱)
方案一:2PC(两阶段提交)
2PC 是最经典的分布式事务方案,由一个协调者(Coordinator)和多个参与者(Participant)组成。
第一阶段:准备阶段(Prepare)
协调者(事务管理器)
│
├──→ 订单服务:准备创建订单,写入 UNDO/REDO 日志
│ ← 响应:准备就绪 ✅
│
├──→ 支付服务:准备扣款,冻结余额,写入 UNDO/REDO 日志
│ ← 响应:准备就绪 ✅
│
└──→ 库存服务:准备扣库存,写入 UNDO/REDO 日志
← 响应:准备就绪 ✅
如果任何一个参与者返回“准备失败”,协调者会通知所有参与者回滚。
第二阶段:提交阶段(Commit)
协调者(事务管理器)
│
├──→ 订单服务:提交 ✅
├──→ 支付服务:提交 ✅
└──→ 库存服务:提交 ✅
协调者一旦宕机,参与者就会进入“不确定状态”——不知道应该提交还是回滚。这就是 2PC 最大的问题:阻塞。
2PC 的缺陷
| 问题 |
说明 |
后果 |
| 同步阻塞 |
第一阶段所有参与者都必须等待协调者指令 |
资源被锁定,无法释放 |
| 单点故障 |
协调者挂了,整个事务卡住 |
参与者永远处于“待定”状态 |
| 数据不一致 |
第二阶段部分参与者提交成功,部分失败 |
协调者也挂了,没人知道该回滚还是提交 |
| 性能差 |
所有参与者都要同步等待,延迟高 |
不适合高并发场景 |
2PC 适用于传统单体架构的跨数据库事务,不适合微服务的高并发场景。
方案二:TCC(Try-Confirm-Cancel)

TCC 是分布式事务中最常用的“柔性事务”方案,它把每个服务的操作拆成三个阶段:
Try 阶段:资源预留
检查业务资源是否可用,并预留资源。
// 支付服务 - Try
public boolean tryPay(long userId, long amount) {
// 1. 检查余额是否充足
Account account = accountDao.getByUserId(userId);
if (account.getBalance() < amount) {
return false; // 余额不足,Try 失败
}
// 2. 冻结资金(不扣款,只是预留)
account.setFrozenAmount(account.getFrozenAmount() + amount);
accountDao.update(account);
// 3. 记录事务日志,用于后续 Confirm/Cancel 时的幂等
transactionLogDao.save(new TransactionLog(userId, amount, "TRY"));
return true;
}
Confirm 阶段:正式执行
确认执行,真正扣款。Confirm 必须保证幂等(多次执行结果一样)。
// 支付服务 - Confirm
public boolean confirmPay(long userId, long amount) {
// 1. 幂等检查:是否已经 Confirm 过了?
TransactionLog log = transactionLogDao.getByUserIdAndAmount(userId, amount);
if (log.getStatus() == "CONFIRMED") {
return true; // 已经确认过了,直接返回成功
}
// 2. 真正扣款:从冻结资金中扣减
Account account = accountDao.getByUserId(userId);
account.setBalance(account.getBalance() - amount);
account.setFrozenAmount(account.getFrozenAmount() - amount);
accountDao.update(account);
// 3. 更新事务日志
log.setStatus("CONFIRMED");
transactionLogDao.update(log);
return true;
}
Cancel 阶段:取消回滚
取消操作,释放 Try 阶段预留的资源。Cancel 也必须保证幂等。
// 支付服务 - Cancel
public boolean cancelPay(long userId, long amount) {
// 1. 幂等检查:是否已经 Cancel 过了?
TransactionLog log = transactionLogDao.getByUserIdAndAmount(userId, amount);
if (log.getStatus() == "CANCELLED") {
return true; // 已经取消过了,直接返回
}
// 2. 释放冻结资金
Account account = accountDao.getByUserId(userId);
account.setFrozenAmount(account.getFrozenAmount() - amount);
accountDao.update(account);
// 3. 更新事务日志
log.setStatus("CANCELLED");
transactionLogDao.update(log);
return true;
}
TCC 与 2PC 的对比
|
2PC |
TCC |
| 资源锁定 |
数据库层面锁行记录 |
业务层面预留资源(冻结/预占) |
| 性能 |
差(同步阻塞,持有锁) |
好(业务层预占,不阻塞数据库) |
| 一致性 |
强一致(但可能阻塞) |
最终一致 |
| 实现复杂度 |
低(数据库原生支持) |
高(需要自己写 Try/Confirm/Cancel) |
| 适用场景 |
跨数据库事务,并发量低 |
微服务架构,高并发 |
| 幂等要求 |
不要求 |
Confirm 和 Cancel 必须幂等 |
| 典型框架 |
Atomikos, Seata AT |
Seata TCC, ByteTCC |
TCC 的常见问题
1. 空回滚
Try 还没调用,就收到了 Cancel。比如网络超时,协调者误以为 Try 失败,但实际 Try 根本未执行。
解法:在 Cancel 中判断是否有 Try 的记录,如果没有,直接返回成功(视为空回滚)。
2. 幂等控制
Confirm 和 Cancel 可能被调用多次,需要保证多次执行结果相同。
解法:使用事务日志表记录执行状态,每次执行前检查。
3. 悬挂
Cancel 先于 Try 执行(网络乱序),之后 Try 才到,导致预留的资源既不会被 Confirm 也不会被 Cancel。
解法:Try 中先检查是否已有 Cancel 记录,如果有,跳过预留操作。
什么时候用分布式事务,什么时候不用?
| 场景 |
推荐方案 |
| 跨数据库的强一致需求 |
2PC(Seata AT 模式) |
| 微服务间的高并发事务 |
TCC(Seata TCC 模式) |
| 非关键路径,允许短暂不一致 |
最终一致性(MQ 异步消息 + 补偿) |
| 不能容忍任何失败 |
本地事务 + 本地消息表 + 定时补偿 |
| 最简单的方式 |
尽量避免分布式事务,通过业务设计规避 |
小结
分布式事务在微服务架构中绕不开。核心思路只有两种:
• 2PC(两阶段提交) — 像婚礼上的“我愿意”,所有人准备好了才正式执行。简单,但会阻塞,适合低并发场景。
• TCC(Try-Confirm-Cancel) — 像订餐厅包间,先预留再确认。灵活但实现复杂,适合高并发微服务场景。
但说实话,最好的分布式事务策略就是:尽量避免分布式事务。通过合理的业务设计(比如把相关功能合并到同一个服务、用最终一致性代替强一致),很多时候你根本不需要分布式事务。如果你想深入了解分布式系统设计,欢迎访问云栈社区的后端 & 架构板块。