1. 从一个真实场景说起:分布式事务到底难在哪
我第一次接触分布式事务是在一个电商下单场景里。用户点击“提交订单”之后,系统需要同时做三件事:扣减库存、生成订单、扣减用户账户余额。这三个操作分别落在三个不同的微服务上,每个服务有自己的数据库。单体应用时代,一个本地事务就能搞定的事情,拆成微服务之后突然变得异常棘手。
难在哪?难在“一致性”这三个字。本地事务靠数据库的ACID保证,一旦跨了服务、跨了数据库,ACID就失效了。你可能想到两阶段提交(2PC),但2PC的性能问题和协调者单点故障让很多团队望而却步。你也可能想到最终一致性方案,比如基于消息队列的可靠消息,但业务侵入大、补偿逻辑写起来让人头秃。
Fescar(后来演进为Seata)就是在这个背景下出现的。它的目标很明确:让业务方像用本地事务一样使用分布式事务,对业务代码的侵入降到最低。而“业务调用方”这个角色,恰恰是Fescar整个链路里最先接触、也最能体现其设计哲学的一环。
这篇文章我会从源码角度,把Fescar中业务调用方的启动流程、代理机制、事务发起过程、上下文传播等核心环节逐一拆开讲。适合已经用过Fescar/Seata但想深入理解其内部原理的开发者,也适合正在做分布式事务选型、想搞清楚“它到底怎么做到的”的技术负责人。读完之后,你应该能自己画出业务调用方的完整调用链路,并且在遇到问题时知道该从哪个环节下手排查。
2. 业务调用方的整体设计与核心思路
2.1 为什么业务方几乎不用改代码
Fescar最吸引人的一点是:业务方只需要在发起全局事务的方法上加一个@GlobalTransactional注解,剩下的脏活累活框架全包了。这个体验的背后,是代理模式在起作用。
具体来说,Fescar通过Spring的AOP机制,对标注了@GlobalTransactional的方法进行拦截。拦截之后,框架会在方法执行前开启一个全局事务,生成一个全局唯一的XID(全局事务ID),然后在方法执行过程中,把这个XID通过服务调用的上下文传递给下游服务。方法执行完毕,再根据执行结果决定是提交还是回滚全局事务。
这个设计的精妙之处在于:业务开发者不需要关心XID怎么生成、怎么传递、怎么提交回滚,这些全部由框架的代理层和底层通信层完成。业务代码里看不到任何分布式事务的痕迹,就像写本地事务一样自然。
2.2 核心角色拆解:TM、RM和TC
要理解业务调用方的行为,必须先搞清楚Fescar的三个核心角色:
- TM(Transaction Manager,事务管理器):负责全局事务的开启、提交和回滚。在业务调用方这一侧,TM的逻辑就嵌入在代理拦截器里。
- RM(Resource Manager,资源管理器):负责管理分支事务,也就是每个微服务本地数据库的事务。RM会向TC注册分支事务,上报分支状态。
- TC(Transaction Coordinator,事务协调器):独立部署的服务端,负责维护全局事务和分支事务的状态,驱动全局事务的提交或回滚。
业务调用方在发起一次全局事务时,TM先向TC申请开启全局事务,拿到XID。然后业务方法执行,过程中调用下游服务,XID随之传播。下游服务的RM接收到XID后,向TC注册分支事务,执行本地操作。所有分支执行完毕后,TM根据整体结果向TC发起全局提交或回滚,TC再通知各个RM执行相应的分支操作。
这个链路里,业务调用方同时扮演了TM和RM的双重角色——它既是全局事务的发起者(TM),也可能有自己的本地数据库操作(RM)。
2.3 方案选型背后的取舍
Fescar没有采用传统的2PC,而是设计了一套改进版的2PC。传统2PC的问题在于:协调者要求所有参与者在第一阶段就锁定资源,直到第二阶段结束才释放,锁持有时间太长,并发能力差。
Fescar的做法是:第一阶段,RM执行本地事务时就直接提交(而不是像传统2PC那样等协调者指令),但同时会记录一份undo log(回滚日志)。如果全局事务最终需要回滚,TC通知RM,RM根据undo log生成反向SQL把数据补偿回来。这样本地事务的锁在第一阶段就释放了,大大提升了并发性能。
这个设计的关键前提是:本地事务必须能通过undo log反向补偿。所以Fescar对SQL有一定的要求,比如不支持某些复杂的DDL操作。这也是业务方在使用时需要注意的约束。
3. 核心细节解析:代理拦截与事务发起
3.1 @GlobalTransactional注解的处理入口
业务调用方的起点是@GlobalTransactional注解。Fescar通过一个叫GlobalTransactionalInterceptor的拦截器来处理这个注解。这个拦截器实现了Spring的MethodInterceptor接口,会在目标方法执行前后插入事务逻辑。
拦截器的核心逻辑大致是这样的:
public Object invoke(MethodInvocation methodInvocation) throws Throwable { // 1. 判断方法或类上是否有@GlobalTransactional注解 GlobalTransactional annotation = getAnnotation(methodInvocation); if (annotation == null) { return methodInvocation.proceed(); } // 2. 有注解,走全局事务逻辑 return handleGlobalTransaction(methodInvocation, annotation); }这里有一个细节值得注意:拦截器会同时检查方法级别和类级别的注解,方法级别的优先级更高。如果你在类上标注了@GlobalTransactional,那么这个类里所有public方法都会走全局事务逻辑。实际使用中建议只在需要的方法上标注,避免不必要的性能开销。
3.2 事务模板的执行流程
拿到注解之后,拦截器会委托给TransactionalTemplate来执行。这个模板类是整个事务发起流程的核心编排者,它的execute方法定义了全局事务的完整生命周期:
public Object execute(TransactionalExecutor business) throws Throwable { // 1. 获取或创建全局事务 GlobalTransaction tx = GlobalTransactionContext.getCurrentOrCreate(); try { // 2. 开启事务(向TC注册,获取XID) tx.begin(business.timeout(), business.name()); // 3. 执行业务方法 Object rs = business.execute(); // 4. 提交事务 tx.commit(); return rs; } catch (Throwable ex) { // 5. 异常时回滚事务 tx.rollback(); throw ex; } }这个流程看起来简单,但每一步都有不少细节。比如begin的时候,如果当前线程已经存在一个全局事务(说明是嵌套调用),Fescar不会重新开启,而是复用已有的XID。这就是所谓的事务传播行为。
3.3 全局事务的开启:向TC申请XID
tx.begin()最终会走到DefaultGlobalTransaction.begin()方法。这个方法会通过RPC向TC发送一个开启全局事务的请求,TC收到后生成一个全局唯一的XID返回。
XID的格式通常是这样的:ip:port:transactionId。比如192.168.1.100:8091:1234567890。这个XID在整个全局事务的生命周期内保持不变,是串联所有分支事务的关键标识。
这里有一个容易踩的坑:TC的地址配置。业务调用方需要知道TC部署在哪里,这个信息通过配置项service.vgroupMapping来指定。如果配置错了,begin阶段就会直接失败,报连接不上TC的错误。我在实际项目中遇到过因为配置了错误的vgroup名称导致事务无法开启的情况,排查了半天才发现是配置问题。
3.4 事务上下文的创建与绑定
拿到XID之后,Fescar会创建一个RootContext,把XID绑定到当前线程的ThreadLocal上。这个RootContext是全局事务上下文的核心载体,后续所有需要XID的地方都从这里取。
RootContext.bind(xid);绑定之后,当前线程内的任何代码都可以通过RootContext.getXID()拿到当前的全局事务ID。这个机制是XID能够跨方法、跨服务传播的基础。
但ThreadLocal有一个天然局限:它只在当前线程有效。如果业务方法里开了新线程去执行子任务,新线程是拿不到XID的。Fescar对此的处理方式是:在需要跨线程传递的场景下,手动从父线程获取XID并绑定到子线程。这一点在异步编程场景下需要特别注意。
4. 实操过程:从注解到XID传播的完整链路
4.1 环境准备与依赖引入
要复现业务调用方的完整流程,首先需要搭建一个基本的运行环境。假设你已经有一个Spring Boot项目,需要引入Fescar的starter依赖:
<dependency> <groupId>com.alibaba.fescar</groupId> <artifactId>fescar-spring</artifactId> <version>0.4.2</version> </dependency>同时需要配置TC的地址信息,在application.properties中添加:
fescar.tx.service.vgroupMapping.my_test_tx_group=default fescar.tx.service.default.grouplist=127.0.0.1:8091这里的my_test_tx_group是事务分组名称,default是TC集群名称,grouplist是TC的地址列表。实际部署时,TC通常是集群模式,grouplist里可以配置多个地址。
4.2 业务方法的标注与代理生效
在需要发起全局事务的方法上添加注解:
@Service public class OrderService { @GlobalTransactional(timeoutMills = 300000, name = "create-order") public void createOrder(OrderDTO order) { // 扣减库存 storageService.deduct(order.getProductId(), order.getCount()); // 创建订单 orderDao.insert(order); // 扣减余额 accountService.debit(order.getUserId(), order.getAmount()); } }timeoutMills指定全局事务的超时时间,默认是60秒。name是事务名称,方便在TC端做监控和排查。
代理生效的前提是Spring的AOP配置正确。Fescar的starter会自动注册GlobalTransactionalInterceptor,但如果你用的是手动配置方式,需要确保拦截器被正确织入。一个常见的坑是:同类内部方法调用不会触发代理。比如在OrderService里有一个方法A调用了标注了@GlobalTransactional的方法B,如果A和B在同一个类里,B的注解不会生效,因为代理对象只拦截外部调用。
4.3 XID在服务调用链中的传播
业务方法执行过程中,会通过Feign或RestTemplate调用下游服务。Fescar需要把XID从当前服务传递到下游服务。这个传递是通过服务调用拦截器实现的。
以Feign为例,Fescar提供了一个FescarFeignClient,它会在发送HTTP请求之前,把当前线程的XID放到请求头里:
RequestTemplate template = ...; String xid = RootContext.getXID(); if (StringUtils.isNotBlank(xid)) { template.header("TX_XID", xid); }下游服务收到请求后,会通过一个TransactionPropagationInterceptor从请求头里取出XID,并绑定到自己的RootContext上。这样下游服务的RM在执行本地操作时,就能拿到XID并向TC注册分支事务。
这个传播机制的关键在于:请求头的名称必须一致。Fescar默认使用TX_XID作为请求头key,如果上下游配置不一致,XID就传不过去,下游会当成一个独立的本地操作,不会纳入全局事务管理。这是一个非常隐蔽的坑,排查起来也不容易。
4.4 分支事务的注册与本地执行
下游服务的RM在拿到XID后,会在执行本地数据库操作之前,通过ConnectionProxy向TC注册分支事务。注册时会带上XID、资源ID(通常是数据库连接的信息)、锁信息等。
注册成功后,TC会返回一个branchId,RM把这个branchId和本地事务关联起来。然后RM执行本地SQL,同时把执行前的数据快照记录到undo log表里。本地事务提交后,RM向TC上报分支事务的状态。
这里有一个重要的细节:undo log的记录是在本地事务的同一个数据库连接里完成的。也就是说,业务SQL和undo log的写入在同一个本地事务中,保证了原子性。如果本地事务提交成功,undo log一定写入了;如果本地事务回滚,undo log也不会存在。
4.5 全局事务的提交与回滚
业务方法执行完毕后,回到TransactionalTemplate。如果一切正常,调用tx.commit()。TM会向TC发送全局提交请求,TC收到后,通知所有已经注册的RM进行分支提交。
注意,Fescar的分支提交是异步的。TC只是把全局事务的状态标记为提交,然后异步通知各个RM。RM收到通知后,清理undo log即可,因为本地事务在第一阶段已经提交了。
如果业务方法抛出了异常,TransactionalTemplate会调用tx.rollback()。TM向TC发送全局回滚请求,TC通知所有RM进行分支回滚。RM收到回滚通知后,根据undo log生成反向SQL,把数据恢复到修改前的状态。
这里有一个性能上的考量:回滚是同步还是异步。在Fescar的早期版本中,回滚是同步的,TM需要等待所有RM回滚完成才返回。后来优化为异步回滚,提升了响应速度,但也带来了一个问题:如果回滚过程中某个RM失败了,需要有重试机制来保证最终一致性。
5. 常见问题与排查技巧实录
5.1 事务不生效的几种典型情况
在实际使用中,事务不生效是最常见的问题。我整理了一个排查表,按优先级从高到低排列:
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 加了注解但事务没开启 | 同类内部调用,代理未生效 | 检查调用方是否通过代理对象调用 |
| 事务开启了但下游没纳入 | XID未传播,请求头不一致 | 检查上下游的请求头配置 |
| 下游注册分支失败 | TC地址配置错误 | 检查vgroupMapping和grouplist |
| 全局回滚但数据没恢复 | undo log未正确记录 | 检查undo log表是否存在及数据 |
| 事务超时后状态不一致 | 超时时间设置过短 | 调整timeoutMills并检查TC配置 |
5.2 XID传播失败的排查思路
XID传播失败是我踩过最多的坑。有一次下游服务明明收到了请求,但事务就是没纳入全局管理。排查过程是这样的:
首先确认上游有没有把XID放到请求头里。可以在Feign的拦截器里打日志,看RootContext.getXID()是否有值。如果没有值,说明上游的全局事务根本没开启,问题出在更前面。
如果有值但下游拿不到,就检查下游的拦截器有没有正确解析请求头。Fescar的TransactionPropagationInterceptor默认从TX_XID头里取,如果上游用的key不一样,自然取不到。
还有一种情况是:下游服务用了自定义的HTTP客户端,没有走Fescar提供的拦截器。这种情况下需要手动在请求头里加上XID,或者把自定义客户端替换成Fescar支持的版本。
5.3 undo log相关的注意事项
undo log是Fescar实现回滚的关键。有几点需要特别注意:
第一,undo log表必须提前创建。Fescar不会自动建表,需要手动执行建表SQL。如果表不存在,本地事务执行时会报错。
第二,undo log的记录有性能开销。每次写操作都要额外写一条undo log,对数据库的写入压力会增大。在高并发场景下,需要评估这个开销是否可接受。
第三,undo log的清理策略。全局事务提交后,undo log理论上可以立即删除。但Fescar为了支持异步回滚和重试,会保留一段时间。如果清理不及时,undo log表会越来越大,影响查询性能。建议配置定时清理任务。
5.4 超时与重试的处理经验
全局事务的超时时间需要根据业务的实际执行时间来设置。设得太短,正常业务可能被误判为超时;设得太长,异常情况下资源释放不及时。
我的经验是:先统计业务方法在P99情况下的执行时间,然后在此基础上乘以1.5到2倍作为超时时间。比如P99是3秒,超时时间可以设为5到6秒。
超时之后,TC会主动发起回滚。但这时候业务方法可能还在执行中,回滚和业务执行会并发进行,可能导致数据不一致。所以业务方法本身也需要有超时控制,避免执行时间过长。
重试方面,Fescar的TC在通知RM回滚失败时,会进行重试。重试次数和间隔可以配置。如果重试多次仍然失败,需要人工介入处理。建议对这种情况配置告警,及时发现和处理。
6. 从源码看设计:几个值得学习的设计模式
6.1 模板方法模式在事务编排中的应用
TransactionalTemplate是模板方法模式的典型应用。它定义了事务执行的骨架流程:开启、执行、提交/回滚。具体的开启和提交逻辑由GlobalTransaction接口的实现类来完成。这种设计的好处是:流程固定,但具体实现可以替换。比如未来要支持不同的事务协调器,只需要换一个GlobalTransaction实现即可。
6.2 拦截器链的灵活扩展
Fescar的拦截器设计也很巧妙。GlobalTransactionalInterceptor只负责判断是否需要开启全局事务,具体的执行逻辑委托给TransactionalTemplate。而XID的传播则通过独立的拦截器实现。这种职责分离的设计,让每个拦截器都足够简单,也方便按需组合。
6.3 ThreadLocal与上下文传递的权衡
用ThreadLocal存储XID是最简单直接的方式,但它的局限性也很明显:跨线程传递需要手动处理。Fescar没有选择用InheritableThreadLocal,因为线程池场景下InheritableThreadLocal会失效。取而代之的是,Fescar提供了RootContext的静态方法,让开发者在需要的时候手动绑定和解绑。这是一种务实的取舍:不追求自动化,但给开发者足够的控制权。
7. 我个人在实际操作中的体会
回过头来看,Fescar业务调用方的设计核心就是两个字:透明。它通过代理和拦截器,把分布式事务的复杂性封装在框架内部,让业务开发者几乎无感知。但这种透明也带来了一些代价:一旦出问题,排查链路会比较长,需要你对框架的内部机制有足够的了解。
我的建议是:在使用Fescar之前,先花时间把业务调用方的源码走读一遍,搞清楚XID的生成、传播、绑定、清理的完整链路。这样在遇到问题时,你能快速定位是哪个环节出了岔子。另外,undo log表的监控和清理一定要提前做好,不要等到表膨胀了才想起来处理。
最后分享一个小技巧:在开发和测试阶段,可以把TC的日志级别调到DEBUG,这样能看到每一次事务开启、分支注册、提交回滚的详细日志。对于理解整个流程非常有帮助。生产环境再调回INFO级别,避免日志量过大。