1. 从一个线上事故说起:下单成功了,库存却扣了两次
大概两年前,我负责过一个电商中台项目,核心链路是用户下单后同步扣减库存、生成订单、发优惠券。这套链路最初是单库单服务,日子过得很舒坦。后来业务拆分,库存独立成服务,订单独立成服务,优惠券也独立成服务,问题就来了。
上线当晚,我们收到告警:有一笔订单用户支付成功,但库存扣减接口超时重试,结果库存被扣了两次。更麻烦的是,订单表里那笔订单状态是“已支付”,但下游的积分系统根本没收到消息,因为本地消息表的事务和订单事务不在同一个数据库里,回滚根本无从谈起。
那次事故排查到凌晨三点,最终结论是:我们需要一套能把多个独立资源(数据库、消息队列、远程服务)纳入同一个事务语义的机制,这就是分布式事务。
这篇文章我不会给你堆概念,而是从实际问题出发,讲清楚分布式事务的核心矛盾、主流方案的内在逻辑、Seata这种框架的工作原理,以及我个人在项目中真实踩过的坑和选型经验。内容会比较长,我尽量说人话。
在正式开始之前,先同步一个基本认知,否则后面很多讨论会绕晕。
分布式事务要解决的并不是“让多个操作同时成功或同时失败”这种笼统问题,而是要在网络不可靠、节点可能宕机、数据可能分区的前提下,让跨节点的数据最终达成一致。这就引出了两个关键词:一致性和可用性。你没法同时完美拥有它们,所以所有分布式事务方案本质上都是在这两者之间做取舍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么本地事务搞不定分布式场景:核心矛盾拆解
2.1 从ACID到BASE:分布式场景下的“一致性”变味了
传统单机数据库事务有四个特性:原子性、一致性、隔离性、持久性,也就是ACID。在单库单服务时代,这一切由数据库的锁、日志、回滚段来保证,你只要写BEGIN和COMMIT就行,剩下的交给数据库。
但服务拆分之后,订单数据在订单库,库存数据在库存库,它们各自的事务是独立的。你没办法让MySQL和另一个MySQL或者Redis在底层共享同一个回滚日志。这是分布式事务的第一个核心矛盾:事务的边界被网络和独立的存储节点割裂了。
于是大家退而求其次,提出了BASE理论:Basically Available(基本可用)、Soft state(软状态)、Eventually consistent(最终一致)。大白话就是:我不追求任何时刻都强一致,但我保证经过一段时间后,数据能收敛到一致状态。这个“收敛”的过程,就是分布式事务框架要干的事。
2.2 分布式事务要解决的三个具体问题
我把它归纳成三个问题,你在做任何技术方案时都能对照着看:
- 原子性问题:跨多个节点的多个操作,如何保证要么全部成功、要么全部失败?注意,这里的“失败”不是指数据库回滚,而是指对已经成功的操作做补偿。
- 一致性问题:在操作进行中,不同节点读到的数据可能是不同的(比如库存扣了但订单未生成),如何让数据最终收敛到一致?
- 隔离性问题:多个并发事务同时操作同一批数据时(比如两个订单同时扣同一个库存),如何避免互相覆盖?这在分布式场景下远比单机复杂,因为很可能没有统一的锁机制。
这三个问题对应到具体方案里,就是你看到的二阶段提交、三阶段提交、TCC、Saga、本地消息表、事务消息等一堆名词。它们各自在不同维度上做了取舍,没有一个银弹。
2.3 一个最经典的失败场景:订单与库存
很多人一提起分布式事务就说“订单和库存”。我们把这个场景掰开揉碎看一下:
假设用户下单,前端调订单服务创建订单(写订单库),订单服务再调库存服务扣减库存(写库存库)。
如果扣库存成功,但返回给订单服务时网络超时,订单服务会怎么做?大多数情况下会重试。重试就会带来重复扣减。你要么让库存接口做幂等(通过订单号+商品ID作为唯一键),要么引入分布式事务让两个操作成为一个原子单元。
如果不做任何处理,就会出现三种常见脏数据:
- 订单生成了,库存没扣(超卖)。
- 库存扣了,订单没生成(少卖,库存凭空消失)。
- 订单生成了,库存扣了两次(多扣,用户投诉)。
这三种情况,就是分布式事务要消灭的典型问题。接下来我会逐个方案讲清楚它们各自是怎么处理这些问题的。
3. 二阶段提交和三阶段提交:教科书的方案为何不实用
3.1 XA协议和二阶段提交(2PC)的完整流程
二阶段提交是分布式事务的老祖宗,由XA协议实现,几乎所有主流数据库(MySQL、Oracle、PostgreSQL)都原生支持。
它的流程分两步:
准备阶段(Prepare):事务协调器(Coordinator)给所有参与者(数据库)发送准备请求。每个参与者执行事务的SQL,但不提交,而是把事务状态写成“就绪”(Ready),并写入undo/redo日志。注意,此时数据在事务内部是可见的,对其他事务不可见。
提交阶段(Commit/Rollback):如果所有参与者都返回“就绪”,协调器发送提交指令,各参与者正式提交。如果有任何一个参与者返回“失败”或超时,协调器发送回滚指令,各参与者根据undo日志回滚。
看起来很美,对吧?但你在实际项目中几乎不会直接用XA。
3.2 2PC的三个致命问题
第一是同步阻塞:在准备阶段,所有参与者都要持有资源锁直到第二阶段结束。如果协调器挂了,这些锁会一直持有,数据库连接池很快被耗尽,系统基本瘫痪。
第二是协调者单点:协调者本身就是单点,它挂了,整个事务就悬挂在那,所有参与者只能干等。
第三是数据不一致的窗口仍然存在:第二阶段如果协调者在发送提交指令时宕机,有的参与者收到了提交,有的没收到,数据还是不一致的。
3.3 三阶段提交(3PC)做了哪些改进,为什么还是不够
三阶段提交在二阶段中间插入了一个“预提交”(PreCommit)阶段,并且引入了超时机制,避免参与者无限等待。
流程变成:CanCommit(询问能否提交) -> PreCommit(预执行,写日志但不提交) -> DoCommit(正式提交)。
它的改进是:参与者不再无限阻塞,超时会自动放弃事务。但代价是:如果在PreCommit阶段之后有参与者超时放弃,而此时协调者发出了提交指令,那么已经放弃的参与者数据就会不一致。所以三阶段只是降低了阻塞风险,并没有解决一致性问题。
一句话总结:2PC和3PC适合对一致性要求极高、并发量不大、参与者较少的场景,比如跨机房的同构数据库同步。在互联网高并发场景下,它太重了,几乎没人直接用。但理解它是理解后面所有方案的基础,因为TCC和Seata的AT模式本质都是在2PC思路上做改良。
4. 真正在用的主流方案:TCC、Saga、本地消息表与事务消息
这一节是全文的重点,我会把四个最常见的方案讲透,包括每个方案的适用场景、代码层面的落地思路和坑点。
4.1 TCC:补偿思想的最佳实践
TCC是Try、Confirm、Cancel三个单词的缩写。它把一个业务操作拆成三个阶段:
- Try:尝试执行,完成资源预留。比如扣库存场景,Try阶段不是真正扣减库存,而是把库存数量冻结掉(冻结库存 = 冻结字段 + 1,可用库存 = 可用库存 - 1)。
- Confirm:确认执行,真正执行业务。比如冻结库存转成实际扣减(冻结字段 - 1,实际扣减字段 - 1)。
- Cancel:取消执行,释放预留资源。比如把Try阶段冻结的库存释放回可用库存。
TCC要求业务系统自己实现这三个方法,所以它对业务侵入性很强,但也因此非常灵活。比如在跨行转账场景中,A行扣钱、B行加钱,TCC的Try阶段就是分别冻结A的扣款金额和检查B的账户状态,Confirm阶段才是真正扣款和入账。
**为什么TCC比2PC好?**因为Try阶段不持有数据库锁,它只是资源预留,其他事务仍然可以操作非预留部分的数据。比如库存100件,TCC预留了10件,其他事务还可以买剩下90件,而2PC是整个表锁住。这在并发场景下是天壤之别。
TCC的最大难点是空回滚和幂等。空回滚指的是:Try阶段因为网络超时没执行成功,但Cancel却被调用了,此时Cancel要能正确处理“没冻结过”的情况。幂等问题指的是:Confirm和Cancel可能被重复调用(网络重试),你必须保证第二次调用不会产生副作用。这两个问题都需要你在业务代码里用事务状态表来兜底。
4.2 Saga:长事务的最终一致方案
Saga模式最初来自论文《Saga》,核心思想是把一个长事务拆成一系列本地事务,每个本地事务都有对应的补偿事务。比如一个旅游预订流程:订机票 -> 订酒店 -> 租车。如果订酒店失败,就取消机票,这就是Saga。
Saga有两种执行方式:
- 串行编排:一个事务完成后,触发下一个事务,这个过程由编排中心控制。如果某个事务失败,编排中心反向调用之前所有事务的补偿事务。
- 事件编排:每个服务监听事件,自己决定要不要执行下一步。比如订单服务发布“订单已创建”事件,库存服务监听到后扣库存,扣完发“库存已扣减”事件,积分服务监听后再加积分。任何一个服务失败,通过事件回溯触发补偿。
Saga的优点是:没有锁,没有Try阶段,事务粒度小,适合长链路、跨系统、业务逻辑复杂的场景。缺点是:它只能保证最终一致,中间状态对用户是可见的。比如机票订好了,酒店还在预订中,用户已经能看到部分结果。另外,补偿事务本身也可能失败,你还要做重试和人工介入。
Saga和TCC的区别:TCC是“预留 -> 确认/取消”,Saga是“直接执行 -> 失败则补偿”。TCC更适合需要防并发超卖的库存场景,Saga更适合流程长、对中间状态不敏感的业务场景,比如审批流、订单全链路状态流转。
4.3 本地消息表:最朴素但最可靠的方式
这个方案我第一次听说时觉得太土了,后来才明白它才是很多大厂核心链路(尤其是对账、回调类业务)的压舱石。
做法是:在业务数据库里建一张本地消息表,发起方在同一个本地事务里写业务数据和消息记录:
code复制BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1001;
INSERT INTO local_message (msg_id, payload, status) VALUES ('uuid', '扣减库存', 'PENDING');
COMMIT;
业务数据和消息记录在同一个数据库里,天然就是原子提交。之后会有一个异步任务扫描PENDING状态的消息,将消息投递到MQ,下游消费者消费成功后回调确认,更新消息状态为CONSUMED。
如果MQ投递失败或者消费失败,异步任务会不断重试。如果重试超过N次,就转人工或走告警。
这个方案的核心价值在于:它把分布式事务拆成了“本地事务 + 异步重试”两个简单可靠的环节,用数据库的原子性来保证不会丢消息,用重试来保证最终一致。缺点是需要维护消息表,且对消息消费方有幂等要求。但对很多业务来说,维护一张表的成本远低于引入一个分布式事务框架的成本。
我在实际项目中见过一个极端case:一个订单推送系统,用本地消息表支撑了每日百万级投递量,消息表数据量增长到亿级,最后用按天分表解决。稳定运行两年多,几乎没有出现数据不一致。
4.4 事务消息:RocketMQ给的标准答案
事务消息本质上是对本地消息表的优化——把消息表从业务数据库挪到了MQ内部。这里以RocketMQ为例说明:
正常流程:
- 生产者发送半消息(Half Message)给MQ Broker,此时消息对消费者不可见。
- 生产者执行本地事务。
- 本地事务执行成功,向MQ发送Commit,消息对消费者可见;执行失败,发送Rollback,消息被删除。
关键点:如果生产者发送Commit/ Rollback之前宕机了怎么办?RocketMQ有事务消息回查机制。Broker会隔一段时间反向询问生产者,你那条半消息对应的本地事务到底成功了没有?生产者需要提供一个回调接口,根据本地事务的执行状态返回Commit或Rollback。
事务消息相比本地消息表的优势是不需要自己建表、不需要自己写重试调度,MQ帮你做了。但它要求你使用RocketMQ(或其他支持事务消息的MQ,比如Kafka在部分版本也支持类似功能,但生产级支持还是RocketMQ最成熟),而且同样要求消费者一定要做幂等。
关于幂等,这里必须多说一句:不管是本地消息表还是事务消息,虽然能保证消息必达,但MQ大多数是At Least Once语义,也就是消费者可能会收到重复消息。所以消费者的处理逻辑里必须包含幂等判断。最简单的做法是:消费者在执行前查询本地事务表,看这个消息ID是否已经处理过。处理过就跳过。
5. Seata AT模式的工作原理与落地实操
讲完理论,必须落到框架。目前国内用的最多的是Seata,它提供了AT、TCC、Saga、XA四种模式。其中AT模式是最有特色的,因为它对业务代码几乎零侵入,我来重点拆一下它的原理。
5.1 AT模式的三组件架构
Seata有三个核心角色:
- Transaction Coordinator(TC):全局事务协调器,它是一个独立服务,负责维护全局事务的状态,驱动事务的提交和回滚。TC需要独立部署,官方叫Seata Server。
- Transaction Manager(TM):事务管理器,在业务代码里通过
@GlobalTransactional注解标注,负责开启、提交、回滚全局事务。 - Resource Manager(RM):资源管理器,它和底层数据库交互,负责管理分支事务的状态。RM是嵌入在业务服务里的一个代理层。
整个流程:TM向TC注册全局事务,拿到全局事务ID(XID);XID通过Dubbo、Spring Cloud等RPC框架传递到下游服务;下游服务接收到XID后,RM注册分支事务;当TM发起全局提交/回滚时,TC统一协调所有RM执行相应动作。
5.2 AT模式的数据回滚原理:镜像机制
AT模式最核心的设计是数据快照(镜像)。它的执行流程是这样的:
- 执行业务SQL前,RM会查询该行数据的当前值,生成前置镜像(before image)。比如
UPDATE product SET stock = 100 - 1 WHERE id = 1,它会先查出stock = 100存起来。 - 执行业务SQL更新数据,
stock变成99。 - RM再次查询该行数据,生成后置镜像(after image),即
stock = 99。 - 将前置镜像、后置镜像和业务SQL一起写入undo_log表(这是Seata要求你在业务库里额外建的表)。
- 业务SQL提交。
如果是全局回滚,RM会根据undo_log里的镜像数据执行反向SQL。比如前置镜像是stock = 100,后置镜像是99,那回滚SQL就是把stock改回100。
这个设计的精妙之处在于:业务开发者不需要写任何补偿代码,就像在单库里写事务一样。但实际上,Seata替你在底层做了补偿。
5.3 AT模式的并发控制与脏写
但这里有个大坑:如果两个全局事务同时操作同一行数据怎么办?比如事务A扣库存,前置镜像stock = 100,执行后变成99,还没提交;事务B也来扣库存,它查到stock = 99(取决于隔离级别),前置镜像就是99,执行后变成98。如果A最终回滚,它把stock从99改回100,这时候B的修改就被覆盖了。
Seata应对这个问题的手段是全局锁。在AT模式下,RM在执行SQL前会尝试获取该行数据的全局锁(这个锁记录在TC上)。如果获取不到,说明有其他全局事务在操作这行数据,那当前事务会等待或根据隔离级别报错。全局锁能避免多个全局事务的并发写冲突。
但是注意,全局锁不是万能的。它只锁“Seata管理下的写”,如果你有一些Seata感知不到的本地SQL直接修改了同一行数据,依然会出现脏写。比如一个定时任务直接连数据库执行了UPDATE product SET stock = stock - 5,没走Seata,那全局锁对它无效,Seata在回滚时就会把这条本地修改覆盖掉。
5.4 Seata AT模式实操步骤(Spring Cloud Alibaba版)
下面是一个完整的落地步骤,直接可以照着做。
第一步:部署Seata Server
下载Seata Server包,修改conf/registry.conf,注册中心我用的是Nacos:
yaml复制registry {
type = "nacos"
nacos {
serverAddr = "127.0.0.1:8848"
namespace = ""
group = "SEATA_GROUP"
username = "nacos"
password = "nacos"
}
}
启动:sh seata-server.sh -p 8091 -m file。
第二步:业务库创建undo_log表
每个参与分布式事务的数据库都必须执行:
sql复制CREATE TABLE IF NOT EXISTS `undo_log` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`branch_id` BIGINT(20) NOT NULL,
`xid` VARCHAR(100) NOT NULL,
`context` VARCHAR(128) NOT NULL,
`rollback_info` LONGBLOB NOT NULL,
`log_status` INT(11) NOT NULL,
`log_created` DATETIME NOT NULL,
`log_modified` DATETIME NOT NULL,
`ext` VARCHAR(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4;
第三步:应用侧引入依赖和配置
xml复制<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
application.yml里配置:
yaml复制seata:
tx-service-group: my_test_tx_group
registry:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
config:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
第四步:代码里加注解
在入口服务的方法上加@GlobalTransactional,下游服务不用加,但RM会通过拦截器自动参与:
java复制@GlobalTransactional(timeoutMills = 30000, name = "order-create-tx")
public void createOrder(OrderDTO order) {
// 1. 创建订单(本地事务)
orderMapper.insert(order);
// 2. 扣库存(RPC调用库存服务,库存服务里是普通本地事务)
inventoryService.deductInventory(order.getProductId(), order.getCount());
// 3. 发优惠券
couponService.sendCoupon(order.getUserId());
}
注意:每一步调用下游服务时,需要确保RPC框架能把XID传递过去。在Spring Cloud Alibaba里,如果你用了OpenFeign,需要开启seata.feign.enabled: true,否则下游服务感知不到全局事务。
5.5 Seata AT模式的性能代价和资源占用
AT模式不是免费的午餐,你享受了零侵入的便利,就要承担以下代价:
- 一份数据写两次:需要额外写undo_log和全局锁记录,写入放大严重。
- 全局锁竞争:在高并发写同一行数据的场景(比如秒杀),全局锁会成为瓶颈,大量事务会阻塞在等待锁上。
- SQL限制:AT模式不支持
SELECT ... FOR UPDATE(会锁冲突),也不支持批量更新,某些复杂SQL会解析失败。
所以在秒杀这类高并发强隔离场景,很多人会选择TCC而不是AT模式,因为TCC的Try阶段资源预留不依赖全局锁,冲突概率更小。
6. 一张决策表帮你做选型:到底该用哪个方案
之前每次技术评审,我都会被问到“你觉得我们该用哪个分布式事务方案”。这个问题没法一刀切回答,我整理了一张选型决策表,基本覆盖我见过的主流场景。
| 方案 | 一致性级别 | 吞吐量 | 业务侵入性 | 典型场景 | 主要风险 |
|---|---|---|---|---|---|
| 2PC/XA | 强一致 | 低 | 低 | 跨库同构数据迁移、低频对账 | 阻塞、协调者单点 |
| TCC | 最终一致 | 高 | 高(需写三个方法) | 库存扣减、转账、预扣款 | 空回滚、幂等、Cancel失败 |
| Saga | 最终一致 | 高 | 中 | 长流程、多服务编排、订单全链路 | 中间状态可见、补偿复杂 |
| 本地消息表 | 最终一致 | 中 | 中 | 异步通知、数据同步、积分发放 | 消息表膨胀、消费幂等 |
| 事务消息 | 最终一致 | 高 | 低 | MQ能保证消息可靠投递的场景 | 需支持事务消息的MQ |
| Seata AT | 默认读已提交级别,最终一致 | 中 | 极低 | 业务代码改动少、团队经验不足时首选 | 全局锁冲突、写入放大 |
| Seata TCC | 最终一致 | 高 | 高 | 高并发库存、账务类操作 | 需要业务方实现预留逻辑 |
我个人的选型思路,按优先级排列:
- 如果业务链路短且并发低,比如内部管理系统,Seata AT模式是最省事的,直接上。
- 如果并发高、核心资源(库存/金额)强隔离,比如电商秒杀,TCC。
- 如果链路长、涉及服务多、对中间状态不敏感,比如下单到发货的完整流程,Saga。
- 如果是异步任务,不需要同步返回事务结果,比如发消息、发短信、同步ES,本地消息表或事务消息,别杀鸡用牛刀。
注意一个非常重要的点:不是所有业务都需要分布式事务。能用“本地事务+定时对账”解决的,就不要引入分布式事务框架。分布式事务引入的是额外的复杂度、性能损耗和运维成本,尤其是Seata Server本身又是新的单点,你得保证它也高可用。
7. 实战中的四大坑,每个都是我花钱买来的教训
7.1 坑一:超时时间和全局锁的相互影响
我用Seata AT模式跑过一个压测,发现大量超时异常。后来定位到原因:某个下游服务数据库连接池只有10个,同时有5个分支事务各占一个连接,在等待全局锁释放。而全局锁的持有者也在等数据库连接提交,形成了死锁般的等待。
解决办法有两个方向:一是调大@GlobalTransactional的timeoutMills,但这只是拖延问题;二是优化下游服务的数据库连接池大小,并给Seata全局锁增加超时重试机制。我最终的做法是把连接池从10调到30,同时把client.rm.lock.retryInterval调小,问题解决。
7.2 坑二:消息事务的重复消费
我用事务消息做订单同步时,消费者在消费消息后执行了数据库更新,但在返回ACK之前进程宕机,MQ重新投递了这条消息,导致重复更新。
解决办法:消费者里加一个去重表,以消息ID为唯一键,消费前先插入,插入成功才执行业务逻辑。后续如果收到重复消息,插入会报唯一键冲突,直接跳过。注意,这个去重表的插入和执行业务逻辑必须在同一个本地事务里,否则还是可能丢。
7.3 坑三:TCC的Cancel调用顺序不是“后进先出”
很多人以为TCC的Cancel是逆序执行,比如A->B->C,回滚时C->B->A。确实,Seata会尽量逆序,但网络是异步的,可能出现C先回到Try状态,B还没执行完。如果你的Cancel逻辑依赖于前置Try完成,就会出大问题。
所以TCC的Try、Confirm、Cancel三个方法都必须设计成幂等且自包含。也就是说,Cancel不管在什么状态下被调用,都要能正确执行,不能假设前置方法已经完成了。
7.4 坑四:别把分布式事务用在读链路
我在实际项目中见过有人把查询接口也加上@GlobalTransactional,理由是“要保证多个服务查询的数据一致性”。这完全是误区。
分布式事务控制的是写操作的原子性,读链路的一致性应该通过缓存、读写分离和必要的对账来保证。把读操作纳入全局事务,只会增加无谓的开销和等待。
8. 伸手可用的收尾建议:先想清楚你真的需要分布式事务吗
文章最后,我不做“总结”,就分享一些我在真实项目里的体会。
第一个体会是:优先考虑能否避免分布式事务。很多所谓的分布式事务场景,其实可以通过调整业务逻辑来规避。比如把订单和库存放在同一个库里,比如通过预扣库存设计让下单和扣库存变成单库单服务操作。能不做分布式事务就不做,这是成本最低的方案。
第二个体会:如果确定要做,先选最容易实现的方案,再逐步演进。我做过一个积分系统,最开始用本地消息表,跑了半年很稳定。后来因为MQ统一迁移到RocketMQ,顺手换成了事务消息,代码更简洁。没有必要一开始就上Seata全家桶。
第三个体会是:一定要有最终的对账兜底机制。不管你用了多牛的分布式事务框架,都有可能因为bug、人为操作、网络分区导致数据不一致。所以,任何核心链路都要配套对账任务,定期扫描订单表、库存表、流水表,发现不一致就告警并自动修复。这不是重复建设,这是运维底线。
分布式事务这个领域,理论很多、框架很多、坑也很多,但核心就是在“一致性”和“性能”之间找平衡点。把上面这些方案都吃透,遇到实际场景时你心里会更有底。
