分布式事务最佳实践:TCC空回滚、Saga补偿风暴与选型建议

简介: 讲述分布式事务从2PC到TCC再到Saga的踩坑经历,解析强一致性与最终一致性的取舍、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成功就写一条。

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执行时先查记录:

// 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有没有执行过。

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都加了状态检查:

-- 补偿前先检查状态
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分钟才发现。后来加了超时控制和降级方案,才没再出问题。

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

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

相关文章
|
算法 Java
jvm性能调优 - 15JVM的老年代垃圾回收器CMS的缺点
jvm性能调优 - 15JVM的老年代垃圾回收器CMS的缺点
494 0
|
2月前
|
消息中间件 NoSQL 数据库
分库分表后数据不一致?3种分布式事务方案,帮你彻底解决“钱货不等”难题
本文由“数据库小学妹”详解分布式事务核心难题:分库分表后如何保障跨库数据一致性。涵盖TCC、消息队列(最终一致性)、2PC等方案对比,强调互联网场景首选“MQ+幂等+本地消息表”,并指出避坑要点(重复消费、消息丢失、悬挂问题)。
|
2月前
|
缓存 监控 NoSQL
【Redis】Redis缓存三大核心问题:缓存穿透 / 击穿 / 雪崩(原因 + 解决方案)
本文系统解析Redis缓存三大高危问题:**穿透**(查不存在数据)、**击穿**(热点Key过期瞬间并发压库)、**雪崩**(大量Key集中失效或集群宕机)。深入剖析根因,提供分层防护方案——布隆过滤器+参数校验防穿透、永不过期+本地缓存防击穿、过期打散+高可用架构防雪崩,并强调全链路兜底与生产避坑要点。
|
2月前
|
SQL 关系型数据库 MySQL
MySQL慢查询诊断实战:从10秒到0.1秒,我的5步排障法
数据库小学妹分享慢查询优化实战:从10秒降至0.08秒!详解「发现→收集→分析→优化→验证」5步排障法,覆盖慢日志配置、EXPLAIN进阶、索引失效场景、JOIN与分页优化等核心技巧,附真实案例与速查表。
|
2月前
|
canal 缓存 NoSQL
数据库扛不住高并发?Redis缓存+双写一致性:给你的系统装上“涡轮增压”
数据库小学妹带你破解Redis缓存一致性难题!面对高并发,如何确保Redis与数据库数据同步?详解“先更库后删缓”“延时双删”“Binlog异步同步”等4大方案,直击雪崩、击穿、穿透三座大山,助你构建又快又稳的数据库架构.
|
2月前
|
消息中间件 关系型数据库 MySQL
CDC实时数据同步:让数据库变更秒级流向大数据平台!
本文由“数据库小学妹”生动讲解CDC(变更数据捕获)核心原理与实战:基于MySQL binlog实时捕获INSERT/UPDATE/DELETE事件,通过Debezium解析为含before/after的结构化消息,推送至Kafka,实现缓存、ES、Flink等系统的零侵入、秒级同步。兼顾原理、避坑与场景,让数据流通真正实时可靠。
|
2月前
|
SQL 缓存 关系型数据库
主从延迟的5大“元凶”+3个排查命令,别再让从库拖后腿
数据库小学妹详解MySQL主从延迟:5大元凶(硬件弱、写压大、慢查询、网络差、大事务)+3条核心排查命令(SHOW SLAVE STATUS等),助你快速定位、精准优化,避坑生产故障!
|
3月前
|
SQL 关系型数据库 MySQL
数据量大查询慢?索引让你的SQL秒级响应!|转行学DB第9天
用生活化比喻(如字典目录)详解索引原理:它通过B+树结构加速查询,避免全表扫描;涵盖创建、查看、删除索引方法,联合索引的最左前缀原则,以及读写平衡等实战要点——让查询从“等几秒”变“秒出”!
数据量大查询慢?索引让你的SQL秒级响应!|转行学DB第9天
|
3月前
|
SQL 关系型数据库 MySQL
EXPLAIN 执行计划:一眼看穿你的SQL慢在哪
数据库小学妹带你轻松掌握SQL性能诊断!通过EXPLAIN查看执行计划,精准识别索引失效、全表扫描(ALL)、key为NULL等瓶颈。聚焦type、key、rows等6个关键字段,结合实战案例与避坑指南(如函数滥用、最左前缀破坏),让优化有的放矢。学完即用,告别盲目调优!
|
27天前
|
存储 关系型数据库 MySQL
MySQL索引底层原理:B+树能存多少数据?页分裂与回表机制详解
数据库小学妹带你深入B+树底层:为何选它而非二叉树或哈希?揭秘页分裂/合并机制、聚簇与二级索引差异、回表代价及磁盘I/O优化逻辑。3层B+树可存约800万数据,查询仅需3次I/O!