分布式事务能保证一致性吗?TCC与Saga模式深度解析

大家好,我是数据库小学妹 👋

上个月接手了一个跨库转账的需求。当时想,不就是保证多个数据库操作要么全成功要么全失败嘛,能有多难。

结果第一个坑就栽在2PC上。用了一个开源框架,测试环境跑得好好的,一上生产就卡。高峰期响应时间飙到几秒,用户投诉了好几轮。排查了半天,发现是2PC同步阻塞的问题。后来换成TCC,又踩了空回滚的坑,账差点对不上。折腾了好几周,才算把这事理清楚。

今天把这些踩坑经历整理出来,希望能帮你少走弯路少踩坑。

一致性到底是什么

分布式事务要解决的问题:多个服务、多个数据库之间,怎么让数据保持一致。

强一致性,任何时刻所有节点的数据都一样。听着挺好,但实现成本高,性能差。

最终一致性,允许短暂不一致,但最终所有节点会达成一致。成本低,性能好。

大部分业务不需要强一致性。下单后库存扣减和订单创建,中间差几毫秒,用户感知不到。

但转账这种场景,钱不能少不能多,就得上更强的保证。

2PC和3PC

2PC是最经典的分布式事务协议,分两个阶段。

准备阶段:协调者问所有参与者"能不能提交",参与者执行事务但不提交,返回YES或NO。

提交阶段:所有参与者都返回YES,协调者发"提交"命令;否则发"回滚"命令。

这几个问题我在生产环境都遇到过:

同步阻塞。 Prepare阶段所有参与者都锁着资源。我查了监控,高峰期锁持有时间达到500ms。业务操作本身才10ms,剩下全是等锁。并发一上来,请求全排着队,响应时间飙到几秒。

单点故障。 协调者挂了,参与者就卡在Prepare阶段。我遇到过一次协调者OOM,整个分布式事务卡了十几分钟。当时排查了好久才发现是协调者的问题。

脑裂。 网络分区时,协调者可能给部分参与者发提交,部分发回滚。数据就乱了。

3PC加了超时机制,参与者等太久会自动提交。但还是解决不了脑裂问题。

实际项目中很少直接用2PC/3PC,更多用TCC或Saga。

TCC模式

TCC把分布式事务拆成三个阶段:

Try做资源预留。转账场景,先把A账户100块冻结,不是直接扣掉。

Confirm确认提交。所有Try成功后,执行实际操作。把冻结的100块从A转到B。

Cancel取消回滚。任一Try失败,把冻结的100块解冻。

TCC有三个坑,我都踩过。

空回滚

Cancel执行了,但Try根本没执行过。

我遇到的情况是:Try请求超时了,调用方以为失败就调Cancel。但Try其实成功了,钱已经冻结了。Cancel执行的时候,把没冻结的钱给"解冻"了,账对不上。

排查时我先以为是网络重试导致的双重扣款。看了半天日志才发现,Cancel有记录,但Try没有对应的冻结记录。才反应过来------这是空回滚。

怎么解:Cancel执行前,先查Try有没有执行过。加一张事务记录表,Try成功就写一条。

sql 复制代码
CREATE TABLE tcc_transaction (
  tx_id VARCHAR(64) PRIMARY KEY COMMENT '事务ID',
  status VARCHAR(16) NOT NULL COMMENT 'INIT/TRIED/CONFIRMED/CANCELLED',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

Try执行时先插入记录,Cancel执行时先查记录:

java 复制代码
// Try:先标记,再冻结
public void try(String txId) {
    insert(txId, "INIT");
    freezeAmount(100);
    updateStatus(txId, "TRIED");
}

// Cancel:先检查Try是否执行过
public void cancel(String txId) {
    String status = queryStatus(txId);
    if (status == null) {
        // Try没执行过,标记取消即可,不用解冻
        insert(txId, "CANCELLED");
        return;
    }
    unfreezeAmount(100);
    updateStatus(txId, "CANCELLED");
}

悬挂

Cancel执行完了,Try才到。

比如网络抖动,Cancel先执行,资源释放了。然后Try到了,资源又被预留了。但没人来Confirm或Cancel,资源就一直挂着。

怎么解:Try执行前,检查Cancel有没有执行过。

java 复制代码
public void try(String txId) {
    String status = queryStatus(txId);
    if ("CANCELLED".equals(status)) {
        return; // Cancel已执行,Try不再执行
    }
    // 正常执行
    insert(txId, "INIT");
    freezeAmount(100);
    updateStatus(txId, "TRIED");
}

幂等

Confirm或Cancel可能被重复调用。不处理的话,重试时钱可能多扣或多加。

怎么解:执行前检查状态,做过了就跳过。

Saga模式

另一种方案是Saga。把长事务拆成多个本地事务,每个都有对应的补偿操作。

下单流程为例:创建订单→扣减库存→扣减余额。失败就反向补偿:恢复余额→恢复库存→取消订单。

Saga分两种。编排式没有中央协调者,服务通过事件通信。协调式有中央协调者,统一调度。

Saga踩坑也不少。

最头疼的是补偿风暴。步骤越多,补偿链越长。我那个转账场景还好,只有两步。后来有个下单流程涉及5个服务,中间步骤失败了,前面4个都要补偿,整个回滚跑了十几秒。

还有数据可见性的问题。中间步骤完成后数据已经可见了。用户看到"下单成功",结果后面扣款失败,订单被取消。体验很差。

另外补偿操作本身也可能失败,需要重试。但重试就要保证幂等,不然会出问题。

补偿幂等我踩过坑。有一次网络重试,库存恢复了两次,账对不上。后来每条补偿SQL都加了状态检查:

sql 复制代码
-- 补偿前先检查状态
UPDATE inventory
SET stock = stock + 1, status = 'COMPENSATED'
WHERE order_id = 'xxx' AND status != 'COMPENSATED';

TCC和Saga怎么选

我现在的理解:

维度 TCC Saga
开发成本 高,每个操作都要写Try/Confirm/Cancel 中,每个操作写正向+补偿
资源锁 Try阶段锁定,时间短 不锁定,中间数据可见
一致性 最终一致(通过Confirm/Cancel保证) 最终一致(通过补偿保证)
适用场景 转账、支付等强一致要求 订单、物流等长流程
踩坑重点 空回滚、悬挂 补偿风暴、数据可见性

实际项目中,很多场景用消息队列加本地事务表就够了。能用简单方案解决的,别上复杂框架。

避坑清单

  1. 别一上来就上分布式事务。先想想能不能用本地事务解决,大部分场景不需要。
  2. TCC一定要处理空回滚、悬挂、幂等。这三个坑我都踩过,不处理账迟早对不上。
  3. Saga补偿操作必须加幂等检查。我之前没加,网络重试导致库存恢复了两次。
  4. 框架选型要慎重。AT模式看着简单,但锁竞争激烈,高并发场景扛不住。
  5. 监控必须到位。分布式事务出了问题排查很麻烦,建议用链路追踪把调用链记下来。
  6. 降级方案提前准备好。我有一次Cancel操作卡了,第三方接口慢,整个事务卡了20分钟才发现。后来加了超时控制和降级方案,才没再出问题。

你们项目里用的什么分布式事务方案?评论区聊聊,有踩过坑的也欢迎分享。

我是数据库小学妹,咱们下篇见 👋