说实话,搞了这么多年微服务,真正让团队熬夜加班的,往往不是接口性能,不是并发调优,而是分布式事务。尤其是订单和库存这种跨服务、跨库的核心链路,一旦出现数据不一致,第二天对账时发现订单显示已支付但库存没扣,或者库存扣了订单却取消了,那种排查过程真的能让人崩溃三天。这篇文章我把自己踩坑踩出来的实战经验整理一下,从理论到落地,讲清楚2PC为什么不够用、Saga模式怎么落地、Seata在整个方案里的定位和工作原理,顺便把金融级场景里那些“教科书上不会写”的细节也一并说了。
如果你正在做微服务拆分,或者你们的系统里已经出现“跨库一致性”问题,又或者你只是想把分布式事务这件事彻底搞明白,这篇文章应该能帮上忙。我会尽量用聊天的口吻讲干货,涉及到原理的地方给类比,涉及到代码的地方给可直接抄的配置。
1. 为什么分布式事务在金融场景里是个绕不开的坎
1.1 单体时代没那么复杂
在单体应用时代,一个订单从创建到扣库存、扣余额、生成流水,全部发生在同一个数据库事务里。BEGIN TRANSACTION到COMMIT之间,所有操作要么全部成功,要么全部回滚,ACID特性由数据库本身保证。你不需要关心什么一致性协议,也不需要设计补偿逻辑,出了问题直接ROLLBACK,数据天然是一致的。
但微服务架构把这一切拆碎了。订单服务管订单库,库存服务管库存库,账户服务管资金库。原来的“一个本地事务”变成了“跨服务、跨数据库的多个本地事务”。每个服务各自提交自己的事务,但在全局视角下,这些事务加在一起是否具备原子性和一致性,就变成了一个全新的工程问题。
1.2 一个典型的订单与库存场景
我举一个最常见的例子:用户在电商平台下单,前端调用订单服务创建订单,订单服务内部要完成两步操作,一是写入订单表(状态为待支付),二是调用库存服务扣减库存。在金融或电商场景里,扣库存这件事往往还会关联到锁定库存、预占库存、释放库存等状态流转。
问题在于:订单服务写入订单成功,但调用库存服务时网络超时了,这时候订单已经落库,库存却没有扣减。如果你重试,可能会出现订单重复、库存超扣的风险;如果你不重试,订单和库存的数据就永远对不上。等到后续流程依赖库存数据做决策时,整套系统的可信度就崩了。
1.3 为什么金融级场景更敏感
金融级场景和普通互联网业务最大的区别,在于对数据一致性的要求是“零容忍”的。普通电商场景,偶尔一个订单超卖,运营做个补偿可能就过去了。但资金账户、交易流水、资产持仓这一类数据,任何一条不一致的记录都会引发严重的对账问题,甚至触碰合规红线。
所以金融级场景里,分布式事务方案不仅要解决“最终一致”,还得考虑“实时一致窗口”多长、失败后的补偿是否可靠、幂等机制是否完善、会不会出现资金悬空等细节。这也是为什么从2PC到Saga,再到Seata这类中间件,始终没有一个“银弹”,每种方案都在做取舍,你需要找到最适合自己业务形态的那一种。
2. 从2PC说起:经典方案的优势与硬伤
2.1 2PC的核心流程
2PC(两阶段提交协议)是最经典的分布式事务方案之一,它的核心思想是引入一个协调者(Coordinator),由协调者统一驱动所有参与者(Participant)完成事务提交。
第一阶段是准备阶段(Prepare)。协调者向所有参与者发送准备请求,每个参与者执行本地事务操作,但先不提交,把事务状态锁定在“可提交”状态,然后向协调者返回“准备完成”或“准备失败”的响应。第二阶段是提交阶段(Commit/Abort)。协调者根据所有参与者的响应做决策:如果全部参与者都返回准备完成,协调者广播提交请求,各参与者正式提交事务;如果任何一个参与者返回准备失败,协调者广播回滚请求,各参与者回滚本地事务。
2.2 2PC在真实场景里会遇到什么问题
2PC理论上是完备的,但它实际落地时存在几个硬伤,我一个个说。
第一,同步阻塞问题。2PC的整个过程中,所有参与者的事务资源(比如数据库行锁、连接池连接)都是保持持有的。参与者数量越多,锁定的资源越多,事务的整体耗时越长,系统的并发吞吐能力就会被严重拖累。在高并发订单场景里,这种阻塞几乎是不可忍受的。
第二,协调者单点问题。协调者本身也是系统的一部分,如果协调者在第二阶段崩溃了,所有参与者都处于“事务状态不确定”的情况——它们不知道到底是该提交还是该回滚。这个状态只能依赖协调者恢复后重新决策,但在恢复之前,相关资源一直被锁着。这个窗口期越长,对业务的影响越大。
第三,数据可见性问题。在2PC的第一阶段,事务操作的结果是处于中间状态的,其他读操作可能会读到“未提交的修改”。如果业务场景要求强一致读,2PC的中间状态会带来额外的复杂度。
2.3 2PC适合用在什么场景
说了这么多问题,并不代表2PC一无是处。对于那些参与者数量少、事务执行时间短、并发压力不大的场景,2PC依然是一个合理的选择。比如企业内部的管理系统、低频的管理操作,2PC的简单直接反而是最大的优点。
但是,在电商大促、金融高频交易这类场景里,2PC的同步阻塞和协调者单点问题会被放大成系统瓶颈。这也是为什么业界逐渐从2PC转向柔性事务方案,Saga模式就是其中最具代表性的一种。
3. Saga模式:用补偿思维解决一致性
3.1 Saga的核心思想
Saga模式的核心思想是:将一个长事务拆分成一系列有序的本地事务,每个本地事务都有对应的补偿事务(Compensation Transaction)。正常流程走正向操作,如果某个环节失败,就反向执行已完成的环节对应的补偿操作,实现数据回滚。
举个例子,订单流程拆成三个子事务:创建订单 → 扣减库存 → 扣减余额。如果扣减余额发现余额不足,就需要反向执行“补回库存”和“取消订单”两个补偿操作。注意,Saga的补偿操作是业务层面的反操作,不是数据库层面的回滚。扣库存对应的补偿是加库存,创建订单对应的补偿是更新订单状态为已取消。
3.2 编排式Saga与协同式Saga
Saga有两种落地形态,一种叫编排式(Orchestration),一种叫协同式(Choreography)。这两种我都实际用过,各有利弊。
编排式Saga引入一个中心化的协调器(Orchestrator)来管理整个事务流程。协调器负责按顺序调用各个参与服务,监听每个服务的执行结果,在出现失败时触发补偿操作。它的优点是流程清晰,事务状态集中管理,排查问题比较直观。缺点是协调器本身可能成为性能瓶颈,也增加了额外的开发和维护成本。
协同式Saga不引入中心协调器,每个服务在执行完本地事务后,通过事件总线(比如Kafka)发布事件,由下一个服务监听事件并继续执行后续操作。它的优点是去中心化,服务之间耦合度更低。缺点也很明显:整个事务的流转逻辑分散在各服务的事件处理器里,出了问题很难追踪全链路状态。
从我的实践经验来看,金融级场景我更推荐编排式Saga。因为金融场景对事务的可观测性和审计要求很高,中心化的协调器可以记录每一步的执行状态和补偿状态,对排查问题、做对账都更友好。
3.3 Saga的保证范围与边界
需要注意的是,Saga模式提供的是最终一致性,而不是实时一致性。在事务执行的窗口期,不同服务的数据是处于“暂时不一致”的状态的。比如订单已经创建了,但库存还没扣减成功,这时候你去查库存,看到的数据就是“还没扣减”的旧数据。
这在很多业务场景里是没问题的,因为读请求可以通过一些手段规避不一致的中间状态,比如读请求统一走“只读已提交状态的服务”。但如果你有“必须实时强一致”的业务约束,Saga模式就无法满足,必须回到2PC或者改用其他方案。
4. Seata架构解析:TC、TM、RM到底各司其职
4.1 Seata是什么
Seata是阿里巴巴开源的一套分布式事务解决方案,目前是Apache顶级项目。它把分布式事务的三方角色做了清晰划分——TC、TM、RM,并把AT、TCC、Saga、XA等多种事务模式统一在一个框架里。对业务方来说,用注解就能接入,侵入性比自行实现要低得多。
4.2 三个核心角色
TC(Transaction Coordinator,事务协调器)是独立部署的服务,负责全局事务的注册、分支事务的状态记录、全局提交或回滚的决策。可以把它理解成Saga编排模式里的那个中心协调者,但在Seata里,TC是独立中间件,不跟业务代码耦合。
TM(Transaction Manager,事务管理器)是嵌入在业务应用里的,负责开启全局事务、提交或回滚全局事务。通常我们在业务代码里加@GlobalTransactional注解,实际上就是让TM工作——它向TC发起全局事务的开启和提交/回滚请求。
RM(Resource Manager,资源管理器)同样嵌入在业务应用里,负责管理分支事务的资源,比如处理本地事务的提交、回滚、以及向TC注册分支事务和上报执行状态。在AT模式下,RM还负责生成undo_log回滚日志。
三者的协作流程是:TM向TC申请开启全局事务,得到全局事务ID(XID);XID通过调用链传递到各个微服务;每个微服务中的RM执行本地事务,并向TC注册分支事务;最终TM根据所有分支事务的结果,向TC发起全局提交或回滚请求,TC再统一协调所有RM完成提交或回滚。
4.3 AT模式:无侵入的自动补偿
Seata最受欢迎的是AT模式(Auto Transaction)。AT模式利用了数据库本身的本地事务,在业务操作之外通过拦截SQL解析来生成回滚日志。具体来说,在执行业务SQL之前,RM会先查询出要修改的数据的原始镜像,执行完SQL后,再查询出修改后的新镜像,这两份镜像都保存到undo_log表中。当全局事务需要回滚时,RM根据undo_log里的原始镜像生成反向SQL,把数据恢复到修改之前的状态。
AT模式对业务方的侵入性非常低,基本只需要加@GlobalTransactional注解,不需要显式写补偿逻辑。但它的前提是:数据库必须支持本地事务,且所有参与事务的服务必须使用同一类支持事务的关系型数据库。另外,AT模式的回滚依赖undo_log表,如果数据在事务执行期间被其他本地事务修改了,回滚时可能产生冲突。
4.4 TCC模式:业务补偿的精细化控制
TCC模式(Try-Confirm-Cancel)是另一种在Seata中广泛使用的模式,它要求业务方自己实现三个方法:Try阶段完成资源预检和预留,Confirm阶段完成业务确认提交,Cancel阶段完成业务回滚释放资源。
TCC的优点是比较灵活,可以精确控制资源的预留和释放,适合库存、资金这类需要预占/释放的场景。缺点也很明显:业务侵入性很高,需要为每个需要分布式事务的操作编写三套逻辑,开发和维护成本都不低。
我在实际项目里通常这么选:如果业务比较标准、数据库统一,优先用AT模式,因为成本最低;如果业务逻辑复杂、涉及资金账户的复杂状态流转,或者对性能有极高要求,那就用TCC,自己控制资源锁定粒度,减少行锁时间。
5. 订单与库存分布式事务:Seata在金融级场景的落地实践
5.1 场景设定与技术选型
我们用一个最常见的业务来演示:用户在平台下单购买商品。整个流程涉及订单服务(Order Service)和库存服务(Inventory Service),两者各自使用独立的数据库。技术栈假定为Spring Boot + MyBatis + MySQL,Seata版本为1.5.x以上。
在这个场景里,我要保证的核心约束是:订单创建成功,库存必须扣减成功;如果库存扣减失败,订单必须被标记为无效,不能出现”订单有效但库存不足“的情况。
5.2 部署Seata Server(TC)
Seata Server是独立部署的。下载Seata服务端包后,需要修改两个配置文件:
持久化方式建议用数据库存储事务状态,这样Seata Server重启后不会丢失事务状态信息。在application.yml里,把存储模式改成db,并配置对应的数据库连接。接着需要初始化Seata Server自己的库表,包括global_table、branch_table、lock_table三张表。
服务端配置的核心部分如下:
server: port: 7091 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/seata_server?useUnicode=true&characterEncoding=utf8 username: root password: root123 seata: store: mode: db db: datasource: druid db-type: mysql driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/seata_server?useUnicode=true&characterEncoding=utf8 username: root password: root123配置完成后,启动Seata Server,默认监听端口是8091(服务端口是7091用于控制台)。这一步没什么花哨的,但要注意一个坑:Seata Server自身的数据库和业务数据库千万不能混用,否则后续排查问题会非常痛苦。
5.3 业务服务集成Seata客户端
订单服务和库存服务都需要引入Seata客户端依赖并配置接入参数。以订单服务为例,pom.xml里需要加入:
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId> <version>2.2.2.RELEASE</version> </dependency>在application.yml里配置事务组和TC地址:
spring: application: name: order-service cloud: alibaba: seata: tx-service-group: my_test_tx_group seata: registry: type: file config: type: file service: vgroup-mapping: my_test_tx_group: default grouplist: default: 127.0.0.1:8091这里的tx-service-group建议起一个有意义的名字,后续监控和排查时你能快速区分不同业务链路的事务组。
数据库里还需要创建undo_log表,这是AT模式回滚日志的存储表:
CREATE TABLE IF NOT EXISTS `undo_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `branch_id` BIGINT NOT NULL, `xid` VARCHAR(100) NOT NULL, `context` VARCHAR(128) NOT NULL, `rollback_info` LONGBLOB NOT NULL, `log_status` INT NOT NULL, `log_created` DATETIME NOT NULL, `log_modified` DATETIME NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;5.4 订单服务核心代码与事务链路
在订单服务中,创建订单的接口是分布式事务的发起方,也就是TM所在的位置。我在这边的代码是这么写的:
@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private InventoryFeignClient inventoryFeignClient; @GlobalTransactional(name = "create-order-tx", rollbackFor = Exception.class) public void createOrder(OrderDTO orderDTO) { // 本地事务:插入订单记录,状态为待扣库存 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setProductId(orderDTO.getProductId()); order.setStatus("CREATED"); orderMapper.insert(order); // 远程调用:扣减库存 InventoryReduceRequest request = new InventoryReduceRequest(); request.setProductId(orderDTO.getProductId()); request.setQuantity(orderDTO.getQuantity()); inventoryFeignClient.reduceStock(request); } }在库存服务中,扣减库存的方法加上@Transactional即可,Seata的RM会自动接管它的分支事务注册和回滚逻辑:
@Service public class InventoryService { @Autowired private InventoryMapper inventoryMapper; @Transactional public void reduceStock(InventoryReduceRequest request) { int rows = inventoryMapper.deductStock(request.getProductId(), request.getQuantity()); if (rows == 0) { throw new BusinessException("库存不足"); } } }整个执行链路是这样的:当createOrder方法被调用时,TM向TC注册全局事务并生成XID。随后本地插入订单的操作由RM执行,并向TC注册分支事务。接着Feign调用库存服务时,XID会通过调用链传递过去,库存服务里的RM也向TC注册分支事务。如果库存扣减抛异常,TM会捕获到异常并向TC发起全局回滚请求,TC下发回滚指令,两个服务里的RM根据undo_log执行回滚。
5.5 金融级场景的关键细节调整
上面这个demo能跑通,但真要放到金融级场景,还得额外处理几个问题。
一是超时时间的设置。金融场景里远程调用超时不能设得太短,否则会因为网络抖动导致事务误判。但也不能太长,否则全局事务长时间挂起,资源被锁住。我一般建议初始值设在3秒左右,然后根据监控数据逐步调整。
二是重试机制。Feign调用库存服务失败时,不能直接连同订单库一起回滚就完事,得设计合理重试。如果重试几次仍然失败,才触发全局回滚。这样能显著降低偶发网络问题导致的无效回滚,提升整个链路的事务成功率。
三是幂等机制。因为分布式环境下,重试、网络超时等都很常见,下游服务必须对相同请求做幂等处理。比如库存扣减接口,每次请求都带一个唯一业务主键(可以用订单号+商品号+操作类型拼接),数据库对该主键做唯一约束,重复请求直接返回成功。
6. 常见问题与排查技巧实录
6.1 全局事务不回滚了怎么办
这是用Seata最常遇到的问题:代码里加了@GlobalTransactional,但服务B抛异常后,服务A的本地事务竟然没有回滚。碰到这个情况,先别急着怀疑框架有问题,按照下面几步排查。
第一步,确认XID是否传递到服务B。在服务B的接口实现里加上日志,打印RootContext.getXID()。如果XID是空的,说明Seata的XID传递机制没生效。常见原因是Feign拦截器没配置,或者使用了异步线程导致XID丢失。Feign的场景下,需要确保SeataFeignClientAutoConfiguration被正常加载,同时检查Hystrix或Sentinel之类的熔断组件是否把线程上下文切换了。
第二步,确认TC是否收到了分支事务注册。去Seata Server的日志里搜服务A和服务B对应的分支信息。如果服务B没有注册,可能是RM没被正确初始化,检查Seata客户端的配置和undo_log表是否创建成功。
第三步,确认异常是否被正常抛出。如果服务B的reduceStock方法内部catch住了异常,没有继续往上抛,TM就感知不到失败,自然不会触发回滚。这一点经常被忽略,代码里吞掉异常的行为在分布式事务场景里是致命伤。
6.2 回滚冲突:undo_log数据被覆盖
AT模式的一个风险点在于,如果全局事务执行期间,同一行数据被其他本地事务修改了,回滚时就会产生冲突。Seata的默认行为是尝试重试,但如果重试后仍然失败,就需要人工介入处理。
我从实践中得到的经验是:在设计表结构时,尽量让分布式事务保护的数据行在事务执行期间不与其他本地事务产生交叉。比如库存表经常会进行并发扣减更新,这种热点数据用AT模式要特别小心。实际的规避手段有两个,一是用乐观锁,在回滚时校验版本号;二是把热点行拆分,分散并发压力。
6.3 事务隔离级别与脏读
Seata AT模式默认的隔离级别是读未提交(Read Uncommitted),这意味着在全局事务执行过程中,其他事务可能读到事务中尚未提交的数据变更。这在某些金融场景下是不能接受的。
解决方案有两个。一是使用Seata提供的SELECT ... FOR UPDATE语句,在读取时加锁,保证读到的是已经提交的数据。二是在业务设计层面规避,比如把查询口统一设计成“只读已确认数据”,把未完成事务产生的中间状态数据标记为不可见。
6.4 监控与告警
金融级场景里,没有监控的分布式事务方案等于裸奔。我在项目里会做三件事。
第一,Seata Server自身监控。开启Prometheus指标暴露,监控TC所在的机器CPU、内存、堆积事务数量。全局事务堆积量是一个核心指标,如果不断增长,说明有事务卡住没有终态。
第二,业务侧监控。在@GlobalTransactional方法入口和出口埋点,记录每个全局事务的耗时和结果。同时在Feign调用和本地事务的关键节点打日志,方便追踪全链路。
第三,告警规则。全局事务失败率超过阈值、分支事务注册数量异常、undo_log回滚失败等,都应该触发告警。这样即使出了问题,你至少能在用户发现之前感知到。
7. 我对分布式事务方案选型的一些个人看法
7.1 没有银弹,按场景选型
聊了这么多,我最想传达的一个观点是:分布式事务没有银弹,没有哪种方案是“终极方案”。2PC、Saga、AT、TCC各有适用边界,选型的核心依据是业务对一致性、可用性、性能三者之间的权衡。
如果你的业务要求强一致、参与方少、并发量不高,2PC或者Seata的XA模式是合适的,它能给你最强的数据一致性保证。如果你的业务链条较长、参与方多、并发量大,Saga模式是更好的选择,通过补偿机制换来更高的可用性和性能。如果你的公司内部数据库统一、业务操作标准,Seata AT模式是最省力的,因为它对代码侵入最小。如果你的业务操作复杂,需要精细控制资源预占和释放,TCC模式值得投入研发资源。
7.2 分布式事务方案只是工具,核心还是业务设计
我看到过很多团队,上来就引入Seata,指望靠一个框架解决所有数据一致性问题。但实际上,很多场景根本不需要分布式事务。比如订单创建和库存扣减,你可以通过库存预占接口先行锁定库存,最后再通过消息队列异步确认扣减。订单创建成功后发消息,库存服务消费消息执行扣减,这个方案本质上用消息队列的可靠投递+消费幂等解决了最终一致性,完全不需要引入分布式事务中间件。
所以我的建议是:设计阶段先问自己,这个操作的实时一致性是必须的吗?有没有可能通过异步解耦来规避?如果确实需要分布式事务,再评估用Seata的哪种模式。中间件只是最后的兜底手段,而不是第一选择。
7.3 落地之后还要做的事
Seata落地之后不是结束,而是一个新的开始。你需要建立一套完整的演练和应急机制。我自己维护的项目里,会定期做故障演练,比如主动kill掉某个参与服务、让TC暂时不可用、模拟数据库网络分区,然后观察全局事务的行为是否符合预期。这些演练能提前暴露很多配置问题,比如超时时间设置不合理、回滚逻辑缺失、幂等保护不到位等。
最后再分享一个小技巧:上线初期,别急着把所有跨服务操作全部接入Seata,先挑一条低频、核心、容易出问题的链路做试点。等这条链路稳定运行一两个星期,监控指标都正常了,再逐步扩大覆盖范围。分布式事务这种机制,本身就增加了系统的复杂度,如果一开始就把所有业务都裹进来,出了问题你连排查方向都找不到。老老实实一步一步来,反而比追求一步到位更快。