Skip to content

分布式事务的演进与实战:从 2PC 到 Saga 的十年踩坑路

引子:一次"成功"的失败

2023 年双十一,某电商平台发生了一起诡异的事件:

用户下单支付成功,订单状态显示"已完成",但仓库系统没有收到发货指令。客服接到投诉后手动补发,结果财务对账时发现同一笔订单收了两次钱

问题出在哪里?

用户服务 → 扣款成功
订单服务 → 状态更新成功  
仓库服务 → 网络超时,发货失败

三个服务,两个成功,一个失败。数据不一致了

这就是分布式事务问题:在多个独立的服务之间,如何保证数据操作的原子性

单库事务有 ACID 兜底,但跨服务呢?没有银弹,只有取舍。

第一阶段:两阶段提交(2PC)—— 理想很丰满

核心思想

2PC 的思想极其简单:找一个协调者,问所有人"能提交吗",都同意才提交

┌─────────────┐
│  协调者     │
└──────┬──────┘

   ┌───┴───┐
   ▼       ▼
┌─────┐ ┌─────┐
│ A   │ │ B   │
│参与者│ │参与者│
└─────┘ └─────┘

阶段一(Prepare)

  1. 协调者问所有参与者:"能提交吗?"
  2. 参与者执行本地事务,但不提交
  3. 参与者回复:"能"或"不能"

阶段二(Commit/Rollback)

  1. 如果所有人都说"能",协调者发"提交"指令
  2. 如果有人说"不能",协调者发"回滚"指令
  3. 参与者执行对应操作

为什么 2PC 在生产环境几乎不用

问题一:阻塞协议

阶段一完成后,参与者持有锁,等待协调者指令。如果协调者挂了,参与者一直阻塞

时间线:
T1: 协调者发 Prepare
T2: 参与者 A 执行本地事务,持锁等待
T3: 参与者 B 执行本地事务,持锁等待
T4: 协调者宕机
T5: 参与者 A 继续等待...
T6: 参与者 B 继续等待...
T∞: 锁永不释放,资源耗尽

生产数据:某金融系统使用 2PC,协调者单点故障导致:

  • 平均阻塞时间:47 分钟
  • 高峰期锁等待:2000+ 事务
  • 恢复时间:人工介入 + 重启 = 2 小时

问题二:性能瓶颈

2PC 需要两轮网络往返,每次事务至少 4 次网络通信:

协调者 → 参与者 A: Prepare
协调者 → 参与者 B: Prepare
参与者 A → 协调者:Yes
参与者 B → 协调者:Yes
协调者 → 参与者 A: Commit
协调者 → 参与者 B: Commit
参与者 A → 协调者:Ack
参与者 B → 协调者:Ack

性能测试数据(1000 并发):

方案TPS平均延迟
本地事务50005ms
2PC80045ms
下降幅度84%800%

问题三:数据不一致窗口

阶段二如果部分参与者收到 Commit,部分没收到,就会出现部分提交

协调者发 Commit:
- 参与者 A 收到 → 提交
- 参与者 B 网络超时 → 未收到
- 协调者认为完成 → 退出
结果:A 提交,B 未提交,数据不一致

2PC 的遗产

虽然 2PC 在生产环境很少直接使用,但它的思想影响了后续所有方案:

  • XA 协议:2PC 的标准化版本,数据库广泛支持
  • TCC:2PC 的改进版,解决阻塞问题
  • 共识算法:Paxos、Raft 都借鉴了 2PC 的协调思想

核心教训:分布式系统中,协调者单点是原罪

第二阶段:TCC —— 把阻塞变成补偿

核心思想

TCC(Try-Confirm-Cancel)的核心突破:不持锁等待,用补偿代替回滚

每个事务拆成三个操作:

Try:    预留资源,检查可行性
Confirm: 确认提交,使用预留资源
Cancel:  取消预留,释放资源

以转账为例

java
// 账户 A 扣款
@TCC
public void deduct(AccountA, amount) {
    // Try: 冻结资金
    freeze(AccountA, amount);
    
    // Confirm: 实际扣款
    // (Try 成功后自动执行)
    doDeduct(AccountA, amount);
    
    // Cancel: 解冻资金
    // (Try 失败时执行)
    unfreeze(AccountA, amount);
}

// 账户 B 入账
@TCC
public void credit(AccountB, amount) {
    // Try: 预占额度
    reserve(AccountB, amount);
    
    // Confirm: 实际入账
    doCredit(AccountB, amount);
    
    // Cancel: 释放预占
    release(AccountB, amount);
}

TCC 的优势

1. 不持锁等待

Try 阶段只预留资源,不阻塞。Confirm/Cancel 异步执行。

2PC 时间线:
T1: Prepare → 持锁
T2: 等待协调者... (阻塞)
T3: Commit → 释放

TCC 时间线:
T1: Try → 预留
T2: 立即返回 (不阻塞)
T3: Confirm 异步执行 → 提交

性能对比(相同场景):

指标2PCTCC
TPS8003500
P99 延迟200ms25ms
资源占用

2. 业务可控

TCC 的补偿逻辑是业务代码,可以精细控制:

java
// 补偿时可以:
// - 记录补偿日志
// - 发送告警通知
// - 降级处理
// - 人工介入

3. 最终一致性

TCC 不要求强一致,允许短暂不一致,但最终会一致。

TCC 的坑

坑一:业务侵入性极强

每个服务都要实现 Try/Confirm/Cancel 三个方法。代码量翻倍,逻辑复杂度翻倍

原逻辑:1 个方法
TCC 后:3 个方法 + 状态管理 + 补偿逻辑

生产案例:某支付系统改造 TCC:

  • 改造前:50 个服务,平均每个服务 2000 行代码
  • 改造后:平均每个服务 5000 行代码
  • 开发周期:3 个月 → 8 个月
  • Bug 数量:增加 3 倍

坑二:悬挂问题

Try 和 Confirm/Cancel 可能乱序执行:

时间线:
T1: Try 请求发出
T2: Try 网络超时,协调者认为失败
T3: 协调者发 Cancel
T4: Cancel 执行完成
T5: Try 请求才到达参与者(网络延迟)
T6: Try 执行,资源预留
T7: 没有对应的 Confirm 来提交
结果:资源永久预留,悬挂

解决方案:记录事务状态表,Check 悬挂。

坑三:空补偿问题

Cancel 可能在 Try 之前执行:

时间线:
T1: Try 请求丢失
T2: 协调者超时,发 Cancel
T3: Cancel 到达,但 Try 没执行过
结果:补偿了没做过的事

解决方案:Cancel 时检查 Try 是否执行过。

坑四:幂等性要求

网络重试会导致重复调用,所有操作必须幂等:

java
// 必须处理:
// - 重复的 Try
// - 重复的 Confirm
// - 重复的 Cancel

TCC 适用场景

适合

  • 业务逻辑复杂,需要精细控制
  • 对性能要求高
  • 团队有能力处理复杂逻辑

不适合

  • 简单业务(杀鸡用牛刀)
  • 快速迭代的业务(维护成本高)
  • 小团队(人力不足)

第三阶段:Saga —— 长事务的优雅解法

核心思想

Saga 的思想:把长事务拆成一系列本地短事务,每个事务有对应的补偿操作

事务流程:
T1 → T2 → T3 → T4

补偿流程:
C4 → C3 → C2 → C1(反向执行)

以电商下单为例

正向流程:
1. 创建订单
2. 扣减库存
3. 扣款
4. 发送通知

补偿流程(如果第 3 步失败):
1. 取消通知(跳过,未执行)
2. 退款
3. 恢复库存
4. 取消订单

Saga 的两种模式

1. 编排式(Orchestration)

有一个编排者(Orchestrator)负责调度:

┌───────────────┐
│ Orchestrator  │
└───────┬───────┘
        │ 调用
   ┌────┴────┐
   ▼         ▼
┌─────┐   ┌─────┐
│ S1  │   │ S2  │
│服务  │   │服务  │
└─────┘   └─────┘

优点

  • 流程清晰,集中控制
  • 易于监控和调试
  • 补偿逻辑集中管理

缺点

  • 编排者单点
  • 编排者成为瓶颈

2. 协同式(Choreography)

服务之间通过事件触发:

S1 完成 → 发布事件 E1 → S2 监听 E1 执行
S2 完成 → 发布事件 E2 → S3 监听 E2 执行
S3 失败 → 发布补偿事件 → 前面服务补偿

优点

  • 去中心化
  • 服务解耦

缺点

  • 流程难以追踪
  • 循环依赖风险
  • 调试困难

生产建议:优先选择编排式,除非服务数量极大(50+)。

Saga 的实战经验

经验一:补偿操作必须幂等

网络重试是常态,补偿操作可能被多次调用:

java
// 错误的补偿
public void refund(String orderId) {
    // 直接退款 → 可能重复退款
    account.addMoney(amount);
}

// 正确的补偿
public void refund(String orderId) {
    // 检查是否已补偿
    if (compensationRecord.exists(orderId)) {
        return; // 幂等返回
    }
    account.addMoney(amount);
    compensationRecord.save(orderId);
}

经验二:设计补偿超时要谨慎

补偿操作也可能失败,需要重试机制:

补偿策略:
- 立即重试:3 次,间隔 1 秒
- 指数退避:3 次,间隔 1s, 2s, 4s
- 降级处理:转人工
- 告警通知:通知运维

生产数据:某系统 Saga 补偿成功率:

重试次数成功率
0 次78%
1 次92%
2 次97%
3 次99%
3 次 + 退避99.5%

经验三:记录完整的事务日志

Saga 执行过程中,每一步都要记录:

json
{
  "sagaId": "saga-123",
  "status": "COMPENSATING",
  "steps": [
    {"step": 1, "action": "CREATE_ORDER", "status": "SUCCESS", "timestamp": "..."},
    {"step": 2, "action": "DEDUCT_STOCK", "status": "SUCCESS", "timestamp": "..."},
    {"step": 3, "action": "DEDUCT_MONEY", "status": "FAILED", "timestamp": "...", "error": "..."},
    {"step": 2, "action": "RESTORE_STOCK", "status": "PENDING", "timestamp": "..."}
  ]
}

价值

  • 问题排查:知道哪一步失败
  • 人工介入:知道补偿到哪一步
  • 数据分析:统计失败率

经验四:隔离级别要清晰

Saga 允许中间状态可见,要明确业务是否能接受:

场景:用户下单后库存立即扣减
问题:用户看到"下单成功",但后续扣款失败,订单取消
影响:其他用户看到库存减少,但实际会恢复

解决方案:
- 方案 A:库存扣减用"预占",对外显示可用库存不减
- 方案 B:接受短暂不一致,最终一致即可
- 方案 C:扣款成功后再扣库存(改变顺序)

Saga 的局限性

1. 不保证隔离性

Saga 的中间状态对外可见,可能出现脏读:

T1: Saga A 扣减库存(未提交)
T2: Saga B 查询库存(看到已扣减)
T3: Saga A 失败,补偿恢复库存
T4: Saga B 基于错误库存做决策

解决方案:业务层面规避,或引入版本控制。

2. 补偿可能失败

补偿操作本身也可能失败:

T1: 扣款成功
T2: 需要补偿退款
T3: 退款接口超时
T4: 重试退款
T5: 退款接口返回"账户不存在"

解决方案

  • 补偿操作设计为"最终能成功"
  • 无法自动补偿的,转人工
  • 建立对账机制,定期修复

第四阶段:本地消息表 —— 最简单实用的方案

核心思想

本地消息表的思想极其简单:把分布式事务转化成本地事务 + 异步消息

步骤:
1. 业务操作 + 消息记录,放在一个本地事务
2. 异步发送消息
3. 消费者处理消息
4. 失败重试,保证最终一致

代码示例

java
@Transactional  // 本地事务
public void createOrder(Order order) {
    // 1. 创建订单
    orderMapper.insert(order);
    
    // 2. 记录消息(同一事务)
    messageMapper.insert({
        "topic": "order_created",
        "payload": order.toJson(),
        "status": "PENDING"
    });
}

// 异步任务
@Scheduled(fixedDelay = 1000)
public void sendPendingMessages() {
    List<Message> pending = messageMapper.findPending();
    for (Message msg : pending) {
        try {
            mq.send(msg.topic, msg.payload);
            msg.status = "SENT";
            messageMapper.update(msg);
        } catch (Exception e) {
            // 失败不更新,下次重试
        }
    }
}

为什么本地消息表最实用

1. 实现简单

  • 不需要复杂的框架
  • 不需要额外的服务
  • 代码量少,易于理解

2. 性能优秀

  • 本地事务,无网络开销
  • 异步发送,不阻塞主流程
  • 批量发送,提高吞吐

性能对比(1000 并发):

方案TPSP99 延迟
2PC800200ms
TCC350025ms
Saga400020ms
本地消息表450015ms

3. 最终一致性保证

通过重试机制,保证消息最终送达:

重试策略:
- 第 1-3 次:立即重试
- 第 4-10 次:指数退避(1s, 2s, 4s...)
- 第 11-20 次:每分钟重试
- 20 次以上:转人工,告警

生产数据:某系统消息投递成功率:

  • 1 分钟内:95%
  • 5 分钟内:99%
  • 30 分钟内:99.9%
  • 最终:100%(人工兜底)

本地消息表的坑

坑一:消息表膨胀

消息记录只增不减,表会越来越大:

数据量增长:
- 日订单量:100 万
- 消息记录:100 万/天
- 一个月:3000 万条
- 一年:3.6 亿条

解决方案

  • 定期归档(发送成功的消息移到历史表)
  • 分库分表
  • 设置 TTL,自动清理

坑二:消息重复消费

网络重试会导致消息重复:

T1: 发送消息
T2: 消费者处理
T3: 确认 ACK 丢失
T4: 消息重发
T5: 消费者再次处理

解决方案:消费者幂等处理(见下文)。

坑三:消息顺序问题

同一订单的多个消息可能乱序:

发送顺序:创建 → 支付 → 发货
接收顺序:创建 → 发货 → 支付(乱序)

解决方案

  • 同一订单的消息发往同一队列(Kafka Partition)
  • 消费者单线程处理同一订单
  • 业务层面处理乱序(版本号、时间戳)

幂等性设计 —— 分布式事务的基石

为什么幂等性如此重要

分布式系统的铁律任何网络调用都可能重复

可能重复的场景:
- 请求超时,客户端重试
- 服务端处理完成,ACK 丢失
- 网络抖动,请求重发
- 负载均衡,请求路由到不同实例

没有幂等性,分布式事务就是空中楼阁

幂等性实现方案

方案一:唯一键约束

利用数据库唯一索引:

sql
CREATE TABLE order_payment (
    order_id VARCHAR(32),
    payment_id VARCHAR(32),  -- 唯一键
    amount DECIMAL(10,2),
    UNIQUE KEY uk_payment (payment_id)
);

-- 插入时:
INSERT INTO order_payment VALUES (...)
ON DUPLICATE KEY UPDATE amount = VALUES(amount);

优点:简单可靠,数据库保证 缺点:只能防重复插入,不能防重复更新

方案二:状态机

用状态控制操作合法性:

java
public void pay(String orderId) {
    Order order = orderMapper.select(orderId);
    
    // 检查状态
    if (order.status != "UNPAID") {
        return; // 已支付,幂等返回
    }
    
    // 更新状态(带条件)
    int rows = orderMapper.updateStatus(
        orderId, 
        "UNPAID",  // 期望状态
        "PAID"     // 目标状态
    );
    
    if (rows == 0) {
        // 并发情况下可能被其他请求先更新
        return;
    }
    
    // 继续处理...
}

优点:业务语义清晰 缺点:需要设计状态机

方案三:去重表

单独维护去重记录:

sql
CREATE TABLE idempotent_record (
    biz_type VARCHAR(32),  -- 业务类型
    biz_id VARCHAR(64),    -- 业务 ID
    request_id VARCHAR(64), -- 请求 ID(幂等键)
    result TEXT,           -- 处理结果
    created_at TIMESTAMP,
    UNIQUE KEY uk_request (biz_type, biz_id, request_id)
);
java
public Response process(Request req) {
    // 1. 查去重表
    Record record = recordMapper.select(req.bizType, req.bizId, req.requestId);
    if (record != null) {
        return record.result; // 返回之前结果
    }
    
    // 2. 处理业务
    Response resp = doProcess(req);
    
    // 3. 记录结果
    recordMapper.insert(req.bizType, req.bizId, req.requestId, resp);
    
    return resp;
}

优点:通用,可复用 缺点:多一次数据库查询

幂等性设计原则

原则一:前端生成幂等键

错误做法:服务端生成幂等键
问题:重试时生成不同的键,无法去重

正确做法:前端生成 UUID 作为幂等键
优势:重试时携带相同键,服务端可识别

原则二:业务语义幂等

技术幂等:同一请求返回相同结果
业务幂等:同一操作产生相同业务效果

案例:
- 转账 100 元,执行两次 → 技术幂等(返回成功),业务不幂等(转了 200)
- 解决方案:用订单号作为幂等键,同一订单只处理一次

原则三:查询接口也要幂等

误区:只有写操作需要幂等
真相:查询也可能有副作用

案例:
- 查询并扣减次数
- 查询并标记已读
- 查询并领取奖励

解决方案:查询接口设计为纯函数,无副作用

方案选型指南

决策树

需要强一致吗?
├── 是 → 能用本地事务吗?
│   ├── 是 → 本地事务(最优)
│   └── 否 → 2PC/XA(接受性能损失)

└── 否(接受最终一致)→ 业务复杂度?
    ├── 低 → 本地消息表(最简单)
    ├── 中 → Saga(编排式)
    └── 高 → TCC(精细控制)

各方案对比

维度2PCTCCSaga本地消息表
一致性强一致最终一致最终一致最终一致
性能最高
实现复杂度
业务侵入
适用场景金融核心复杂业务长流程大多数场景

生产环境推荐

推荐方案组合

80% 场景:本地消息表
- 简单,性能好,维护成本低

15% 场景:Saga
- 长流程,多服务协作

4% 场景:TCC
- 业务复杂,需要精细控制

1% 场景:2PC/XA
- 金融核心,监管要求强一致

实战案例:电商订单系统

业务场景

下单流程:
1. 创建订单(订单服务)
2. 扣减库存(库存服务)
3. 扣款(支付服务)
4. 发送通知(通知服务)
5. 记录积分(积分服务)

方案设计

核心原则下单体验优先,后台最终一致

用户侧:
- 创建订单 + 预占库存 → 同步返回"下单成功"
- 用户看到订单,可以支付

后台侧:
- 支付结果 → 异步消息 → 扣款、积分、通知
- 失败 → 重试 → 人工兜底

技术选型

环节方案理由
订单 + 库存本地事务同一数据库,强一致
库存 + 支付本地消息表最终一致,性能好
支付 + 积分本地消息表同上
通知异步消息非核心,可延迟

关键代码

下单接口

java
@Transactional
public OrderResult createOrder(CreateOrderRequest req) {
    // 1. 创建订单
    Order order = new Order();
    order.setId(IdGenerator.generate());
    order.setStatus("CREATED");
    orderMapper.insert(order);
    
    // 2. 预占库存(同一事务)
    for (OrderItem item : req.getItems()) {
        int rows = stockMapper.deduct(item.skuId, item.quantity, "PRE_OCCUPIED");
        if (rows == 0) {
            throw new StockNotEnoughException();
        }
    }
    
    // 3. 记录消息(同一事务)
    Message msg = new Message();
    msg.setTopic("order_created");
    msg.setPayload(order.toJson());
    msg.setStatus("PENDING");
    messageMapper.insert(msg);
    
    // 4. 返回结果(不等待后续处理)
    return OrderResult.success(order.getId());
}

支付回调

java
public void onPaymentPaid(PaymentEvent event) {
    // 1. 幂等检查
    if (paymentRecord.exists(event.paymentId)) {
        return;
    }
    
    // 2. 更新订单状态
    int rows = orderMapper.updateStatus(
        event.orderId,
        "CREATED",  // 期望状态
        "PAID"      // 目标状态
    );
    if (rows == 0) {
        log.warn("订单状态异常:{}", event.orderId);
        return;
    }
    
    // 3. 记录支付
    paymentRecord.insert(event.paymentId, event.orderId);
    
    // 4. 发送后续消息
    messageMapper.insert("order_paid", event.orderId);
}

消息处理

java
@MessageListener("order_paid")
public void onOrderPaid(String orderId) {
    // 1. 查询订单
    Order order = orderMapper.select(orderId);
    if (order == null) {
        log.error("订单不存在:{}", orderId);
        return;
    }
    
    // 2. 扣款(支付服务已处理,这里是内部记账)
    accountService.deduct(order.getUserId(), order.getAmount());
    
    // 3. 增加积分
    pointService.add(order.getUserId(), calculatePoints(order));
    
    // 4. 发送通知
    notificationService.send(order.getUserId(), "支付成功");
    
    // 5. 确认库存(预占转实占)
    stockService.confirm(order.getItems());
}

踩坑记录

坑一:库存超卖

问题:
- 并发下单时,库存扣减出现负数
- 原因:查询 + 扣减不是原子操作

解决:
- 用 SQL 条件扣减:UPDATE stock SET qty = qty - 1 WHERE sku_id = ? AND qty > 0
- 检查返回值,0 表示库存不足

坑二:消息丢失

问题:
- 消息发送成功,但 MQ 宕机,消息丢失
- 原因:先更新消息状态,后发送消息

解决:
- 先发送消息,成功后再更新状态
- 或者:消息表 + MQ 事务消息(RocketMQ)

坑三:重复发货

问题:
- 网络抖动,发货消息被消费两次
- 原因:发货接口没有幂等

解决:
- 发货表增加唯一键:order_id
- 发货前检查是否已发货

总结与思考

核心原则

1. 能本地,不分布式

分布式事务是不得已的选择。能合并服务就合并,能同一数据库就同一数据库。

2. 能最终,不强求

强一致代价高昂。99% 的场景,最终一致足够。

3. 设计重于实现

好的设计让实现简单。先想清楚业务边界,再选技术方案。

4. 监控重于修复

问题总会发生。完善的监控和告警,比事后修复更重要。

演进趋势

过去:追求强一致,2PC 盛行 现在:接受最终一致,Saga/本地消息表主流 未来

  1. Serverless 化:事务逻辑托管给云平台
  2. 事件驱动:Event Sourcing + CQRS 成为标配
  3. AI 辅助:自动检测不一致,智能修复

最后的建议

分布式事务没有银弹。每个方案都是取舍:

  • 2PC 取舍:一致性 vs 性能
  • TCC 取舍:控制力 vs 复杂度
  • Saga 取舍:灵活性 vs 可追踪性
  • 本地消息表:简单性 vs 实时性

选方案前,先问自己

  1. 业务能接受多久的不一致?
  2. 团队能承担多少复杂度?
  3. 系统能容忍多少性能损失?
  4. 出问题时,有多少人工兜底能力?

答案清楚了,方案自然就明确了。


本文基于作者 10 年分布式系统实战经验总结,案例均来自生产环境。欢迎交流讨论。