资讯详情

Seata AT模式内核源码拆解:全局锁、undo_log与二阶段回滚

📅 2026/10/23 2:06:24 | 华诺云谱 👁 阅读
Seata AT模式内核源码拆解:全局锁、undo_log与二阶段回滚
从一次真实的数据不一致事故说起。当时我们项目组做的是一个典型的微服务下单链路订单服务、库存服务、账户服务各自独立数据库。某个下午压测时发现订单创建成功了库存也扣了但账户扣款接口超时回滚了于是数据就永远差了这笔钱。排查到最后结论很尴尬——每个服务自己的本地事务都正常工作但跨服务的数据一致性没有一个人能保证。后来我们引入了Seata这款分布式事务中间件用AT模式解决了这类问题。但如果只是把它当黑盒用心里始终不踏实全局锁到底锁了什么undo_log是什么时候写的二阶段回滚为什么能精准补偿而不是把整行覆盖掉全局事务提交时TCTransaction Coordinator到底在做什么这些问题不搞清楚出问题就只能靠猜。所以这篇文章我打算从内核源码的角度把Seata的分布式事务机制完整拆一遍。适合已经用过Seata、想在简历和项目里讲清楚原理的读者也适合正在做分布式事务选型、想评估AT模式落地风险的技术负责人。文章会把核心类、关键调用链、时序逻辑、状态机都过一遍最后再分享我在参数调优和源码调试上踩过的坑。1. 先对齐认知Seata的架构角色与AT模式的底层信任链1.1 三个角色各管哪一段Seata的概念看似简单TM、TC、RM。但源码里这三个角色的职责边界非常清晰我建议先记住它们的实现入口后面看代码不迷路。TMTransaction Manager发起全局事务的角色。它定义事务边界负责向TC请求开启、提交、回滚全局事务。源码入口主要在io.seata.tm.api这个包里核心类有GlobalTransaction、GlobalTransactionContext最终通过DefaultCore和TC的Netty通信。TCTransaction Coordinator全局事务的协调者维护全局事务和所有分支事务的状态。在Server端核心类是DefaultCoordinator它接收TM和RM的RPC请求管理GlobalSession和BranchSession。RMResource Manager管理分支事务的资源比如一个数据库连接。在AT模式下RM核心类是DataSourceManager以及被代理的ConnectionProxy负责注册分支、生成undo_log、上报分支状态。一句话概括TM定边界TC定状态RM管分支。1.2 XID贯穿全链路的那根线Seata的全局事务上下文靠一个字符串传递叫XID。它长什么样形如192.168.1.100:8091:1234567由TC在收到TM的begin请求后生成包含TC地址和全局事务ID。XID的传递是理解整个框架的关键。在微服务调用链里上游服务在发起远程调用前会把当前XID塞进调用协议的透传字段下游服务在收到请求后从透传字段拿出XID并绑定到当前线程的RootContext里。这样同一个全局事务内所有服务对数据库的操作都会被代理数据源识别为“需要参与全局事务”。我见过不少人在看源码时忽略RootContext后面看ConnectionProxy就会很懵——为什么同样的代码有了XID就走了一堆逻辑没有就直接透传记住RootContext.bind(xid)和RootContext.unbind()是RM识别全局事务的开关。1.3 AT模式为什么敢在一阶段就提交本地事务这是Seata和XA最大的区别也是面试里最常问的点。XA两阶段协议要求一阶段只准备不提交数据库行锁要一直持有到二阶段结束高并发下基本不可用。而AT模式的胆大设计是一阶段直接执行业务SQL并提交本地事务把回滚需要的“后悔药”提前写入undo_log表。这个设计能成立靠的不是数据库层面而是两把锁第一全局锁。在执行业务SQL之前RM会向TC申请全局锁锁的粒度是“资源ID 表名 主键值”。它的作用是防止两个不同的全局事务并发修改同一行数据从而避免脏写。第二本地数据库锁。业务SQL照常走数据库行锁机制。关键在于顺序先拿全局锁再执行SQL并持有数据库行锁然后写undo_log最后提交本地事务释放数据库行锁。这样在本地事务提交前TC已经知道这个分支会对哪些行做什么操作后续的补偿才有的放矢。AT模式名字的含义就是“Automatic Transaction”它把分布式事务中对业务代码的侵入降到最低代价是要求SQL可以被反向补偿并且接受全局锁带来的性能损耗。2. 发起一次全局事务从GlobalTransaction到GlobalSession2.1 业务代码里那行begin到底做了什么我们平时写的代码大概是这样的GlobalTransaction tx GlobalTransactionContext.getCurrentOrCreate(); tx.begin(60000, order-service);getCurrentOrCreate()的逻辑是先检查当前线程是否已经绑定了XID如果绑定了就直接复用否则新建一个GlobalTransaction对象。这里有个容易被忽略的点——这个对象只是本地的一个“代理”真正服务端的状态还没创建。进入tx.begin()之后内部通过DefaultCore.begin()发了一个Netty RPC给TC端// 简化后的逻辑 public String begin(String applicationId, String transactionServiceGroup, String name, int timeout) { GlobalBeginRequest request new GlobalBeginRequest(); request.setTransactionName(name); request.setTimeout(timeout); GlobalBeginResponse response (GlobalBeginResponse) syncCall(request); // 拿到TC返回的xid return response.getXid(); }2.2 TC端GlobalSession的创建与状态机TC端收到GlobalBeginRequest后对应处理器在DefaultCoordinator的onBegin方法里。核心动作是GlobalSession globalSession GlobalSession.createGlobalSession( applicationId, transactionServiceGroup, transactionName, timeout); sessionManager.add(globalSession); String xid globalSession.generateXid();GlobalSession是TC端全局事务的会话实体内部维护了XID事务超时时间事务状态GlobalStatus一个BranchSession列表这里重点看状态机。全局事务的状态定义在GlobalStatus枚举里流转像这样Begin是初始态后续根据业务走向进入Committing/AsyncCommitting或Rollbacking最终到达终态Committed/Rollbacked/Finished。超时场景还会进入TimeoutRollbacking。为什么这个状态机重要因为你排查问题时看TC端日志里GlobalSession的状态就能判断是卡在提交路径还是回滚路径上。比如一直停在Committing说明有分支的commit没返回要考虑TC是否在做重试。2.3 分支事务如何挂载到全局会话当RM端某个数据源连接参与全局事务时RM会向TC发BranchRegisterRequestTC端onBranchRegister会做两件事BranchSession branchSession new BranchSession(); branchSession.setXid(xid); branchSession.setBranchId(branchId); branchSession.setResourceId(resourceId); branchSession.setLockKey(lockKey); // 1. 申请全局锁 boolean lockResult lockManager.acquireLock(branchSession); // 2. 将分支会话加入全局会话 globalSession.addBranchSession(branchSession);这里有一个非常关键的设计分支会话挂载之后TC端就能看到这个全局事务涉及哪些资源、哪些锁后续的全局提交或回滚就是遍历这个分支列表逐个通知。3. ConnectionProxyRM数据源代理的改造内幕3.1 为什么业务代码的侵入几乎为零Seata对数据源的改造思路是用DataSourceProxy包一层你的DataSource业务里拿到的连接实际上是ConnectionProxy。// 通常的接入方式 Bean public DataSource dataSource(DataSource dataSource) { return new DataSourceProxy(dataSource); }业务代码写的依然是connection.prepareStatement(sql)和connection.commit()但ConnectionProxy在背后偷偷做了很多事情。这也是AT模式最让我佩服的地方改造全都在代理层完成业务对事务的感知仅限于XID的透传。3.2 SQL执行前的全局锁申请分支事务执行SQL时入口不是普通的Statement.execute()而是走到了AbstractDMLBaseExecutor。它针对INSERT/UPDATE/DELETE做了统一的处理执行前获取全局锁执行后再采集前后镜像。全局锁的获取逻辑大致在processBeforeExecute中核心是调用LockManager向TC申请锁。注意这里申请锁的key是整个分支事务涉及的所有主键的集合不是单条。也就是说一条UPDATE如果扫过10条记录这10个主键都会被纳入同一个branchSession.getLockKey()里。这一步如果锁等待超时会抛异常“get global lock fail”。高并发热点行场景下这个异常是最常见的报错之一。3.3 本地提交与分支上报的顺序ConnectionProxy.commit()的执行顺序非常有讲究public void commit() throws SQLException { // 如果参与全局事务并且生成了undo_log if (context.hasUndoLog()) { // 1. 在本地事务提交之前把undo_log写入数据库 undoLogManager.flushUndoLogs(targetConnection); // 2. 提交本地事务 targetConnection.commit(); // 3. 最后向TC上报分支提交 DefaultRMHandler.get().branchReport( BranchType.AT, xid, branchId, BranchStatus.PhaseTwo_Committed, null); } else { // 非全局事务直接提交 targetConnection.commit(); } }注意顺序不能乱。undo_log必须在本地commit之前落库否则一旦本地提交了再写日志失败就没有回滚依据了。而分支上报必须在本地commit之后因为TC收到“你提交成功”之后可能立刻就对其他分支发起回滚如果本地还没提交就上报回滚就会操作一个未提交的事务的数据。我之前读源码时一直困惑一个问题为什么回滚阶段还要再查一次数据库的当前值后来才想明白这是为了做“脏写检测”。在二阶段回滚之前如果另一个本地事务非Seata管理的SQL绕过代理直接改了同一行数据那么按主键回滚就会覆盖掉别人的操作。所以在生成回滚SQL时Seata会检查当前值是否等于afterImage不等就说明有问题分支回滚会失败需要人工介入。4. 二阶段回滚的底气undo_log机制源码级拆解4.1 beforeImage与afterImage的采集undo_log机制是AT模式的核心资产。每一次DML执行完成后AbstractDMLBaseExecutor会调用TableRecords相关工具采集两类镜像。beforeImageSQL执行前根据主键查到的那一行记录的原始值。afterImageSQL执行后再次按主键查到的这行记录的新值。具体实现是通过TableMetaCache拿到表结构字段名、主键列然后拼出SELECT语句。拿到表结构之后Seata会缓存在本地JVM里避免每次DML都查一次数据库的information_schema。下面这张表是镜像采集的关键点总结环节采集时机数据内容用途beforeImageSQL执行前该行所有业务字段原始值回滚时恢复的数据来源afterImageSQL执行后该行所有业务字段新值回滚时校验脏写undo_log日志本地提交前序列化后的两个Image二阶段回滚的反向SQL依据这里有一个性能上的坑如果一条UPDATE影响了几万行这两个SELECT会把所有行的所有列都捞回来undo_log体积会非常大。所以批量大事务在Seata下要格外谨慎。4.2 回滚SQL如何精准补偿回滚阶段做的事情本质上就是把afterImage中的数据反向UPDATE回beforeImage。但千万不能直接“用主键定位然后整行覆盖”那会把别的并发事务的提交也覆盖掉。真正的回滚逻辑在UpdateUndoLogExecutor里核心是这样一个条件// 简化后的回滚SQL语义 UPDATE table SET field1 beforeImage.field1, field2 beforeImage.field2, ... WHERE id afterImage.id AND field1 afterImage.field1 AND field2 afterImage.field2 ...也就是说回滚只针对“当前值仍然等于afterImage”的行执行恢复。如果WHERE条件匹配不到任何行说明这行数据在全局事务执行完之后被其他事务改过了属于脏写这个分支的回滚会被标记为失败。4.3 脏写检测为什么回滚时会失败脏写检测失败是分布式事务在复杂环境下最头疼的问题。最常见的触发场景业务代码里有人绕过了数据源代理直接用原始DataSource或JdbcTemplate操作数据Seata的锁和镜像都管不到它。Redis缓存和数据库不一致定时任务用普通事务补偿数据恰好和Seata回滚撞车。使用了不支持全局锁的存储比如某些情况下Redis分支与数据库分支混用回滚顺序不当。遇到分支回滚失败TC不会放弃它会一直把全局事务标记为RollbackRetrying定时重试。真正的解法不是调大重试次数而是先查undo_log如果rollback_info里的afterImage和当前数据库值不一致就要人工评估用哪个值才是最终事实然后手动恢复数据并清理日志。5. 二阶段提交与回滚TC端的调度和状态推进5.1 全局提交到底做了什么TM发起tx.commit()后TC收到GlobalCommitRequest进入DefaultCore.commit()。逻辑上分两步第一步锁定全局会话判断状态。如果全局会话已经被标记为超时或者处在回滚中这个commit请求会被拒绝或转成回滚处理。第二步对全局会话里所有的BranchSession逐个下发commit请求。这里有个优化AT模式的分支commit实际动作只是异步删除该分支对应的undo_log因为一阶段RM已经完成了本地提交。删除undo_log的语义非常轻所以Seata允许全局提交采用异步方式。状态推进流程是GlobalSession先设为AsyncCommittingTC的后台任务再真正执行分支清理全部完成后推进到Committed。5.2 超时会话与重试机制我在生产环境遇到最多的问题不是回滚失败而是“该回滚的一直没回滚”。这背后是TC的定时任务在起作用。Seata Server端启动后会起几个定时线程专门扫描没有终态的GlobalSession。销毁逻辑是这样的如果一个GlobalSession停留在Begin状态超过配置的timeout时间TC就会主动把它标记为TimeoutRollbacking然后走回滚流程。分支回滚失败后会进入RollbackRetrying后台线程会按照退避策略不断重试。这也是为什么TC端一般不建议重启太频繁——大量处于中间态的全局会话依赖定时任务恢复。如果TC宕机时间过长这些会话可能被判定超时产生大量不必要的回滚。5.3 一张状态流转表快速定位日志位置GlobalStatus含义日志常见关键词Begin已开启等待事务提交/回滚“GlobalSession begin”AsyncCommitting分支已提交等待异步清undo_log“Async committing”Committed全局提交成功“GlobalSession committed”Rollbacking正在回滚分支“Rollbacking global session”Rollbacked全局回滚成功“GlobalSession rollbacked”TimeoutRollbacking超时触发回滚“timeout check”Finished终态可从存储删除“Remove global session”6. 内核之外调优经验与调试内核的实战建议6.1 全局锁与超时参数的平衡Seata有几个参数直接影响全局锁的获取行为# 获取全局锁的重试间隔毫秒 client.rm.lock.retryInterval10 # 获取全局锁的重试次数 client.rm.lock.retryTimes10 # 全局事务超时时间毫秒 client.tm.degradeCheckPeriod2000默认的锁重试策略是等10毫秒重试10次总共约100毫秒。这个配置对低峰期还行一旦遇到热点行竞争很快就抛“get global lock fail”。坦白说这个默认值并不适合生产我习惯把retryInterval调到50、retryTimes调到30但也要警惕重试时间太长会拖慢业务响应且全局锁在TC端是占用资源的。更健康的做法是减少热点冲突本身。比如把库存拆成多个子账户、在业务上按用户维度散列而不是一味调参数。6.2 不支持的SQL与常见误用Seata AT模式对SQL的约束官方文档里有但我实际测试下来的体会是以下几点第一每张参与事务的表必须有主键,否则无法生成锁key和回滚条件。第二UPDATE/DELETE不支持多表JOIN,因为镜像采集机制是按单表主键做的。第三不支持没有WHERE条件的全表UPDATE——不是不能执行而是镜像会拉取全表undo_log膨胀到没法看。第四DDL、TRUNCATE这类直接改表结构的操作Seata管不了。另外一个容易踩的坑undo_log表和业务表必须在同一个库。因为一阶段写undo_log是走本地事务的如果两个库分开本地提交时无法保证原子性。6.3 一次压测事故undo_log膨胀与归档清理有次我们做全链路压测模拟了1000个并发用户同时下单。压测结束后发现数据库里undo_log表占了将近2GB。分析日志发现大部分是全局提交成功后异步删除没跟上加上有些事务在重试回滚阶段反复读取rollback_info。这个问题的根源不是Seata本身而是我们的业务事务过大一个下单事务里塞了十几个分支。后来做了两件事一是业务流程尽量瘦身长事务拆短二是写了一个定时任务清理log_status1已提交且创建时间超过一小时的undo_log记录。其实Seata自己做异步清理已经很及时但生产环境总会有各种意外归档机制是必要的兜底。6.4 想精读内核从哪几个断点开始最后给想自己动手看源码的读者一条最快的路线不要试图从TCP握手开始看那样效率太低。按这个顺序断点DefaultCoordinator.onBegin看全局会话如何创建。AbstractDMLBaseExecutor.executeAutoCommitFalse看DML如何采集镜像。ConnectionProxy.commit看undo_log写入、本地提交、分支上报的顺序。DefaultCore.rollback看二阶段回滚如何遍历分支。UndoLogManager.flushUndoLogs看undo_log的序列化结构。配合一个跑通的示例工程在本地启动TC然后用两个服务模拟一次跨库事务全程单步调试一遍你会对整条信任链有完全不同的理解。我在实际调试时最有收获的其实是最后一步手动修改数据库里的一行数据后触发回滚亲眼看到脏写检测如何导致回滚失败——那种体验比读十篇源码解析都深刻。Seata的内核设计本质上是一套“先尝后补、以锁为纲、以日志为凭”的信任链工程。全局锁保证并发安全undo_log保证可补偿状态机保证流程可追踪。把这套机制吃透之后再遇到线上数据不一致问题你至少会有一个明确的分析起点先查XID、再查GlobalSession状态、最后查undo_log而不是面对一堆日志靠经验猜。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑