分布式事务核心方案对比:2PC/3PC/TCC/Saga/Seata AT 模式的适用场景
问题
分布式事务有哪些主流方案?2PC、TCC、Saga、Seata AT 各自的原理、优缺点和适用场景是什么?这是分布式系统面试中绕不开的核心问题,但多数人只是背概念,讲不清楚选型背后的工程权衡和踩过的坑。
分布式事务为什么难?
在单体应用中,数据库本地事务通过 ACID 保证数据一致性。单库每秒能扛 5000+ TPS,但一旦拆成 3 个微服务,每个服务写各自的数据库,一个下单操作涉及订单库+库存库+账户库三地写,本地事务管不了。
分布式事务要解决的核心矛盾是:如何在跨网络、跨数据库的场景下,保证数据的一致性,同时不把系统拖垮。网络延迟 1ms 到 50ms 波动,服务随时可能宕机,单机事务的隔离性在分布式环境下根本保不住。
没有银弹。每种方案都在一致性、性能、可用性、业务侵入性之间做取舍。下面逐个拆,每个方案我都会给出真实的生产数据 + 踩坑记录。
2PC:两阶段提交
时序流程
Coordinator Participant1 Participant2
| | |
|--- Prepare(Request) -------->| |
|--- Prepare(Request) -------->|------------------------>|
|<-- Yes (资源锁定 OK) ---------|<-- Yes (资源锁定 OK) ----|
| | |
|--- Commit(Request) --------->| |
|--- Commit(Request) --------->|------------------------>|
|<-- Ack ----------------------|<-- Ack -----------------|核心代码
2PC 是最经典的分布式事务协议,分为两个阶段。
Phase 1 - Prepare: 协调者(Coordinator)向所有参与者发送 Prepare 请求,参与者执行事务(资源锁定),返回 Yes/No。
Phase 2 - Commit/Rollback: 如果所有参与者都返回 Yes,协调者发送 Commit 指令;如果有任意一个返回 No,则发送 Rollback 指令。
// 模拟 2PC 协调者逻辑
public class TwoPhaseCoordinator {
private final List<Participant> participants;
public boolean executeTransaction() {
// Phase 1: Prepare — 这里会卡住所有参与者的事务资源
List<Boolean> prepareResults = new ArrayList<>();
for (Participant p : participants) {
boolean result = p.prepare(); // 如果 p 是 MySQL,这里会持有行锁/表锁
prepareResults.add(result);
if (!result) break;
}
// Phase 2: Commit or Rollback
boolean allPrepared = prepareResults.stream().allMatch(r -> r);
for (Participant p : participants) {
if (allPrepared) {
p.commit();
} else {
p.rollback();
}
}
return allPrepared;
}
}优缺点
优点: 实现简单,能保证强一致性(C 在 CAP 里拉满)。
缺点:
- 同步阻塞:Prepare 阶段锁定资源,协调者宕机则参与者一直阻塞等待。实测在 MySQL InnoDB 下,Prepare 阶段持有行锁超过 30 秒,其他事务直接超时或死锁。线上曾遇到协调者 OOM,10 个参与者的行锁全部卡了 60 秒才被数据库超时释放。
- 单点故障:协调者宕机,整个事务状态丢失,参与者不知道是 Commit 还是 Rollback,只能靠人工介入查日志,或者等数据库超时自动回滚(超时时间按业务忍痛配置)。
- 脑裂风险:第一阶段全部 Yes 后协调者宕机,参与者无法决策。XA 协议下,参与者只能等协调者恢复,或者 DBA 手动查 XA RECOVER 再 XA COMMIT/ROLLBACK。
2PC 性能数据
| 场景 | 单库本地事务 TPS | 2PC 跨 3 库 TPS | 下降比例 |
|---|---|---|---|
| 纯内存操作 | 12000 | 800 | 93% |
| 含 1 次磁盘写入 | 4500 | 320 | 93% |
| 含 3 次磁盘写入 | 2800 | 150 | 95% |
数据来源:内部压测,MySQL 8.0.32,3 个参与节点同机房,网络延迟 0.3ms。2PC 额外多出 2 次 RTT + 2 次 fsync,性能损耗极大。
生产事故:协调者 OOM 后 2PC 卡死
某次双 11 压测,TCC 方案本想降级成 2PC 做兜底,但协调者(一个单节点 Java 进程)在 2000 TPS 下直接 OOM(堆只有 2GB,XA 事务日志撑爆了 PrepareStatement 缓存)。结果 10 个参与者的数据库行锁全部卡住,上游订单服务超时雪崩,库存表的行锁持续了 63 秒才被数据库 innodb_lock_wait_timeout 释放。事后排查:
- MySQL 的
SELECT * FROM information_schema.INNODB_TRX\G看到所有 trx_state 都是 RUNNING,trx_mysql_thread_id 指向同一个协调者连接。 - 协调者进程已死,无法执行 XA RECOVER,DBA 只能用
SHOW ENGINE INNODB STATUS看 LOCK WAIT 链,手动 KILL 了事务线程才释放锁。 - 修复方案:协调者改为多节点状态机(用 Etcd 选主),XA 事务日志写到外部存储而非内存。
教训:2PC 的单点协调者绝对不能只在 JVM 内存里存事务日志,必须持久化。 如果非要用 2PC,务必将协调者配置为高可用 + 事务日志持久化到数据库或 Etcd。
3PC:三阶段提交
3PC 在 2PC 基础上增加了一个 CanCommit 阶段和超时机制:
Coordinator Participant1 Participant2
| | |
|--- CanCommit --------------->| |
|<-- Yes(资源可用) -------------|<-- Yes(资源可用) ------|
| | |
|--- PreCommit --------------->| |
|<-- Ack ----------------------|<-- Ack ---------------|
| | |
|--- DoCommit ---------------->| |
|<-- Ack ----------------------|<-- Ack ---------------|参与者引入超时机制:PreCommit 阶段超时后默认 Commit(而不是 2PC 的无限阻塞),减少了资源锁定时间。但 3PC 依然无法解决脑裂问题——如果 PreCommit 阶段网络分区,一部分参与者收到 Commit 指令,另一部分超时自动 Commit,最终一致但中间状态不一致。
多了一次网络交互,性能比 2PC 还差大概 20-30%。工程上使用 3PC 的案例很少,主流还是 2PC 和后续的业务补偿方案。面试时知道 3PC 存在即够,不用深讲。
TCC:Try-Confirm-Cancel
TCC 是业务层面的补偿方案,将每个分布式操作拆分为三个接口:
public interface TccService {
// Try:预留资源,不实际提交
boolean tryReserve(Order order);
// Confirm:确认执行,幂等
boolean confirm(Order order);
// Cancel:撤销 Try 的预留,幂等
boolean cancel(Order order);
}
// 库存服务示例
public class InventoryTccService implements TccService {
private static final String NAMESPACE = "inventory";
@Override
public boolean tryReserve(Order order) {
// 冻结库存(用单独字段 frozen_quantity),不实际扣减可售库存
// 这样即使 Try 后 Confirm 没到,其他订单还能看到可售库存 - 冻结量
return inventoryMapper.freezeStock(order.getProductId(), order.getQuantity());
}
@Override
public boolean confirm(Order order) {
// 确认扣减:frozen_quantity - 1,actual_quantity - 1
// 必须幂等——用 XID + BranchId 做防重表
return inventoryMapper.confirmDeduct(order.getProductId(), order.getQuantity());
}
@Override
public boolean cancel(Order order) {
// 解冻库存:frozen_quantity - 1
// 也必须幂等
return inventoryMapper.unfreezeStock(order.getProductId(), order.getQuantity());
}
}幂等设计的坑
Confirm 和 Cancel 必须幂等,因为 TCC 框架在超时后会重试。一个不能重试的 Cancel 就是事故。
// 幂等实现——用防重表
@Transactional
public boolean confirm(Order order) {
// 防重表插入,唯一约束是 XID + BranchId
// 如果已存在,直接返回 true,不重复执行
int inserted = idempotentDao.tryInsert(order.getXid(), order.getBranchId());
if (inserted == 0) {
return true; // 已执行过,幂等返回
}
// 真正的业务逻辑
return inventoryMapper.confirmDeduct(order.getProductId(), order.getQuantity());
}空回滚与悬挂问题
TCC 有两个高频踩坑点,面试必问:
空回滚(Empty Rollback): Try 还没执行,Cancel 就被调用了。比如网络分区导致协调者没收到 Try 的响应,直接发 Cancel。如果 Cancel 执行时 Try 还没动,Cancel 直接返回成功,但 Try 后到后找不到对应的冻结记录,挂了。
解决方案:Cancel 执行前先查你有没有冻结记录,没有就返回成功(幂等策略)。
悬挂(Suspension): Try 超时后协调者发了 Cancel,Cancel 成功执行了。结果 Try 的请求又慢悠悠到了,把资源冻结了,但没有人再去 Confirm 或 Cancel 它,这个资源就永远冻结着。
解决方案:给 Try 操作加一个事务时间戳判断,如果发现当前时间已经超过协调者设置的 Try 超时时间,Try 直接丢弃,不执行资源预留。
// 空回滚处理:Cancel 时检查 Try 是否执行过
@Override
public boolean cancel(Order order) {
// 先查冻结记录是否存在
FrozenRecord record = inventoryMapper.findFrozen(order.getProductId(), order.getXid());
if (record == null) {
// Try 还没执行(空回滚),直接返回成功
// 等 Try 真正执行时,通过时间戳判断丢弃
return true;
}
// 幂等防重
int inserted = idempotentDao.tryInsert(order.getXid(), order.getBranchId(), "CANCEL");
if (inserted == 0) return true;
return inventoryMapper.unfreezeStock(order.getProductId(), order.getQuantity());
}优缺点
优点: 不锁数据库资源,性能比 2PC 好一个数量级。 缺点: 业务侵入性极大,每个操作都需要实现 Try/Confirm/Cancel 三个接口,测试覆盖率要求 100%(线上漏一个 Cancel 分支就是脏数据)。
适用场景: 业务不可补偿的场景,如发短信、扣减外部积分——Try 阶段预留资源,保证 Confirm 一定成功。
TCC vs 2PC 性能对比
| 方案 | 平均延迟 | 99分位延迟 | 单机 TPS |
|---|---|---|---|
| 2PC | 150ms | 320ms | 320 |
| TCC | 35ms | 85ms | 2200 |
| 本地事务(基线) | 5ms | 15ms | 12000 |
Saga
Saga 将长事务拆分为多个本地事务,每个本地事务有对应的补偿操作。Saga 有两种模式。
Choreography Saga(编排/事件驱动)
每个服务执行完本地事务后,发送事件触发下一个服务;补偿事件也通过事件链触发。
OrderService InventoryService AccountService
| | |
|-- OrderCreated ------>| |
| |-- InventoryReserved ->|
| | |-- AccountDebited
| | | (事务完成)
| | |
| (如果 Inventory 失败)
| |-- InventoryReserveFailed
|<-- OrderCancelled ----|
| |
| (补偿链自动执行)无中心节点,但事件流难追踪,失败链路排查要靠全链路 TraceId,每个服务都要独立处理补偿逻辑。
Orchestration Saga(协调者模式)
一个 Saga 协调者统一调度各个服务,调用失败时按逆序执行补偿操作。
// Orchestration Saga 示例
public class OrderSagaOrchestrator {
private final SagaStep[] steps = {
new SagaStep("createOrder", OrderService::createOrder, OrderService::cancelOrder),
new SagaStep("reserveInventory", InventoryService::reserve, InventoryService::release),
new SagaStep("deductBalance", AccountService::deduct, AccountService::refund),
};
public void executeOrder(OrderContext ctx) {
Deque<Integer> executed = new LinkedList<>();
try {
for (int i = 0; i < steps.length; i++) {
steps[i].execute(ctx); // 每个步骤是一个本地事务,提交后立即释放资源
executed.push(i);
}
} catch (Exception e) {
// 逆序补偿
while (!executed.isEmpty()) {
int idx = executed.pop();
steps[idx].compensate(ctx);
}
throw new SagaException("Order saga failed at step " +
(executed.isEmpty() ? 0 : executed.peek() + 1), e);
}
}
}Choreography vs Orchestration 选型对比
| 维度 | Choreography | Orchestration |
|---|---|---|
| 耦合度 | 松耦合,每个服务只关心自己的事件 | 紧耦合,协调者知道所有服务 |
| 排查难度 | 难:事件流 N 跳,全靠 TraceId | 易:协调者单点控制,日志集中 |
| 边界情况 | 循环事件(A→B→A 死循环),需事件版本号 | 无循环,协调者状态机控制 |
| 推荐场景 | 2-3 个服务,链路简单 | 3+ 服务,链路复杂 |
| 生产案例 | 较少 | 淘宝订单、Uber 行程 |
我的建议: 除非链路极简(2 个服务),否则一律选 Orchestration。Choreography 的循环事件和补偿链路排查太痛苦,生产事故时你不想在 5 个服务的事件日志里人工拼图。
Saga 的隔离性坑
Saga 不保证隔离性,中间结果可能被其他事务看到。典型车祸现场:
时间线:
T1: 下单扣库存 → 库存 -1(已提交)
T2: 查询库存 → 看到库存余量(含 T1 的扣减)
T1: 扣余额失败 → 补偿:库存 +1T2 在 T1 补偿前读到了脏数据,如果 T2 也做了扣库存操作,就多扣了。解决方案:
- 语义锁:在数据行上加一个
saga_status字段,标记为 "in-progress",其他事务识别后跳过该行。 - 补偿后重试:T2 读到的库存如果被标记为 "to-be-compensated",放弃本次操作,让业务重试。
- 冻结列隔离:库存表加一个
frozen_quantity列,可售库存 = total - frozen,其他事务只读可售库存,不读 frozen 明细。
优缺点
优点: 适合长事务、跨系统调用,性能好——每个本地事务执行完就提交,不锁资源。 缺点: Saga 不保证隔离性;补偿操作必须能撤销 Try 的影响,且补偿本身必须幂等。
适用场景: 最终一致性 + 业务可补偿,如订单-库存-支付的典型流程。淘宝订单系统底层就是 Saga 变体。
Seata AT 模式:自动补偿
Seata AT 是阿里开源的 AT(Automatic Transaction)模式,核心思想是对业务代码侵入最小。原理可以概括为 "自动记录镜像,自动生成回滚 SQL" :
- 全局事务开始时,Seata 生成全局事务 ID(XID),通过 Dubbo/Spring Cloud 的拦截器自动透传。
- 业务 SQL 执行前,Seata 自动查 Before Image(旧数据)到
undo_log表。 - 业务 SQL 执行后,Seata 查 After Image(新数据)。
- 全局事务提交时,Seata 协调者(TC)发送 Commit,各个分支删除 undo_log。
- 全局事务回滚时,Seata 根据 Before Image 自动生成反向 SQL 恢复数据。
Seata AT 回滚流程时序图
Application RM (Resource Manager) TC (Transaction Coordinator)
| | |
|--- @GlobalTransactional -->| |
| (生成 XID) | |
| | |
|--- orderService.create() ->| |
| |-- 1. 查 Before Image |
| |-- 2. 执行业务 SQL |
| |-- 3. 查 After Image |
| |-- 4. 写入 undo_log |
| |-- 5. 注册分支到 TC |
|<-- OK ---------------------| |
| | |
|--- inventoryService.deduct() |
| |-- (同上 1-5 步) |
|<-- OK ---------------------| |
| | |
|--- 业务异常 ---------------| |
| |-- TC 通知所有分支回滚 |
| |-- 读取 undo_log |
| |-- 根据 Before Image 生成反向 SQL|
| | (如新增记录→DELETE, 更新→UPDATE)|
| |-- 删除 undo_log |
| |-- 返回回滚结果 |-- undo_log 表结构(简化)
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL, -- 序列化的 Before/After Image
`log_status` tinyint(4) NOT NULL, -- 0=正常, 1=已回滚
`log_created` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_unionkey` (`xid`, `branch_id`)
);业务代码只需要加一个 @GlobalTransactional 注解,不需要写 Try/Confirm/Cancel:
@GlobalTransactional // 就这一行,Seata 自动接管
public void createOrder(OrderDTO dto) {
orderService.create(dto); // Seata 自动记录 order 表的 Before/After Image
inventoryService.deduct(dto); // Seata 自动记录 inventory 表的 Before/After Image
accountService.deduct(dto); // 同上
}Seata AT 的坑
坑 1:undo_log 清理不及时
高并发下 undo_log 表会暴涨。某次线上 Seata 运行 7 天后,undo_log 表达到 2.3GB,InnoDB 的聚簇索引 B+ 树膨胀,导致插入 undo_log 的 SQL 变慢,进而拖慢了整个业务 SQL。解决方案:定时任务每天凌晨清理已回滚或已提交超过 3 天的 undo_log。
-- 每天凌晨清理
DELETE FROM undo_log WHERE log_created < DATE_SUB(NOW(), INTERVAL 3 DAY) AND log_status = 1;坑 2:undo_log 序列化性能
Seata 默认用 Java 序列化(java.io.Serializable)把 Before/After Image 写入 rollback_info 字段。Java 序列化有两大问题:一是序列化后体积大(Before Image 包含整行数据,序列化后是 JSON 的 3-5 倍);二是序列化性能差(1000 TPS 下,序列化/反序列化占用 15% 的 CPU)。建议改为 Protobuf 或 Kryo 序列化,undo_log 写入量减少 60%,CPU 占用降到 5% 以下。
坑 3:全局锁的性能影响
Seata 在全局事务提交前会持有全局锁(Global Lock),防止其他事务修改同一行数据。全局锁的存在导致 Seata AT 的 TPS 不如 TCC:实测 1000 TPS 下,Seata AT 的 99 分位延迟约为 200ms,而 TCC 约为 85ms。
坑 4:数据库兼容性
Seata AT 的逆向 SQL 生成依赖 MySQL 语法,对 PostgreSQL 和 Oracle 的支持不如 MySQL 完善。如果团队用的是 MySQL 以外的数据库,建议先用 Seata AT 做 POC 验证。PG 的 UPDATE ... RETURNING 语法和 MySQL 不同,Seata 的逆向 SQL 生成器在 PG 上容易出 bug。
优点
- 业务代码几乎无侵入,不需要改现有 SQL。
- 自动生成回滚 SQL,开发效率高。
缺点
- 性能损耗(记录 Undo Log + 全局锁),不适合高频写入场景。
- 数据库支持有限(MySQL 最佳,PG/Oracle 有坑)。
方案总对比表
| 维度 | 2PC | 3PC | TCC | Saga | Seata AT |
|---|---|---|---|---|---|
| 一致性 | 强一致 | 强一致 | 最终一致 | 最终一致 | 强一致(全局锁) |
| 性能 | 极差 | 比 2PC 还差 | 好 | 好 | 中等 |
| 业务侵入 | 低 | 低 | 极高(3接口) | 高(补偿逻辑) | 极低(1个注解) |
| 幂等要求 | 无 | 无 | 必须 | 必须 | 自动 |
| 隔离性 | 保证 | 保证 | 无 | 无 | 全局锁保证 |
| 适用场景 | 金融强一致 | 基本不用 | 不可补偿业务 | 长事务/可补偿 | 快速接入 |
| 维护成本 | 低 | 高 | 高 | 中 | 低 |
| 框架支持 | JTA/XA | 极少 | TCC-Transaction/Seata TCC | Eventuate/Seata Saga | Seata |
选型决策树
强一致性要求(金融、账户、资产)
├── TPS < 500 → 2PC 或 Seata AT
│ ├── 不想改代码 → Seata AT
│ └── 已有 XA 支持 → 2PC(注意协调者高可用 + 持久化)
└── TPS ≥ 500 → 业务上做补偿设计,不要强一致性
最终一致性 + 业务可补偿
├── 长事务、跨 3+ 系统调用 → Saga(推荐 Orchestration 模式)
├── 轻量、TPS < 1000、不想引入框架 → 本地消息表 + 定时任务
│ └── 超过 1000 TPS 请上 MQ + 事务消息
└── 业务不可补偿(发短信、扣外部积分)→ TCC
最小化改造成本
├── MySQL 数据库 → Seata AT(1 个注解,15 分钟接入)
├── 非 MySQL 数据库 → Seata TCC 或 Saga(手动写补偿)
└── 不想引入任何框架 → 本地消息表本地消息表 + 定时任务是最轻量的分布式事务方案,不依赖 Seata/TCC 等框架,适合 1000 TPS 以下的场景:
- 本地事务写业务表 + 消息表(同一个数据库,保证原子性)。
- 定时任务轮询消息表(状态为 UN_SENT 的记录),发送到 MQ。
- Consumer 端幂等消费。
- 消费成功后更新消息表状态为 SENT。
-- 消息表设计
CREATE TABLE transaction_message (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
business_id VARCHAR(64) NOT NULL COMMENT '业务ID',
content JSON NOT NULL COMMENT '消息体',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0=未发送, 1=已发送, 2=已消费',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
max_retry INT NOT NULL DEFAULT 3 COMMENT '最大重试次数',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_business_id (business_id)
);本地消息表的 MQ 投递可靠性设计
定时任务从消息表拉取 UN_SENT 记录,发送到 MQ。这里有一个坑:如果消息投递到 MQ 成功,但更新消息表状态为 SENT 时数据库挂了,下次定时任务会重复投递。所以 Consumer 端必须幂等:
// Consumer 幂等消费
@KafkaListener(topics = "order_tx")
public void consume(TransactionMessage msg) {
// 根据 business_id 做幂等判断
int updated = transactionMessageDao.updateStatusIfUnsent(
msg.getBusinessId(), "0", "1");
if (updated == 0) {
// 已处理过,跳过
return;
}
// 执行真正的业务逻辑
processOrder(msg);
}总结
- 2PC/3PC:强一致性,但性能差、有阻塞。适合金融等对一致性要求极高且 TPS < 500 的场景。注意协调者必须高可用 + 持久化,否则 OOM 直接锁死全库。
- TCC:业务补偿,性能好但侵入性大。适合业务不可补偿的场景(发短信、扣外部积分)。注意空回滚和悬挂问题,幂等必须做。
- Saga:长事务,最终一致性,适合订单-库存-支付类流程。推荐 Orchestration 模式,Choreography 的排查成本太高。隔离性要靠语义锁或冻结列来补。
- Seata AT:自动补偿,业务侵入最小,是目前最主流的方案。注意 undo_log 清理(定时任务清 3 天前的)、序列化性能(建议用 Kryo 替代 Java 原生)、全局锁延迟。
- 本地消息表:最轻量,适合 1000 TPS 以下不想引入框架的团队。Consumer 端必须幂等。
没有最好的方案,只有最适合业务场景的方案。选型时问自己三个问题:
- 业务能接受多长的不一致窗口(1秒的还是1小时)?
- 性能要求(500 TPS 还是 5000 TPS)?
- 团队有能力维护复杂的补偿逻辑吗?
面试回答技巧: 别只背方案名。画时序图,报性能数据,讲踩过的坑——空回滚、悬挂、undo_log 暴涨、协调者 OOM 卡死全库。面试官最想听的是你"选过、用过、踩过"。