
做后端这些年数据库事务没少打交道但真正让我下定决心去啃 Seata 源码的是一次线上回滚失败。库存服务扣减成功了订单服务却回滚了两个服务数据对不上后台日志里只有一句 rollback failed。当时我连 undo log 长什么样都没看过只能一边查文档一边猜。后来花了大概两周时间把 Seata 的 AT 模式客户端和 TC 服务端主链路捋了一遍之后再看这类报错基本能直接定位到是全局锁冲突还是脏写校验不过。这篇文章不是源码逐行讲解而是我学习过程中的完整思路从模块地图、客户端代理链路、分支事务一阶段到 TC 协调器和二阶段回滚最后附上阅读路线和调试建议。适用对象是已经在用 Seata、或者正准备深入分布式事务源码的 Java 后端开发者。1. 先在地图上找到自己Seata源码模块与AT模式的核心链路1.1 三组件分工TC、TM、RMSeata 整个代码库可以从角色上切成三块理解这三块是谁、干什么、在哪台机器上跑比记住任何类名都重要。TCTransaction Coordinator事务协调器独立部署的服务端负责全局事务的开始、提交、回滚决策以及全局锁管理。TMTransaction Manager事务管理器嵌入在发起全局事务的客户端应用里负责通过 RPC 通知 TC 开启、提交、回滚全局事务。RMResource Manager资源管理器嵌入在参与分支事务的客户端应用里负责本地事务的执行、分支注册、前后镜像采集、二阶段的本地回滚和提交清理。对应到源码模块也基本是角色即模块seata-server是 TC 实现seata-tm是 TM 实现seata-rm是 RM 实现其中seata-rm-datasource是 AT 模式的核心seata-core放公共模型、RPC 协议、上下文、配置seata-spring则是 Spring 集成包含我们后面要讲的扫描器和拦截器。我第一次读这份源码时犯的错就是直接打开seata-server去读结果被 Netty 的 RPC 处理链路绕得晕头转向。后来退回到客户端链路重新走才慢慢建立起全局观。建议你按客户端 TM - 客户端 RM - 服务端 TC的顺序来而不是从服务端开始。1.2 AT模式二阶段的本质AT 模式是 Seata 最常用的模式也是源码组织最复杂的地方。它为什么侵入性小因为它把分布式事务拆成了两个阶段但本质上只做了一件事把一阶段本地事务的副作用记录下来留到二阶段补偿。一阶段业务 SQL 照常执行但执行前后分别生成前镜像和后镜像再把镜像序列化写入一张 undo_log 表。业务 SQL 和 undo log 的插入在同一个本地事务里提交所以这一步只是把原先的单库本地事务加了料没有额外的分布式协调开销。二阶段TC 汇总所有分支结果。如果全部成功全局提交——各分支只需异步删除自己的 undo log如果任一分支失败全局回滚——各分支用 undo log 里的前镜像还原数据。这句话看起来简单但背后牵涉的全局锁、脏写校验、异步提交恰恰是源码里最值得读的部分。我后面会逐个拆开。1.3 源码版本选择与学习环境准备我读的是 1.6.x 版本。2.x 重新调整了包结构很多类挪到了新的子包但核心思路没变。如果你要对照线上部署的版本学习建议直接 checkout 对应 tag否则类路径对不上光找类就能劝退你。我当时准备了一套最小的实验环境一个带 Spring Boot MyBatis 的工程拆两个服务模拟订单和库存单机启动一个 seata-server打开 SQL 日志方便观察 Seata 自动生成的镜像查询和补偿 SQL准备一个能看 BLOB 的数据库客户端DBeaver 或 TablePlus 都行因为 undo_log 的 rollback_info 字段是二进制 JSON这套环境跑通后后面所有源码阅读都是在这个实验台上边跑边断点验证的。2. 一切从代理开始GlobalTransactional 的生效链路2.1 GlobalTransactionScanner 如何把目标Bean变成代理Seata 的 Spring 集成入口是GlobalTransactionScanner它继承了 Spring 的AbstractAutoProxyCreator这是它能在 Bean 初始化阶段动手脚的关键。在这个类的初始化逻辑里它会扫描容器中所有 Bean判断类或方法上是否有GlobalTransactional注解如果命中就创建代理并把GlobalTransactionalInterceptor挂到代理链上。这里有两个细节需要留意。第一个是它还会扫描实现了GlobalTransactional接口的 Bean。很多人不知道这一点如果自定义注解或接口实现方式不对会遇到注解明明加了却不生效的问题。第二个是 Bean 的后置处理时机。AbstractAutoProxyCreator的postProcessAfterInitialization会在每个 Bean 初始化之后执行如果你的业务 Bean 依赖了别的组件从而提前初始化有可能绕过代理。我实际踩过一个坑一个定时任务 Bean 在扫描器初始化之前就被EventListener触发导致GlobalTransactional完全没生效。排查方法很简单看代理对象是否真的被替换了或者把事务边界放到调用链里更靠后的位置。2.2 TransactionalTemplate模板方法在这里是真代码GlobalTransactionalInterceptor本质是一个MethodInterceptor核心逻辑在TransactionalTemplate.execute(...)里。这个类的结构和 Spring 的TransactionTemplate很像核心伪代码如下try { if (RootContext 里已经存在 XID) { 直接执行业务方法加入已有全局事务 } else { tx.begin(...); // 通过 TM 向 TC 发 GlobalBeginRequest 执行业务方法; tx.commit(...); // 通过 TM 向 TC 发 GlobalCommitRequest } } catch (Throwable ex) { tx.rollback(...); // 业务异常触发全局回滚 throw ex; }很多人以为GlobalTransactional是靠 AOP 自动提交回滚的实际上它内部就是一个朴素的事务模板只是把 Spring 的PlatformTransactionManager换成了 Seata 自己的GlobalTransaction对象。这也是为什么你在读源码时会发现TransactionalTemplate的代码异常短——它只负责编排真正的活都在DefaultGlobalTransaction和网络通信里。2.3 XID的诞生与传递RootContext与RPC透传关键对象是RootContext它内部是一个ThreadLocal保存当前线程的 XID。全局事务开启时DefaultGlobalTransaction.begin()调用DefaultTransactionManager.begin()后者通过 TM 的 RPC 客户端向 TC 发送GlobalBeginRequest。TC 创建GlobalSession并返回 XID。XID 拿到后RootContext.bind(xid)把它绑定到当前线程。接下来是 XID 的传播。当前服务通过 Dubbo 或 Spring Cloud 调用下游时Seata 提供的 RPC 拦截器会把 XID 塞进 RPC 上下文下游服务收到请求时再把 XID 绑定到自己的RootContext。如果这套拦截器没有正确配置XID 就断了。这里要强调一个容易混淆的点全局事务不是通过网络请求传播的真正传播的是 XID 这个字符串。只要下游线程的RootContext里有 XID它参与的本地事务就会自动被视为当前全局事务的一个分支。反过来如果拦截器配置漏了下游拿不到 XID分支就不会注册数据一致性就无从谈起。线上排查明明加了注解但没效果的问题第一步就是看 TC 日志里有没有下游服务的分支注册记录。2.4 调试窍门从RootContext断点观察事务边界我会在RootContext.bind/unbind打上断点看 XID 的生命周期什么时候绑定、什么时候解绑、调下游服务时 RPC 上下文里有没有写进去。这个调试方式比对着文档猜快得多。你只需要在两个服务间调用一次断点会依次命中发起方的 bind - RPC 拦截器读写 - 接收方的 bind - 接收方业务执行 - 接收方 unbind - 发起方 commit/rollback - 发起方 unbind。整个过程跑一遍事务边界就清楚了。3. 一阶段分支事务代理数据源、SQL解析与undo log3.1 DataSourceProxy和ConnectionProxy做了什么AT 模式的无侵入是有代价的——它必须拦截 JDBC 层面的 SQL 执行。具体做法是把业务配置的数据源换成DataSourceProxy它包了一层真实数据源。DataSourceProxy.getConnection()返回的不是原生Connection而是ConnectionProxy。ConnectionProxy又包了一层真实连接。你在这个类上看不到什么高深算法但它是整条分支链路的总装车间它知道当前线程有没有 XID通过RootContext它维护一个ConnectionContext保存当前事务涉及的表名、主键值、SQL 类型等关键信息在commit()触发时它负责完成分支注册、全局锁获取、本地提交这一连串动作3.2 ExecuteTemplate按SQL类型选择执行器StatementProxy执行 SQL 时所有逻辑最终都会进入ExecuteTemplate.execute(...)。它会根据 SQL 类型选择一个执行器SQL类型执行器职责UPDATEUpdateExecutor生成前后镜像 undo logINSERTInsertExecutor处理主键生成、生成后镜像DELETEDeleteExecutor生成前后镜像 undo logSELECT ... FOR UPDATESelectForUpdateExecutor代理本地锁防止交叉事务脏读用生活化的类比ExecuteTemplate是餐厅的前台根据客人点的菜SQL 类型把订单分发给不同的厨师执行器。每个厨师都有一套固定的做菜流程只是这道菜如果是 UPDATE流程里会额外加两步查询镜像。3.3 前后镜像SELECT先于UPDATE的秘密以前我总觉得先查再改是低性能的做法但 Seata 恰恰是这么干的。以 UPDATE 为例一次执行链路是用 Druid 的 SQL 解析器解析出表名、主键、查询条件拼出一条用于查询镜像的SELECT * FROM table WHERE ...但此时还没执行业务 SQL执行这条查询把结果作为前镜像before image执行真正的UPDATE语句再根据主键查询一次受影响的行作为后镜像after image之后把 before image、after image 连同 SQL 类型、表名、主键信息一起序列化写入 undo_log 表。注意这个写入发生在本地事务里也就是说业务 SQL、镜像查询、undo log 插入是同一个数据库事务要么全成功要么全失败。undo_log 表结构不复杂核心字段是xid、branch_id、rollback_info、log_status。rollback_info是 BLOB 类型的 JSON 序列化数据我第一次在 DBeaver 里打开它时才真正理解 Seata 是怎么记录数据的。这里有个性能细节镜像查询本质上是多了一次全字段 SELECT对宽表来说开销不小。如果是一张包含 BLOB/TEXT 大字段的表每次业务操作都会把整行数据复制进 undo_log日志增长会非常快。我实测过一个 200 行的批量 UPDATEundo_log 动辄几十 KB。业务上如果出现高频大字段更新优先评估能否拆小事务或者考虑用 TCC 模式替代 AT。3.4 提交前的最后几步分支注册与全局锁检查当业务走到sqlSession.commit()或 Spring 的事务 commit 时ConnectionProxy.commit()登场。这一步的顺序很重要检查当前连接是否有需要注册的分支上下文里有没有 undo log 记录如果有向 TC 发送BranchRegisterRequestTC 会在这个过程中完成全局锁的获取再把分支会话挂到全局会话下返回 branchId获取到 branchId 后真正提交本地数据库事务——这一步 undo log 才真正持久化提交成功后向 TC 汇报分支状态标识一阶段完成也就是说分支注册和全局锁检查发生在本地提交之前这样如果全局锁没拿到本地事务就不会提交还能回滚重试。关于全局锁有一个常见误解它不是一个数据库行锁而是 TC 端维护的一张逻辑锁表锁的粒度是资源ID 表名 主键值的组合。一阶段本地事务提交后数据库行锁已经释放但 TC 的全局锁仍然握着用来阻止其他全局事务修改同一行。这个设计在 TC 章节我会展开讲。4. TC服务端协调者到底在协调什么4.1 DefaultCore的入口方法TC 服务端代码量其实不大核心是一个DefaultCore不同版本类名可能不同2.x 拆到了 coordinator 子包下。它对外提供四个核心入口对应 TM/RM 发来的 RPC 请求begin()创建GlobalSession生成 XID状态置为 BeginbranchRegister()把BranchSession加入GlobalSession并尝试获取全局锁globalCommit()把所有分支带入二阶段提交globalRollback()把所有分支带入二阶段回滚在begin()里最值得看的是 XID 的组成规则通常是IP:端口:事务ID。拆开 XID 就能还原出 TC 的地址这也是为什么客户端能够通过网络找到处理该事务的 TC——当然实际使用时客户端还是要单独配置 TC 地址XID 只是把这个信息编进去了方便追踪和日志检索。4.2 GlobalSession与状态机GlobalSession是全局事务在服务端的档案袋记录了全局事务 ID、状态、所有分支会话列表。分支会话BranchSession记录的是每个分支的资源 ID、branchId、状态、锁定的主键集合。全局事务有独立的状态枚举GlobalStatus和分支状态枚举BranchStatus这里列几个高频状态全局状态含义常见触发场景Begin全局事务运行中TM 发 begin 后Committing正在全局提交TM 发 globalCommit 后CommitRetrying二阶段提交失败重试分支提交超时Rollbacking正在全局回滚某个分支失败触发RollbackRetrying二阶段回滚失败重试分支回滚异常TimeoutRollbacking超时回滚中事务超过超时时间Finished终态所有分支处理完成状态机这块很有意思。很多人以为事务只有 begin/commit/rollback 三种状态但 Seata 在 TC 侧把正在提交和提交失败重试拆成了不同状态就是为了处理分布式环境下的不确定性——你发出去的 RPC 可能丢了、可能超时了、可能执行了一半。TC 有专门的线程不停地扫描处于重试状态的分支直到它们进入终态。4.3 全局锁的存储与释放时机TC 端用LockManager管理全局锁底层是内存中的锁表也可以做持久化。锁的 key 由资源ID 表名 主键值拼接而成同一把锁只能被一个全局事务持有。我特意研究过锁的释放时机全局锁一直持有到整个全局事务真正结束。即使一阶段所有分支的本地事务都提交了只要全局事务还没走完二阶段TC 就不会释放锁。这样才能保证全局事务 A 修改了某行但还没最终提交全局事务 B 想来改同一行时会被挡住。等 A 的二阶段结束后锁才释放B 才能基于 A 的最终结果操作。这其实就是把隔离性从数据库行锁层面提升到了整个分布式事务层面。但这个设计也有代价长事务会把锁握得很久影响并发。我之前遇到过线上一条热点商品库存被频繁更新某个全局事务迟迟不结束其他事务全部卡在锁等待上。解决办法是拆小事务或者在上游接口层做幂等和状态约束从根上减少跨服务长事务。5. 二阶段源码提交删日志回滚还原数据5.1 异步提交为什么commit也要异步二阶段提交看起来简单——通知每个分支你可以删 undo log 了。但 Seata 没有在 TC 收到 globalCommit 请求后同步通知所有分支而是引入了异步提交机制。globalCommit()先把全局状态置为 Committing然后对每个分支发起提交请求。如果分支在一阶段已经汇报过一阶段完成TC 会把这个分支标记为可异步提交由AsyncCommitService这个后台线程池去慢慢发通知。为什么要异步因为全局提交不需要任何补偿删 undo log 这件事早做晚做都不影响数据一致性没必要阻塞在 TM 的提交响应上。把耗时操作放到后台能显著降低全局事务的响应时间。5.2 回滚调用链从BranchRollbackRequest到UndoLogManager回滚链路是我认为整份源码里最值得读的地方。全局回滚时TC 向每个分支发BranchRollbackRequest客户端的 RM 收到后再进入UndoLogManager。UndoLogManager从 undo_log 表里查出该分支对应的 rollback_info反序列化后根据 SQL 类型生成对应的撤销执行器UPDATE 语句走更新撤销INSERT 语句走插入撤销DELETE 语句走删除撤销然后执行补偿 SQL。补偿 SQL 的核心不是直接用 before image 覆盖而是把 before image 里的字段值作为 SET 子句和 WHERE 条件拼成一条反向 SQL。比如业务 SQL 是UPDATE product SET stock stock - 1 WHERE id 100补偿 SQL 基本等价于UPDATE product SET stock [before的值] WHERE id 100 AND [其他 before 字段条件]。WHERE 里带上前镜像条件是为了把回滚操作限制在数据没有被别人改过的前提下。5.3 脏写校验Seata如何决定不敢自动回滚在执行补偿 SQL 之前Seata 会做一次脏写校验。思路是把当前数据库里的数据和 before image 里的数据做一次全字段对比。如果当前数据与 before image 完全一致说明一阶段提交后这些行没被其他事务动过可以安全回滚如果当前数据与 before image 不一致或者应该存在的行已经不存在了说明发生了脏写——某个并发事务在你的一阶段提交之后、二阶段回滚之前改了这行数据遇到脏写Seata 不敢用 before image 覆盖回去因为那会覆盖掉别人的修改。它会记录错误并告警需要人工介入处理。这是 AT 模式最大的使用边界适合写冲突少的业务。如果在热点数据高并发写的场景下用 AT 模式脏写概率会明显上升——这不是 Seata 的 bug而是补偿式回滚天然的限制。后来我遇到那次线上回滚失败就是脏写校验没过。当时两个线程并发修改同一行库存一个全局事务回滚时发现当前库存已经不是它的 before image拒绝自动还原。理解了源码之后我做的第一件事不是调参数而是评估业务能否改成状态机 幂等避免同一条记录的并发更新。6. 阅读路线、调试方法与绕坑经验6.1 我推荐的源码阅读顺序按我踩过的坑推荐的顺序是这样先读RootContext、GlobalStatus、BranchStatus这些常量级类建立概念地图。这一步半天就够跟一条 TM 链路GlobalTransactional-GlobalTransactionScanner-GlobalTransactionalInterceptor-TransactionalTemplate-DefaultGlobalTransaction.begin/commit/rollback- RPC 请求模型跟 RM 链路DataSourceProxy-ConnectionProxy-StatementProxy-ExecuteTemplate-XxxExecutor-UndoLogManager再跳到 TC 侧DefaultCore的四个方法顺着GlobalSession、BranchSession、LockManager走一遍最后读二阶段重点放在UndoExecutor和脏写校验建议不要一上来就抠 Netty 的 RPC 层那部分知识密度大但对理解事务协调帮助不大先把它当黑盒。如果你已经读过 MyBatis 源码或 Spring AOP 源码会发现 Seata 的很多套路似曾相识——代理、模板方法、责任链都是 Java 后端框架的老三样。6.2 Debug利器与日志开关调试分布式事务源码最实用的是多服务本地跑 断点跟进本地起两个 Spring Boot 工程一个模拟订单服务一个模拟库存服务中间用 Feign 或 Dubbo 调用seata-server 也单机跑在RootContext.bind、ConnectionProxy.commit()、DefaultCore.globalCommit()这几个节点打断点分别代表 TM、RM、TC 三个视角基本可以覆盖整条链路打开 seata-server 的 INFO 日志能看到每个分支注册、锁等待、提交回滚的明细打开数据库的 SQL 日志观察 Seata 自动生成的镜像查询 SQL 和补偿 SQL这是理解 before/after image 最直观的方式我还会在测试环境故意制造异常——比如在第二个服务里抛异常然后看 TC 收到分支失败汇报后怎么把全局事务标记成 Rollbacking。跑通一次失败回滚的完整流程比读十遍代码都有用。源码学习最忌讳的就是只读不跑状态流转这种东西断点跟一遍胜过看十遍注释。6.3 三个容易被误解的点第一个AT 模式不等于数据库 XA 二阶段。XA 是数据库原生支持的二阶段协议锁和事务由数据库资源管理器管理Seata AT 是业务层的补偿式二阶段用镜像和 undo log 自己实现回滚。两者的性能特性和适用范围完全不同XA 侵入小但锁粒度粗、并发性能差AT 则把冲突检测上移到了全局锁层面。第二个全局锁不是数据库锁。它在 TC 侧用来协调跨服务间的行级写冲突。数据库本地锁在一阶段提交后就已经释放了全局锁一直握到全局事务结束。判断一个慢接口是不是被全局锁拖住看 TC 日志里的锁等待记录就行。第三个GlobalTransactional不是随便加在方法上就万事大吉。它要求方法内所有数据源操作都被 Seata 代理覆盖而且要保证 XID 能通过 RPC 透传到下游。我曾见过项目里某个服务没引入 Seata 的 RPC 拦截器下游方法根本不在同一个全局事务里数据和单机事务没区别。排查这种问题最快的方法是看 TC 日志里有没有对应分支注册记录。如果你也打算啃这份源码我的建议是从你线上遇到的那个问题入手而不是按目录从头读到尾。带着为什么回滚失败为什么锁冲突为什么 XID 没传过去这类具体问题去查代码效率会高得多。读完之后你会发现分布式事务的核心并不在于哪个框架多神奇而在于你对自己业务里事务边界、并发写入模式和一致性要求的理解到底有多深。Seata 源码只是一面镜子照出来的更多是我们自己对分布式系统的认知缺口。