为什么大厂很少直接用 2PC/TCC?聊聊分布式事务在真实业务里的落地套路

面试造火箭,工作拧螺丝

如果你去面试 Java 后端,分布式事务几乎是必考题。面试官期待你流利地背出 XA、2PC、3PC、TCC、Seata 的流程和优缺点。

但如果你真的在团队里负责一个跨库的转账业务,或者订单和库存的最终一致性,你会发现一个尴尬的事实:教科书里的那套 2PC 和 TCC,在实际生产环境里直接用,往往会带来更多问题。

大厂当然有自研的强一致方案,但中小团队更常见的情况是:用本地消息表 + MQ 做最终一致性,或者用 Seata AT 模式做轻量级的补偿,甚至有些业务干脆通过"状态机 + 对账"来兜底,根本不引入复杂的分布式事务框架。

这篇文章就把这条路走一遍:先理清理论考点,再落到生产级方案,重点说清楚每个方案的失效边界 和踩坑点。

理论篇:为什么 2PC 和 3PC 在生产中很少直接套用

XA 是一套规范,2PC(二阶段提交)是它的经典实现。它的核心逻辑很简单:协调者先问所有参与者"能提交吗"(Prepare),大家都说可以,再统一执行"提交"(Commit)。

这个模型的优点是强一致:要么全部成功,要么全部回滚。但它的缺陷在生产中极其致命:

第一,同步阻塞。 在 Prepare 阶段执行完 SQL 后,数据库资源(比如行锁)会被一直锁定,直到 Commit 或 Rollback 指令到达。如果协调者宕机,这些资源就会被长时间锁住,直接拖垮数据库。

第二,协调者单点问题。 协调者一旦挂了,整个分布式事务就悬在半空,参与者不知道该怎么办。

第三,数据不一致风险。 如果 Commit 阶段部分参与者因为网络问题没收到指令,就会出现"一半提交、一半未提交"的脏状态。

3PC 引入了超时机制和 CanCommit 阶段,试图缓解阻塞问题。但它在网络分区场景下依然可能导致数据不一致,而且多一轮通信意味着更高的延迟。所以在互联网的高并发场景下,XA 和 2PC 几乎是被抛弃的方案。

那 TCC 呢?TCC(Try-Confirm-Cancel)是业务层面的 2PC。它要求业务方自己实现 Try(预留资源)、Confirm(确认执行)和 Cancel(取消释放)三个方法。它的优势是性能好、不依赖数据库的全局锁,但缺点是业务侵入性极强。

而且 TCC 有三个臭名昭著的坑,如果没处理好,生产环境一样会出大事故:

空回滚(Empty Rollback): 没有执行 Try,却先收到了 Cancel。比如网络超时导致 Try 没到达,但全局事务超时后触发了回滚。如果 Cancel 逻辑直接执行"释放资源",可能会把其他正常业务的资源误释放掉。

悬挂(Suspension): 空回滚之后,迟到的 Try 请求才到达并执行了资源预留。但此时全局事务已经回滚结束了,这笔预留的资源就永远悬挂在那里,没人来 Confirm 或 Cancel。

幂等(Idempotency): Confirm 或 Cancel 可能因为重试被多次调用,必须保证重复执行不会产生副作用。

要解决这三个问题,通常需要引入一张事务记录表,在 Try、Confirm、Cancel 执行前后做状态检查和唯一性约束。这套逻辑写下来,业务代码会变得非常沉重。这也是为什么 TCC 通常只在资金、交易等对一致性要求极高的核心链路中使用,而不是所有业务都套用。

生产篇:最终一致性才是大多数业务的解

对于绝大多数互联网业务(订单、库存、积分、通知),真正合适的是最终一致性 。它允许数据在短时间内不一致,但保证最终会达到一致状态。核心思路是:先保证本地业务成功,再通过可靠的消息机制去驱动下游。

方案一:本地消息表 + MQ(最经典、最通用的方案)

这个方案的核心是:把"写业务数据"和"写消息记录"放在同一个本地数据库事务里。 这样就能保证业务操作和消息发送的原子性------要么都成功,要么都失败。

具体流程:

  1. 业务执行时,在同一个事务里,往业务表(比如 order)插入数据,同时往消息表(比如 message_queue)插入一条状态为"待发送"的消息。
  2. 事务提交后,有一个后台定时任务(或者用独立线程)去扫描消息表,把"待发送"的消息投递到 MQ。
  3. 投递成功后,更新消息状态为"已发送"。
  4. 下游服务消费消息,执行自己的业务逻辑。

java

typescript 复制代码
// 伪代码:本地消息表的核心逻辑
@Transactional
public void createOrder(Order order) {
    orderMapper.insert(order); // 1. 写业务表
    messageMapper.insert(new Message("ORDER_CREATED", order.getId())); // 2. 同一事务写消息表
}

这个方案的好处是不依赖任何分布式事务框架,完全利用数据库的本地事务,实现简单、性能好。但它的坑在于:

坑一:消息重复投递。 如果消息发送成功,但更新消息表状态失败,下一次扫描就会重复发送。所以下游消费者必须做好幂等处理,通常用业务唯一键(比如订单号)或者消息 ID 来去重。

坑二:消息表膨胀。 消息表需要定期清理或归档,否则会无限增长。可以设置一个保留时间(比如 7 天),或者把已发送的消息转移到历史表。

坑三:定时任务扫描延迟。 如果扫描频率太低,消息投递会有延迟。可以在业务事务提交后,通过 TransactionSynchronization 的 afterCommit 回调立即尝试发送一次,失败后再交给定时任务兜底。

方案二:RocketMQ 事务消息(免去自己维护消息表)

如果团队已经在使用 RocketMQ,可以直接用它的事务消息功能,把"本地消息表"的维护工作交给 MQ 框架。

流程是:

  1. 生产者先发送一条"半消息"到 Broker。这条消息对消费者不可见。
  2. Broker 确认收到半消息后,生产者执行本地事务。
  3. 根据本地事务的执行结果,生产者向 Broker 发送 Commit 或 Rollback 指令。
  4. 如果 Broker 长时间没收到指令,会主动回查生产者的本地事务状态,决定提交还是回滚。

这个方案省去了自己建消息表和写定时任务的工作,但它的前提是你的业务必须能通过一个接口来查询本地事务的执行状态,以便 Broker 回查。

java

typescript 复制代码
// RocketMQ 事务消息伪代码
public class OrderTransactionListener implements TransactionListener {
    @Override
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        try {
            orderService.createOrder(...); // 执行本地事务
            return LocalTransactionState.COMMIT_MESSAGE;
        } catch (Exception e) {
            return LocalTransactionState.ROLLBACK_MESSAGE;
        }
    }

    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt msg) {
        // Broker 回查时调用:查询本地事务是否成功
        return orderService.checkOrderCreated(...) ? 
               LocalTransactionState.COMMIT_MESSAGE : 
               LocalTransactionState.UNKNOW;
    }
}

方案三:Seata AT 模式(低侵入的轻量级选择)

如果不想写消息表,也不想用 TCC 那样重的业务改造,Seata 的 AT 模式是一个折中选择。

AT 模式的核心是自动补偿:它通过代理数据源,自动拦截业务 SQL,记录数据变更前后的快照(undo log)。如果全局事务需要回滚,Seata 就根据 undo log 自动生成反向 SQL 来恢复数据。

它的优点是对业务代码几乎零侵入,开发者像写本地事务一样写业务逻辑,Seata 框架在背后处理全局锁和回滚。但它也有明显的局限:

第一,全局锁的性能开销。 AT 模式在第一阶段提交后,会持有全局锁直到第二阶段结束。在高并发写入场景下,全局锁可能成为瓶颈。

第二,SQL 解析的兼容性限制。 AT 模式依赖 SQL 解析来生成 undo log,对于一些复杂的 SQL(比如多表 join 的更新、不常见的函数),可能无法正确解析,导致回滚失败。

第三,脏写风险。 如果多个全局事务同时修改同一行数据,AT 模式通过全局锁来保证隔离性,但如果锁竞争激烈,会退化为串行执行,吞吐量下降明显。

所以 AT 模式适合那些业务逻辑简单、并发量不高、且主要基于关系型数据库的分布式事务场景。 对于秒杀、大促这类高并发核心链路,AT 模式的全局锁会成为致命的性能瓶颈。

我踩过的坑

第一个坑:用 2PC 做跨库转账,数据库连接池被打满。 早期在一个内部管理系统里,跨库操作直接用了 XA 协议。结果某次网络抖动,协调者响应变慢,所有参与者的数据库连接都被长时间锁住,连接池瞬间耗尽,整个系统雪崩。从那以后,非核心业务坚决不用强一致的 2PC。

第二个坑:本地消息表没有做幂等,下游重复发货。 消息表扫描任务因为一次数据库主从延迟,重复投递了一条"订单创建成功"的消息。下游库存服务没有做幂等,直接扣了两次库存,导致超卖。后来在消费端加了基于 orderId 的唯一索引和状态机校验,才彻底解决。

第三个坑:TCC 的 Cancel 没有处理空回滚,把正常资源释放了。 一个营销活动的名额扣减用了 TCC。Try 因为网络超时没到达,全局事务触发 Cancel。Cancel 逻辑直接做了 名额 + 1,结果把另一个正常用户刚扣减的名额给释放了。后来在 Cancel 执行前增加了"检查 Try 是否执行过"的记录表,才堵住这个漏洞。

选型判断与适用场景

方案 一致性 性能 业务侵入性 适用场景
2PC/XA 强一致 差(同步阻塞) 低 极少使用,除非是内部低并发系统
TCC 强一致(最终) 好 极高(需实现三个方法) 资金、交易等核心链路
本地消息表 + MQ 最终一致 好 中(需建表、写扫描任务) 大多数互联网业务(订单、通知、积分)
RocketMQ 事务消息 最终一致 好 低(需实现回查接口) 已使用 RocketMQ 的团队
Seata AT 最终一致(近似强一致) 中等(有全局锁) 极低(自动代理) 简单业务、低并发、关系型数据库

如果让我给一个默认建议:大多数互联网业务,首选"本地消息表 + MQ"或"RocketMQ 事务消息"做最终一致性。 只有在资金扣减、库存扣减等核心链路上,才考虑引入 TCC 或 Seata AT 模式,并且必须做好幂等、空回滚和悬挂的处理。

结尾

分布式事务的本质,不是在代码里加一个 @GlobalTransactional 注解就万事大吉了。它的核心是承认分布式系统的不确定性,然后在业务层面设计出能够容忍短暂不一致、并且最终能收敛到正确状态的机制。

面试时的八股文是门槛,但真正让你在生产环境中活下来的,是对每个方案的失效边界有清醒的认识,并且知道在什么场景下选择"不完美但够用"的最终一致性,而不是盲目追求理论上的强一致。

相关推荐
Thneonl1 小时前
采样采错地方,trace 全断链:OTel 尾部采样的代价账
后端·架构
匆匆那年随风1 小时前
零依赖 C# Office 库到底怎么用——四个场景讲透 Wisdom.Office
后端
今天早睡1 小时前
用子图封装流程,设计清晰的父子状态接口
后端
Lyy1 小时前
DevOps平台 — 第十七篇:测试管理的设计与实现
后端·devops
用户EasyAdminBlazor1 小时前
用 EasyAdminBlazor + OpenCode 开发进销存:实际做了什么,花了多久
后端
ByteRock1 小时前
同样是 Decimal,为什么 MySQL 和 ClickHouse 会写成两套世界
后端
leo在掘金1 小时前
一天155万亿Token,DeepInfer省在哪
后端·架构
阿良别做梦1 小时前
LangGraph 学习之路:让工作流返工,也让它及时停下
后端·开源
我的div丢了肿么办1 小时前
go语言抽离路由文件以及对路由进行分组
后端·go