分布式事务
#分布式事务 · #2PC · #TCC · #Saga · #最终一致性 · #Seata
微服务架构下,一个业务操作往往跨越多个服务和数据库,如何保证数据一致性是核心挑战。本专题系统梳理分布式事务的理论基础与工程方案。
理论基础
CAP 定理
分布式系统不可能同时满足:
- Consistency(一致性):所有节点看到相同数据
- Availability(可用性):每个请求都能得到响应
- Partition tolerance(分区容忍):网络分区时系统仍能运行
网络分区不可避免,实际系统在 CP 和 AP 之间选择。
BASE 理论
对 ACID 的妥协:
- Basically Available:基本可用
- Soft state:软状态,允许中间状态
- Eventually consistent:最终一致性
方案对比
| 方案 | 一致性 | 性能 | 侵入性 | 适用场景 |
|---|---|---|---|---|
| 2PC/XA | 强一致 | 低 | 低 | 传统数据库跨库 |
| 3PC | 强一致 | 低 | 低 | 理论模型,工程少用 |
| TCC | 强一致 | 中 | 高 | 资金、库存等核心业务 |
| Saga | 最终一致 | 高 | 中 | 长事务、跨服务编排 |
| 本地消息表 | 最终一致 | 高 | 中 | 异步通知场景 |
| 事务消息 | 最终一致 | 高 | 低 | 有 MQ 基础设施 |
| 最大努力通知 | 最终一致 | 高 | 低 | 对一致性要求不高 |
2PC(两阶段提交)
为什么要两阶段?
假设你有一个转账操作:从 MySQL-A 扣 100 元,往 MySQL-B 加 100 元。两个操作分别在独立的数据库事务中提交,没有"跨数据库的事务"。如果 A 提交成功但 B 失败——钱凭空消失了。2PC 就是为了解决"多个参与者要么全成功、要么全失败"的问题。
mermaid
sequenceDiagram
participant TM as "事务协调者 (TM)"
participant RM1 as "参与者1 (MySQL-A)"
participant RM2 as "参与者2 (MySQL-B)"
Note over TM,RM2: Phase 1: Prepare (准备阶段)
TM->>RM1: Prepare: 扣 100 元,准备好提交?
RM1->>RM1: 执行 SQL,写入 undo log<br/>但事务未提交,资源锁定
RM1-->>TM: Vote: YES (我准备好了)
TM->>RM2: Prepare: 加 100 元,准备好提交?
RM2->>RM2: 执行 SQL,写入 undo log<br/>事务未提交,资源锁定
RM2-->>TM: Vote: YES
Note over TM,RM2: 所有参与者都 YES → Phase 2: Commit
TM->>RM1: Commit
RM1->>RM1: COMMIT(释放锁)
RM1-->>TM: ACK
TM->>RM2: Commit
RM2->>RM2: COMMIT
RM2-->>TM: ACK
Note over TM: 事务完成 ✅ 钱从 A→B异常场景
mermaid
sequenceDiagram
participant TM as "TM"
participant RM1 as "RM1"
participant RM2 as "RM2"
Note over TM,RM2: 场景1: Prepare 阶段有参与者 NO
TM->>RM1: Prepare
RM1-->>TM: YES
TM->>RM2: Prepare
RM2-->>TM: ❌ NO (余额不足/锁冲突)
TM->>RM1: Abort (回滚)
TM->>RM2: Abort
Note over TM,RM2: 所有参与者回滚 ✅ 原子性保住
Note over TM,RM2: 场景2: Commit 阶段部分失败 (最危险!)
TM->>RM1: Prepare → YES
TM->>RM2: Prepare → YES
TM->>RM1: Commit → ACK ✅
TM-->>TM: 💥 TM 宕机!RM2 没收到 Commit
RM2->>RM2: 一直等待...资源锁未释放
Note over RM2: 🔴 RM2 处于"不确定状态"<br/>不知道该 Commit 还是 Abort2PC 的致命问题:单点故障(TM 宕机)+ 同步阻塞(参与者持有锁等待 TM 决策)。这在实际生产系统中是不可接受的——所以才有 TCC、Saga 等替代方案。
TCC(Try-Confirm-Cancel)
TCC 是"事务补偿"思想的代表作——它不是在数据库层面做 2PC,而是让业务代码自己定义"怎么做、怎么确认、怎么撤销"。核心是把一次业务操作拆成三个步骤:
mermaid
flowchart TB
subgraph TCC["TCC 三阶段"]
Try["🔵 Try 阶段<br/>预留资源 & 检查<br/>例如:冻结 100 元<br/>(金额还在账户,但不能用)"]
Try -->|"所有参与者 Try 成功"| Confirm
Try -->|"任一 Try 失败"| Cancel
Confirm["🟢 Confirm 阶段<br/>确认执行<br/>例如:扣减冻结金额,转账到对方<br/>(幂等操作)"]
Cancel["🔴 Cancel 阶段<br/>取消释放<br/>例如:解冻金额<br/>(幂等操作)"]
end相比于 2PC 的"数据库帮你锁定",TCC 的"业务自己控制锁"有两大优势:(1) 锁的粒度由你控制(可以只锁余额不锁整个账户行);(2) Try 阶段的锁持有时间可控(超时可以自动触发 Cancel),不会像 2PC 那样无限等待。
在 2PC 基础上增加 CanCommit 阶段和超时机制:
Phase 1: CanCommit → 询问是否可以提交(不锁资源)
Phase 2: PreCommit → 预提交(锁定资源)
Phase 3: DoCommit → 正式提交改进:参与者超时后默认提交,减少阻塞。 局限:仍无法完全解决网络分区下的不一致问题,工程中较少使用。
TCC(Try-Confirm-Cancel)
三个阶段
| 阶段 | 作用 | 示例(转账) |
|---|---|---|
| Try | 预留资源 | 冻结转出账户 100 元 |
| Confirm | 确认提交 | 扣减冻结金额,增加收款方余额 |
| Cancel | 取消释放 | 解冻转出账户 100 元 |
代码示例
java
// TCC 接口定义
public interface AccountTccService {
@TwoPhaseBusinessAction(name = "transfer",
commitMethod = "confirm", rollbackMethod = "cancel")
boolean tryTransfer(BusinessActionContext ctx,
@BusinessActionContextParameter(paramName = "accountId") String accountId,
@BusinessActionContextParameter(paramName = "amount") BigDecimal amount);
boolean confirm(BusinessActionContext ctx);
boolean cancel(BusinessActionContext ctx);
}
// Try 阶段实现
public boolean tryTransfer(BusinessActionContext ctx, String accountId, BigDecimal amount) {
// 冻结金额:available -= amount, frozen += amount
return accountDao.freeze(accountId, amount);
}
// Confirm 阶段实现
public boolean confirm(BusinessActionContext ctx) {
String accountId = ctx.getActionContext("accountId");
BigDecimal amount = ctx.getActionContext("amount");
// 扣减冻结金额
return accountDao.deductFrozen(accountId, amount);
}
// Cancel 阶段实现
public boolean cancel(BusinessActionContext ctx) {
String accountId = ctx.getActionContext("accountId");
BigDecimal amount = ctx.getActionContext("amount");
// 解冻金额:available += amount, frozen -= amount
return accountDao.unfreeze(accountId, amount);
}TCC 异常场景深度解析
空回滚(Empty Rollback)
定义:Try 阶段根本没执行(可能因为网络超时、进程崩溃),但 Cancel 被调用了。
text
时间线:
t1: TM 发起 Try → 网络超时(RM 没收到)
t2: TM 判定 Try 失败 → 触发全局回滚 → 发起 Cancel
t3: RM 收到 Cancel → 但 Try 从未执行过 → 这是"空回滚"java
// 空回滚防御:Cancel 必须判断 Try 是否执行过
public boolean cancel(BusinessActionContext ctx) {
String xid = ctx.getXid();
// 1. 查询事务记录表
TccTransaction tcc = tccMapper.selectByXid(xid);
if (tcc == null) {
// Try 从未执行过 → 空回滚
// 插入一条 status=CANCEL 的记录防止后续 Try 执行(防悬挂)
tccMapper.insert(new TccTransaction(xid, "CANCEL"));
return true; // 空回滚成功,无需实际操作
}
// 2. Try 执行过 → 正常回滚
if (tcc.getStatus() == TccStatus.TRY) {
// 执行回滚逻辑:解冻金额
accountDao.unfreeze(accountId, amount);
tccMapper.updateStatus(xid, TccStatus.CANCEL);
return true;
}
// 3. 已 Cancel / 已 Confirm → 幂等返回成功
return true;
}悬挂(Hanging / Suspension)
定义:Cancel 比 Try 先到达 RM(网络乱序导致),Try 后到达时如果直接执行就会造成"已回滚的事务又被执行了"。
text
时间线:
t1: TM 发起 Try → 网络延迟
t2: TM 等不及 → 超时判定失败 → 发起 Cancel
t3: Cancel 到达 RM → RM 执行 Cancel → 标记事务已取消
t4: Try 终于到达 RM → ⚠️ 如果 RM 不判断 → 执行了 Try!
→ 但事务已经被 Cancel 了 → 永远不会有 Confirm 或 Cancel 来清理
→ 资源永远锁定(悬挂)java
// 悬挂防御:Try 必须先检查事务是否已被 Cancel
public boolean tryTransfer(BusinessActionContext ctx, String accountId, BigDecimal amount) {
String xid = ctx.getXid();
// 1. 防悬挂检查:先查事务记录
TccTransaction tcc = tccMapper.selectByXid(xid);
if (tcc != null && tcc.getStatus() == TccStatus.CANCEL) {
// Cancel 已经执行过 → 拒绝 Try
log.warn("try rejected: transaction already cancelled", xid);
return false;
}
// 2. 插入事务记录(唯一键约束防并发)
try {
tccMapper.insert(new TccTransaction(xid, TccStatus.TRY));
} catch (DuplicateKeyException e) {
// 并发 Try → 幂等检查
tcc = tccMapper.selectByXid(xid);
if (tcc.getStatus() != TccStatus.TRY) {
return false; // 已被 Cancel
}
return true; // 已 Try,幂等
}
// 3. 执行业务:冻结金额
accountDao.freeze(accountId, amount);
return true;
}TCC 状态机
mermaid
stateDiagram-v2
[*] --> TRY : Try 请求到达
TRY --> CONFIRM : 全局事务确认
TRY --> CANCEL : 全局事务回滚
CANCEL --> [*]
note right of TRY
空回滚: Cancel 到达但未曾 TRY
→ 检查无记录 → 记录 CANCEL
end note
note right of CANCEL
防悬挂: Try 到达时发现已 CANCEL
→ 拒绝 Try 执行
end note
note left of CONFIRM
幂等: Confirm 可能重复到达
→ 检查状态 → 已 Confirm 则直接返回成功
end note事务控制表设计
sql
CREATE TABLE tcc_transaction (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
xid VARCHAR(128) NOT NULL UNIQUE, -- 全局事务 ID(唯一键)
status TINYINT NOT NULL DEFAULT 0, -- 0=TRY, 1=CONFIRM, 2=CANCEL
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status_created (status, created_at) -- 用于定时清理超时事务
);
-- Try 时:
INSERT INTO tcc_transaction (xid, status) VALUES (?, 0);
-- ↑ 利用 UNIQUE(xid) 实现幂等 + 防悬挂
-- Cancel 先到时(空回滚):
INSERT INTO tcc_transaction (xid, status) VALUES (?, 2);
-- ↑ Try 后到时发现 status=2 → 拒绝
-- Confirm 时:
UPDATE tcc_transaction SET status = 1 WHERE xid = ? AND status = 0;
-- ↑ 幂等: 第二次 Confirm 时 status 已是 1 → 不影响任何行 → 直接返回成功
-- 定时清理超时 TRY(如 30 分钟未 Confirm/Cancel → 自动 Cancel):
UPDATE tcc_transaction SET status = 2
WHERE status = 0 AND created_at < NOW() - INTERVAL 30 MINUTE;TCC vs 2PC:锁持有时间的对比
text
2PC (数据库锁):
Prepare → Commit 之间,数据库行锁一直持有
如果 TM 宕机 → 锁永远不释放 → 需要 DBA 手动 KILL 事务
TCC (业务锁):
Try → Confirm/Cancel 之间,只有"业务层面的软锁"
超时可以自动 Cancel → 不需要 DBA 介入
锁的范围由业务代码控制(可以只锁余额,不锁整行)XA 2PC 协调者故障的完整分析
2PC 的致命盲区:协调者在 Commit 阶段宕机
mermaid
sequenceDiagram
participant TM as "协调者 (TM)"
participant RM1 as "MySQL-A"
participant RM2 as "MySQL-B"
Note over TM,RM2: Phase 1: Prepare
TM->>RM1: Prepare → YES
TM->>RM2: Prepare → YES
Note over TM,RM2: Phase 2: Commit — 协调者部分发送后宕机
TM->>RM1: Commit → RM1 COMMIT 成功
TM-->>TM: 💥 协调者宕机!
Note over RM2: ⚠️ RM2 处于"不确定状态"<br/>Prepare 了但不知道要 Commit 还是 Abort<br/>行锁一直持有 → 阻塞所有其他事务!
Note over TM,RM2: 恢复后(如果 TM 有 WAL 日志):
TM->>TM: 从 WAL 恢复 → 发现事务 X 的状态是 COMMITTING
TM->>RM2: 重发 Commit → RM2 COMMIT ✅三种恢复策略
| 策略 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| WAL 恢复 | TM 写 WAL 日志记录事务状态,宕机恢复后重放 | 可靠 | TM 需要持久化存储 |
| 参与者互询 | RM2 不确定时,询问其他 RM:"你们 commit 了吗?" | 无需 TM 恢复 | 需 RM 间网络互通 |
| 超时 + 补偿 | RM2 超时后自动 Abort,事后通过补偿机制修复 | 不阻塞 | 可能违反原子性 |
Seata 如何避免 2PC 的单点故障
text
Seata AT 模式的改进:
1. TM (Transaction Manager) → 无状态,任意实例可接管
- 全局事务状态存储在 Seata Server (TC) 中
- TC 使用数据库/Redis 持久化事务日志
2. TC (Transaction Coordinator) → 高可用部署
- TC 集群(多实例 + DB 共享存储)
- 宕机后其他 TC 实例从 DB 读取事务日志继续处理
3. RM (Resource Manager) → 无阻塞
- 第一阶段已提交本地事务(与 2PC 不同!)
- 行锁在第一阶段就已释放
- 回滚时用 undo_log 生成反向 SQL 补偿
- → 即使 TC 宕机 10 分钟,RM 也不会锁表 10 分钟
关键区别:
2PC: Prepare 锁定 → Commit 释放 → TM 宕机 = 锁永远不释放
Seata AT: 第一阶段提交 → 立即释放锁 → TC 宕机只延迟回滚,不阻塞业务mermaid
flowchart TB
subgraph TwoPC["2PC 问题"]
P["Prepare<br/>锁住资源"] --> Crash["TM 宕机"]
Crash --> Block["资源永久锁定<br/>需要 DBA 手工介入"]
end
subgraph Seata["Seata AT 改进"]
Commit1["第一阶段: 本地提交<br/>+ 写 undo_log"] --> Crash2["TC 宕机"]
Crash2 --> Recovery["TC 恢复后<br/>根据 undo_log 补偿"]
Recovery --> OK["资源从不阻塞 ✅"]
end
style Block fill:#f44336,color:#fff
style OK fill:#4CAF50,color:#fffSeata TC 高可用架构
┌─────────────────────────────────────────────────┐
│ Seata TC 集群 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ TC-1 │ │ TC-2 │ │ TC-3 │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ └──────────────┼──────────────┘ │
│ │ 共享 DB 存储事务日志 │
│ ┌───────┴───────┐ │
│ │ global_table │ │
│ │ branch_table │ │
│ │ lock_table │ │
│ └───────────────┘ │
└─────────────────────────────────────────────────┘ |
配置:
registry.type = "nacos" # 服务发现
config.type = "nacos" # 配置中心
store.mode = "db" # 事务日志持久化到 DB最佳实践
- Seata AT 优先:不需要手写 TCC 三接口,零侵入
- TCC 用于特定场景:资金、库存等需要 Try 阶段显式锁定资源的业务
- Saga 用于长流程:跨多个服务的长时间事务
- 监控 TC 健康状态:TC 宕机会延迟回滚但不阻塞业务,仍需告警
- 定时扫描 undo_log:清理过期数据,防止表膨胀
Saga 模式
核心思想
将长事务拆分为多个本地事务,每个本地事务有对应的补偿操作。正向执行失败时,逆序执行补偿。
T1 → T2 → T3 → T4(成功)
T1 → T2 → T3(失败)→ C3 → C2 → C1(补偿回滚)编排模式 vs 事件驱动
mermaid
flowchart LR
subgraph Orchestration["协调器编排 (Orchestration)"]
direction TB
O_Saga["Saga 协调器"]
O_S1["创建订单"]
O_S2["冻结库存"]
O_S3["扣减余额"]
O_Saga -->|"1. 执行"| O_S1
O_S1 -->|"成功"| O_Saga
O_Saga -->|"2. 执行"| O_S2
O_S2 -->|"成功"| O_Saga
O_Saga -->|"3. 执行"| O_S3
O_S3 -->|"失败!"| O_Saga
O_Saga -->|"逆序补偿"| O_S2
O_Saga -->|"逆序补偿"| O_S1
end
subgraph Choreography["事件编排 (Choreography)"]
direction TB
C_Order["订单服务"]
C_Stock["库存服务"]
C_Pay["支付服务"]
C_Order -->|"事件: OrderCreated"| C_Stock
C_Stock -->|"事件: StockReserved"| C_Pay
C_Pay -->|"事件: PaymentFailed"| C_Order
C_Stock -->|"事件: StockReleased"| C_Order
end| 维度 | 协调器编排 | 事件编排 |
|---|---|---|
| 流程可见性 | ✅ 中心协调器,流程一目了然 | ❌ 分散在各服务中,全局难追踪 |
| 耦合度 | 🟡 与 Saga 协调器耦合 | ✅ 各服务仅通过事件解耦 |
| 调试难度 | ✅ 低(单点追踪) | ❌ 高(需分布式追踪) |
| 扩展性 | 🟡 新增步骤需改协调器 | ✅ 新增消费者即可 |
| 适合 | 订单类流程(步骤固定) | 异步通知(步骤易扩展) |
Seata 框架 — AT 模式深度
AT 模式原理
Seata AT(Automatic Transaction)是使用最广泛的模式——它不需要你写 TCC 的三接口,而是自动解析 SQL 生成回滚日志:
mermaid
sequenceDiagram
participant TM as "Seata TM"
participant RM1 as "RM1 (DB-A)"
participant RM2 as "RM2 (DB-B)"
Note over TM,RM2: 全局事务开始
TM->>TM: 生成全局事务 XID
Note over TM,RM2: 分支事务1: 扣库存
RM1->>RM1: 1. 执行前:SELECT stock WHERE id=1 → beforeImage: stock=10
RM1->>RM1: 2. 执行业务SQL:UPDATE stock SET stock=9
RM1->>RM1: 3. 执行后:SELECT stock WHERE id=1 → afterImage: stock=9
RM1->>RM1: 4. 本地事务提交:UPDATE + INSERT undo_log(含beforeImage/afterImage)
RM1->>TM: 分支1提交成功
Note over TM,RM2: 分支事务2: 扣余额 → 失败!
RM2->>RM2: UPDATE account → 数据库异常
RM2-->>TM: 分支2失败
Note over TM,RM2: 全局回滚
TM->>RM1: 回滚分支1
RM1->>RM1: 查询 undo_log: beforeImage stock=10
RM1->>RM1: 执行反向SQL:UPDATE stock SET stock=10 WHERE stock=9
RM1->>RM1: 删除 undo_log 记录
RM1-->>TM: 回滚完成undo_log 表结构:
sql
CREATE TABLE undo_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
branch_id BIGINT NOT NULL,
xid VARCHAR(128) NOT NULL,
rollback_info LONGBLOB NOT NULL, -- JSON: {beforeImage:..., afterImage:...}
log_status INT NOT NULL, -- 0=正常, 1=已全局完成
INDEX idx_xid_branch(xid, branch_id)
);AT 模式的边界:只支持关系型数据库(MySQL/Oracle/PostgreSQL),不支持 NoSQL(MongoDB/Redis等)。因为它依赖 JDBC 拦截 SQL 并解析"修改前/修改后"的数据快照,这要求 SQL 的语义是确定的标准 DML。
协调器模式(Orchestration):
python
# Saga 协调器
class OrderSaga:
steps = [
SagaStep(action=create_order, compensate=cancel_order),
SagaStep(action=reserve_stock, compensate=release_stock),
SagaStep(action=deduct_balance, compensate=refund_balance),
SagaStep(action=send_notification, compensate=None), # 无需补偿
]
def execute(self):
completed = []
for step in self.steps:
try:
step.action()
completed.append(step)
except Exception:
# 逆序补偿
for s in reversed(completed):
if s.compensate:
s.compensate()
raise SagaRollbackException()事件编排模式(Choreography):
OrderService → [OrderCreated] → StockService → [StockReserved] → PaymentService → [PaymentCompleted]各服务监听事件自行决策,无中心协调器,但流程追踪困难。
Saga vs TCC
| 维度 | Saga | TCC |
|---|---|---|
| 隔离性 | 无,可能读到中间状态 | 有,Try 阶段锁定资源 |
| 性能 | 高,无资源锁定 | 中,Try 阶段锁资源 |
| 实现复杂度 | 中 | 高(需实现三个接口) |
| 适用场景 | 长流程、跨多服务 | 短事务、强一致性要求 |
本地消息表
流程
sql
-- Step 1: 业务操作 + 写消息在同一个本地事务
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 1;
INSERT INTO outbox (id, event_type, payload, status, created_at)
VALUES ('msg_001', 'ORDER_PAID', '{"orderId":1}', 'NEW', NOW());
COMMIT;python
# Step 2: 定时任务扫描并发送
def send_outbox_messages():
messages = db.query("SELECT * FROM outbox WHERE status = 'NEW' AND created_at < NOW() - INTERVAL 5 SECOND")
for msg in messages:
try:
mq.send(msg.event_type, msg.payload)
db.execute("UPDATE outbox SET status = 'SENT' WHERE id = ?", msg.id)
except Exception:
pass # 下次重试
# Step 3: 定期清理已发送消息
def cleanup_outbox():
db.execute("DELETE FROM outbox WHERE status = 'SENT' AND created_at < NOW() - INTERVAL 7 DAY")Seata 框架
模式支持
| 模式 | 说明 |
|---|---|
| AT | 自动补偿,基于 SQL 解析生成回滚日志(最常用) |
| TCC | 手动实现 Try/Confirm/Cancel |
| Saga | 长事务编排 |
| XA | 标准 XA 协议 |
AT 模式原理
1. 解析 SQL,记录修改前的数据快照(beforeImage)
2. 执行 SQL
3. 记录修改后的数据快照(afterImage)
4. 生成 undo_log 记录
5. 提交本地事务 + undo_log(同一事务)
回滚时:根据 undo_log 中的 beforeImage 恢复数据使用示例
java
@GlobalTransactional(timeoutMills = 30000, name = "create-order")
public void createOrder(OrderDTO order) {
// 扣减库存(远程调用)
stockService.deduct(order.getProductId(), order.getCount());
// 扣减余额(远程调用)
accountService.debit(order.getUserId(), order.getAmount());
// 创建订单(本地)
orderDao.create(order);
}
// Seata 自动管理全局事务,任一步骤失败自动回滚所有参与者最佳实践
- 优先选择最终一致性:强一致性代价高,大多数业务可接受短暂不一致
- 幂等是基础:无论哪种方案,参与者都必须支持幂等
- 超时处理:设置合理超时,避免事务长时间悬挂
- 监控告警:对悬挂事务、补偿失败建立告警机制
- 人工兜底:极端情况下提供人工介入入口
相关专题
- 消息驱动架构 — 事务消息与最终一致性
- 缓存技术 — 缓存与 DB 一致性
- Etcd / ZooKeeper — 分布式协调
- MySQL — 本地事务与锁机制
登录后即可发表评论 👇