Seata分布式事务三大角色解析:TC/TM/RM职责与协作

Seata分布式事务三大角色解析:TC/TM/RM职责与协作 1. 整体设计与角色拆解1.1 为什么分布式事务会让人头大先把场景摆出来你在做一个电商下单功能订单服务创建订单库存服务扣减库存账户服务扣钱。三件事必须同时成功或者同时失败。单机数据库里这是本地事务的活一条BEGIN到COMMIT就搞定。但服务一旦拆开、数据库一拆多本地事务就管不到别人家了。订单库提交了库存库那边网络抖动或者机器宕机最终结果就是订单在库存没扣超卖。很多人第一反应是“我直接跨库扣嘛”或者“用消息队列做最终一致性”。但有些核心链路就是需要强一致性不能接受中间态。Seata 这类分布式事务中间件就是为了解决这个问题。它的设计核心不是推翻你已经写好的业务代码而是把“全局事务”的协调工作独立出来让业务方可以像写本地事务一样写分布式事务。Seata 的全称是 Simple Extensible Autonomous Transaction Architecture名字里的“自治”很关键它不侵入你的 SQL 语义也不要求你把业务逻辑全部重写。只要你愿意在自己的微服务里接上它的 SDK把分布式事务的边界声明出来它就能帮你协调所有参与方。要理解 Seata第一步就是搞清楚它最著名的三个角色。社区常把它叫“黄金三角”TC 事务协调者、TM 事务管理器、RM 资源管理器。这三个角色各自承担不同职责但又是同一个全局事务生命周期里不可分割的部分。我见过不少同学把这三个角色搞混尤其是 TM 和 RM经常分不清谁在什么时候该干什么。这篇就把它们彻底拆开结合订单和库存的经典场景讲透。1.2 三大角色职责速览先给一个结论性的表格后面再逐项展开。这张表怎么看都不过分面试、排障、设计方案之前都值得回来过一眼。角色英文全称核心职责类比TCTransaction Coordinator维护全局事务状态负责全局提交/回滚的决策与调度总导演TMTransaction Manager定义全局事务边界发起全局提交或回滚请求项目经理RMResource Manager管理分支事务注册分支、上报状态、执行提交/回滚现场执行小组TC 是独立部署的服务端TM 和 RM 都是以 SDK 形式嵌在你的应用进程里的客户端。所以你在代码里看到的GlobalTransactional注解其实是 TM 的事而数据源代理、UndoLog 管理、分支注册这些是 RM 的事。理解这层“进程内 vs 独立服务”的关系是后面排查问题的基础。2. 核心细节解析与实操要点2.1 TC全局事务的调度中枢TC 在 Seata 里是真正意义上的中心节点。在 1.x 版本中它对应的是 seata-server 这个 Java 进程负责接收 TM 开启全局事务的请求记录全局事务 ID也就是大家常说的 XID然后协调下面的所有 RM。一个全局事务从开始到结束TC 至少做这么几件事生成 XID 并下发保存全局事务状态接收 RM 的分支注册请求维护分支事务列表接收 RM 的分支状态上报在收到 TM 的全局提交或回滚请求后向所有 RM 发送对应的分支提交或回滚指令并最终更新全局事务状态。前面说的这些听起来很简单但实际做起来有一个很容易被忽视的点TC 必须自己保存事务状态而且这个状态要能跨网络、跨进程传递。所以 XID 的传播特别关键。Seata 的 XID 本质是IP:端口:事务编号这样的字符串通过 Dubbo、Spring Cloud 等框架的拦截器在服务调用链路上透传。只要子服务能拿到上游传过来的 XIDRM 才知道自己要加入哪个全局事务。实操上TC 的高可用是重中之重。很多人做概念验证的时候只启动一个 seata-server在生产却因为单点问题被坑过。Seata 的 TC 依赖外部存储来持久化全局事务会话目前支持 file、db、redis 三种模式。单机开发用 file 就行生产环境至少得 DB 模式加集群部署。DB 模式下有两张核心表global_table存全局事务branch_table存分支事务。TC 在把事务状态写进数据库之后客户端就收到响应这时候不能想当然地认为内存里的状态就够用因为一旦 seata-server 重启内存状态全丢如果不从 DB 恢复那些未完成的全局事务就成了僵尸事务。集群模式下还会涉及 loadbalance 的问题。因为 XID 里带了 TC 地址RM/TM 拿到 XID 后其实会直接往那个 TC 地址发请求。如果你的 TC 是 Nginx 负载均衡的地址XID 里写入的可能是某一个具体节点地址这会导致重启后客户端找不到原来的 TC。解决方式是把 seata-server 放在固定域名后面或者保证 TC 节点能通过后面的固定地址被客户端访问到必要时自己定制 XID 的生成策略。2.2 TM只负责开和关别多做TM 的职责简单说就两个开启全局事务、结束全局事务。在代码里的体现就是GlobalTransactional注解或者GlobalTransactionContext手动编码。为什么说“别多做”我在一些团队见过把业务逻辑也塞进 TM 自定义拦截器里的操作结果事务控制范围变得非常不可控。TM 不应该关心某个分支事务具体怎么执行的它只需要根据业务方法的执行结果决定全局提交还是全局回滚。业务方法抛出异常TM 调用 TC 的全局回滚方法正常返回TM 调用 TC 的全局提交。这里有个特别坑的细节GlobalTransactional默认只对 RuntimeException 回滚checked exception 不会触发的。很多新手在这个地方翻车。比如你调库存服务的时候库存不足抛出了一个自定义的StockNotEnoughException如果这个异常继承的是Exception而不是RuntimeException那 Seata 默认不会回滚全局事务超卖就发生了。解决办法有两种。一种是把自定义异常继承RuntimeException这是最简单粗暴的。另一种是在注解里指定rollbackFor Exception.class。我个人更推荐第二种因为它更明确不依赖团队的编码习惯。如果你用GlobalTransactionContext手动管理那记得在 catch 代码块里显式调用rollback()。TM 的边界控制也是一个经常被讨论的话题。在一个微服务调用链路里理论上只有最外层入口处需要标注 TM。如果你在链路中间的某个方法上也加了GlobalTransactional就会发生全局事务嵌套。Seata 对嵌套的处理是把内层的注解忽略掉XID 复用外层的事务但“忽略”有时候会造成误解内层方法里的异常已经被 catch 住了外层感知不到全局事务照样提交。这个坑很隐蔽排查起来特别费时间。2.3 RM真正干活的角色RM 在 Seata 中是最忙碌的。每个参与分布式事务的资源方都要有一个 RM。大家常说的“数据源代理”就是 RM 在本地做的一个重要工作。RM 具体做什么我用 AT 模式举例。业务代码里操作数据库不再直接使用原始的 DataSource而是被 Seata 包装成DataSourceProxy。当你执行一条 UPDATE 或者 INSERT 的时候RM 会做几件额外的事解析 SQL 生成镜像数据before image 和 after image把镜像数据写入 undo_log 表向 TC 注册分支事务在事务提交/回滚阶段根据 TC 指令清除 undo_log 或者反向补偿。RM 的注册时机不是事务一开始而是第一条 SQL 执行的时候。所以如果全局事务里只调了一个服务而这个服务什么 SQL 都没执行你可能根本看不到分支注册记录。这是排查“为什么全局事务没有生成分支”时要想到的一点。RM 的状态上报也是异步的。分支事务执行完成并不马上告诉 TC “我成功了”而是在 RM 收到 TC 的提交/回滚请求后再确认自己这条分支是否真的可以完成。这么设计是为了减少网络交互提高吞吐量。但在排障的时候你去看 TC 的日志或者 DB 里分支表的状态会发现分支状态有时候不是实时的所以别急着下结论。补充说一句RM 不是只有操作数据库才算。在 TCC 模式里每个服务自己实现 try、confirm、cancel 三个方法Seata 里也把这套逻辑封装在 RM 上。在 Saga 模式里RM 概念会弱化很多因为状态机编排的粒度是服务而非数据资源。但不管哪种模式TC、TM、RM 的黄金三角分工始终成立变化的是 RM 对“资源”的解释。3. 实操过程与核心环节实现3.1 AT 模式到底做了什么AT 模式是 Seata 最常用的模式也是理解“黄金三角”工作流程最好的入口。它的设计目标很明确让业务代码基本上不改就能获得分布式事务能力。AT 模式的核心是“两阶段提交的两阶段换了个实现方式”。传统 XA 协议要求数据库本身支持全局事务和 XA 接口AT 模式没有这层要求它用镜像数据和 undo_log 表模拟出一个可以回滚的环境。阶段一RM 执行业务 SQL比如update stock set count count - 1 where product_id 100。执行前它查一次当前数据作为 before image执行后再查一次作为 after image。这两个镜像数据只包含这条 SQL 影响到的列和行。然后 RM 把 before image、after image、分支事务相关信息以及业务 SQL 的类型写进 undo_log 表。同一本地事务里业务 SQL 和 undo_log 的写入必须一起提交。这一阶段 RM 也会注册分支事务给 TC。阶段二如果全局事务要提交RM 收到 TC 的提交请求做的事情只有一件删除对应的 undo_log 记录。注意它不会再去把 after image 的数据改成 before image因为业务更新已经生效了。如果全局事务要回滚RM 就拿到 before image生成反向 SQL把当前数据改回去。回滚前还要校验 after image 和当前数据是否一致如果不一致说明有别的事务改过这条数据RM 会报出脏数据冲突触发人工介入。很多资料讲 AT 模式会用“补偿”这个词严格说它和 TCC 的“补偿”并不一样。AT 是基于 undo_log 的“反向补偿”SQL 是自动生成的TCC 的 cancel 方法是业务人员手写的。理解这一点你才能明白为什么 AT 模式对 SQL 有要求为什么多表操作、全文索引、复杂 join 会踩坑。3.2 订单与库存场景的完整流程拆解用订单服务和库存服务跑一遍全局事务你就能把 TC、TM、RM 串起来了。假设你有两个微服务order-service 和 stock-service。订单创建方法加上了GlobalTransactional(rollbackFor Exception.class)。第一步TM 发起全局事务。order-service 里的方法被拦截到TM 构造一个全局事务请求发给 TCTC 生成 XID 并持久化 global_table然后返回 XID 给 order-service。这个时候全局事务处于 begin 状态。第二步订单服务在自己的本地事务里插入订单记录。因为数据源被 DataSourceProxy 代理了RM 会在这个本地事务里先执行插入再生成 before/after image 写入 undo_log然后向 TC 注册分支事务。TC 在 branch_table 里记录一个分支。最后本地事务提交订单插入生效undo_log 保留。第三步订单服务调用 stock-service。这个调用会带上 XID不管你是用 Feign 还是 DubboSeata 都有对应的过滤器或者拦截器把 XID 放到调用上下文。stock-service 收到请求后从上下文还原 XID让当前线程加入到这个全局事务里。第四步库存服务扣减库存。同样的流程SQL 执行、镜像生成、undo_log 写入、分支注册。本地事务提交。库存扣减成功。第五步订单服务的方法正常返回。TM 向 TC 发起全局提交。TC 遍历分支列表向 order-service 和 stock-service 的 RM 分别发送提交请求。RM 删除各自的 undo_log返回成功。TC 把 global_table 里的状态更新为 committed。整个过程里出现任何异常比如库存扣减抛了 RuntimeException业务方法中断TM 就会向 TC 发起全局回滚。TC 通知所有 RM 回滚分支RM 根据 undo_log 把数据恢复原状。最终订单记录被删掉库存恢复。上面这个流程是“理想化”的真实环境里有一个细节特别值得注意库存服务本地事务提交后数据其实已经扣了。全局事务还挂着。如果这个时候有一个新的请求直接去查库存它看到的是扣减后的值。也就是说从分支事务本地提交到全局事务最终提交/回滚之间存在一个“中间可见”状态。AT 模式的默认隔离级别是读已提交级别解决不了这个中间可见问题。如果你们的业务要求库存不能被超读就得使用SELECT FOR UPDATE的全局锁能力或者在应用层做额外的处理。3.3 代码与配置的最佳实践给出一个最小可用配置的关键点方便你照着搭。服务端 seata-server 启动时指定注册中心和配置中心。如果你只是本地跑可以直接用 file 模式sh seata-server.sh -p 8091 -m file应用端依赖于seata-spring-boot-starter。版本对应关系要特别注意Seata 1.5 之后的 starter 结构调整过配置前缀从spring.cloud.alibaba.seata变成了seata。以 1.6.1 为例seata: enabled: true application-id: order-service tx-service-group: my_test_tx_group service: vgroup-mapping: my_test_tx_group: default grouplist: default: 127.0.0.1:8091这里最容易配错的就是tx-service-group和vgroup-mapping。tx-service-group是逻辑上的事务分组名代码里GlobalTransactional不去指定 TC 地址而是通过这个分组名找到对应的 TC 集群。vgroup-mapping的作用是把逻辑分组映射到物理 TC 集群名。如果你后面增加了多套环境比如测试和预发隔离只要改映射关系就行代码不用动。数据源代理的配置同样要小心。Seata 的自动配置只有在某些版本里才真正帮你代理 DataSource。很多时候你需要手动定义一个代理数据源Bean public DataSource dataSource(DataSource dataSource) { return new DataSourceProxy(dataSource); }但如果你引入了seata-spring-boot-starter且版本较新它可能会自动代理。两种情况的差异会导致你本地测试通过、打包到别的环境出现“undo_log 不存在”或者“branch register 失败”。所以第一件事就是确认你现在用的版本是否默认自动代理了数据源。如果用的是 MyBatis还需要注意Transactional和GlobalTransactional的嵌套顺序。GlobalTransactional只是 TM 开启全局事务它不会帮你管理本地事务。所以你在业务方法上通常要同时标注Transactional(rollbackFor Exception.class)或者使用 Spring 的声明式事务。本地事务负责把业务 SQL 和 undo_log 绑定成原子操作全局事务负责跨服务协调。二者是配合关系不是替代关系。4. 常见问题与排查技巧实录4.1 全局事务回滚了但数据没恢复这是新手经常遇到的问题。现象是日志里能看到 TC 发起了回滚RM 执行了但数据库里的数据还是新的。优先看 undo_log 表是否存在是否真的写入了数据。AT 模式回滚依赖 undo_log如果这张表不存在或者表结构不对回滚就会静默失败。表结构里最关键的是rollback_info这个 longblob 字段里面存的是 before/after image 的 JSON 序列化结果。如果字段类型不对或者表名拼写有误都会出问题。另一个常见原因是回滚时发生了数据冲突。RM 会拿当前数据和 after image 做比对一旦发现有其他事务修改过同一行它不会盲目回滚而是抛出异常等待人工处理。日志里一般会出现Buffer limit或者dirty data这样的关键字。解决思路是排查是否真的存在并发修改如果确认是脏数据需要人工决定最终值。4.2 XID 丢失导致子服务不参与全局事务子服务没有注册分支全局事务回滚时根本管不到它。最常见的根因就是 XID 没传过去。排查时先看调用链路的线程上下文到底有没有 XID。可以临时在子服务接口里打印RootContext.getXID()。如果拿不到基本可以确定是 XID 透传失效。Feign 场景下要确认是否引入了seata-feign-all相关依赖并且feign.hystrix.enabled这类配置不会把请求调度到新线程导致上下文丢失。Dubbo 场景要检查过滤器是否被关闭。还有一类隐蔽情况异步线程。RootContext绑定在线程上如果你在业务代码里用了线程池子线程里不会自动继承 XID。你需要手动把 XID 传递过去或者使用 Seata 提供的TransactionalExecutor。这一点在“新线程中执行简单更新”的场景里特别容易踩几乎每次排查都会遇到。4.3 分支事务一直处于 init 状态在 TC 的视图里某个分支的状态长期是 init没有变成 finished。这通常不是 RM 没汇报而是 TC 还没有发提交/回滚指令因为全局事务还没走到结束阶段。可能的原因是 TM 没有发起结束请求。比如业务方法特别长或者调用的子服务有大量异步操作TM 全局事务迟迟不提交。还有可能是代码里捕获了所有异常导致 TM 认为方法正常返回了。另外检查一下 seata-server 与业务服务器的网络稳定性。RM 分支注册成功但 TM 的全局提交请求在网络层被丢弃TC 侧会等待超时。我用过很多次timeout参数的默认值 60000ms一旦超过 60 秒全局事务还没有结束TC 会主动标记超时。如果你的接口本身就要跑几分钟记得在GlobalTransactional(timeoutMills 180000)里调大超时时间。4.4 常见问题速查表现象可能原因处理建议子服务不参与事务XID 未透传RootContext 为空检查 Filter/拦截器确认异步线程是否复制了 XID回滚后数据没还原undo_log 缺失或表结构错误确认表存在字段类型为 longblob回滚报脏数据冲突其他事务并发修改同一行检查业务并发场景必要时引入分布式锁undo_log 不停增加全局事务提交后清理失败检查 RM 是否收到 TC 的提交请求确认版本对应关系TC 日志出现 TimeoutException全局事务超过 timeoutMills调整超时参数或者精简链路耗时重复部署后旧事务恢复不了TC 存储模式是 file重启状态丢失生产环境切换 DB 模式配合集群部署5. 实操心得与扩展思考5.1 不要把 Seata 当万金油我见过很多团队一上来就把 Seata 用在所有调用链路上结果事务覆盖范围太大回滚影响面广经常因为一个非核心服务异常导致整个链路频繁回滚。设计上应该先把业务按一致性要求分级。真正需要强一致性的通常就是订单、库存、资金这几条核心链路。像发短信、发站内信、更新推荐位用消息队列做最终一致性就够了。分布式事务的代价不低它引入了额外的网络交互、undo_log 存储、TC 资源、链路耗时。非必要不入局。另外不要忽略一个现实AT 模式靠 undo_log 回滚的前提是数据没有被本地事务之外的代码修改。如果你直接绕过 Seata 的数据源代理去操作数据库或者用了存储过程、触发器回滚是无法保证的。这种“绕过代理”的操作在生产事故中占比很高排查时一定要先看是否有原生 JDBC 连接被放出来。5.2 分支事务的粒度控制经验理论上一个 RM 可以注册多个分支但分支越多全局事务协调的成本越高回滚的复杂度也越大。我在真实项目中倾向于控制一个服务在一个全局事务里只进行一次分支注册也就是让 RM 的粒度粗一点。比如订单服务里既有插入订单表又有更新订单状态表我会尽量把它封装进一个本地事务方法里。这样 RM 在本地事务提交后只向 TC 注册一个分支。如果系统有“一个服务需要多资源”的情况比如一个应用同时访问两个数据库那就需要拆成两个 RM并且要注意两个 RM 的分支在回滚时按什么顺序补偿。Seata 的默认回滚顺序是分支注册的反向顺序也就是后注册的先回滚所以设计时需要把“初始数据依赖”放在先注册的那一侧。5.3 和 TCC、Saga、XA 怎么选最后多说几句模式选型虽然这篇文章聚焦的是三大角色但模式选择直接影响到你给 RM 定义“资源”的方式。AT 模式无侵入适合快速落地、业务数据以单库操作为主、并发冲突不严重的场景。它的缺点是回滚依赖 undo_log性能和管理成本都不低遇到复杂的 SQL 容易出兼容性问题。TCC 模式适合对性能要求高、需要细粒度控制每个阶段业务的场景但要求业务方自己实现 try、confirm、cancel编码量明显上升。Saga 模式适合长流程、多服务编排的场景比如旅行预订但 Saga 只有正向补偿和逆向补偿没有“全局锁”隔离性更弱。XA 模式是数据库原生协议隔离性最强但对数据库版本和驱动有要求存在锁资源持有时间长的问题并发受罪。我的个人倾向是绝大部分核心交易链路用 AT把代码侵入降到最低一旦性能压测发现 AT 的锁冲突成为瓶颈再针对特定链路改成 TCC。长流程异步编排用 Saga不轻易让 Saga 承担高并发强一致的业务。XA 除非你们有专门的 DBA 团队能把数据库参数调好否则不要轻易在生产用。三大角色这套设计本质上是用一个独立的 TC 把“全局事务状态机”从业务服务里抽离出来TM 只负责边界RM 只负责资源这样才能让一个有几十个微服务的系统依然能像一个数据库那样做全局提交和回滚。每次排查问题时按着“这个操作发生在哪个角色身上”去思考会比一头扎进日志快得多。