
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的三个核心角色TMTransaction Manager事务管理器负责全局事务的开启、提交和回滚。在业务调用方这一侧TM的逻辑就嵌入在代理拦截器里。RMResource Manager资源管理器负责管理分支事务也就是每个微服务本地数据库的事务。RM会向TC注册分支事务上报分支状态。TCTransaction 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通知RMRM根据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申请XIDtx.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 groupIdcom.alibaba.fescar/groupId artifactIdfescar-spring/artifactId version0.4.2/version /dependency同时需要配置TC的地址信息在application.properties中添加fescar.tx.service.vgroupMapping.my_test_tx_groupdefault fescar.tx.service.default.grouplist127.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会返回一个branchIdRM把这个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级别避免日志量过大。