拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Seata AT模式原理拆解:全局锁与UNDO_LOG如何支撑回滚

上个月我们订单服务的灰度环境半夜报警日志里刷满了GlobalLockWaitTimeoutException点开 Seata 控制台一看一个全局事务卡在 Rollbacking 状态大半天也没动。我当时第一反应是网络抖动导致 TC 和 RM 失联后来把 SQL 一条条拉出来对才发现运营后台有个批量改价脚本直接连数据库改了同一行库存绕过了 Seata 的数据源代理导致二阶段回滚时镜像校验失败。那晚上我坐在工位把 Seata AT 模式的源码从分支事务一路翻到 UndoLogManager才真正想明白这个无侵入到底是怎么个无侵入法它又能保护你到哪一步。这篇文章不打算讲怎么引入依赖、怎么配置 registry.conf那些官方文档已经写得很清楚了。我要讲的是 AT 模式实现原理的底层链路一阶段为什么敢先提交本地事务、UNDO_LOG 在回滚时怎么把数据还原、全局锁到底锁的是什么、以及回滚失败时 Seata 为什么不选择覆盖当前数据。适合已经用过 Seata 但遇到诡异数据不一致问题的朋友也适合准备面试分布式事务原理的同学。1. AT 模式的核心思想先交本地事务把后悔药存进日志表1.1 一阶段提交和二阶段补偿的本质区别传统 XA 二阶段提交为什么慢因为第一阶段 PREPARE 之后数据库资源要一直锁着等全局协调者确认所有分支都准备好了才发送 COMMIT。这个过程中任何一条链路慢都会放大成整个事务的阻塞时间。AT 模式换了个思路先让每个分支的本地事务正常执行、正常提交把锁释放掉同时悄悄地把这条数据原来是什么样记录下来等全局事务要回滚时靠这份记录把数据还原回去。这个设计本质上是把回滚能力物化到了日志表里。业务 SQL 该怎么执行还怎么执行业务代码不需要为分布式事务做任何感知这就是无侵入的真正含义。你只需要把数据源换成 Seata 的DataSourceProxy然后在入口方法加一个GlobalTransactional剩下的脏活累活全是 Seata 在背后替你干。但代价也随之而来既然本地事务已经提交了其他事务很可能已经读到了这条提交后的数据。一旦全局事务最终决定回滚你只能靠 UNDO_LOG 里的快照把数据改回去而不是像 XA 那样撤销一个还没提交的事务。这就引出了 AT 模式最核心的问题镜像数据从哪来、怎么保证可靠、回滚时和别人修改的数据冲突了怎么办。1.2 为什么说 AT 是最终一致性而不是强一致性很多刚接触分布式事务的同学会误以为 Seata AT 模式也像 XA 一样提供强一致性。不是的。全局事务提交成功之前其他事务是能看到某个分支已提交的中间结果的只是 Seata 通过全局锁尽量避免并发修改让这种中间结果尽量不外泄。但如果你用了默认的读已提交隔离级别其他服务在全局事务提交前读取该行数据读到的是分支本地提交后的值而不是全局事务开始前的值。这意味着什么AT 模式提供的是一种最终一致的、可回滚的事务能力。业务上要接受这个窗口期。如果你的场景要求全局事务提交前外部绝对看不到任何中间状态那就该考虑 TCC 或者 XA而不是 AT。2. 一阶段执行链路一条 UPDATE 从进入到落地的完整过程2.1 一条 UPDATE 语句背后的十步AT 模式对一条 UPDATE 的处理远比普通 JDBC 执行复杂得多。以 MySQL 为例一条UPDATE product SET stock stock - 1 WHERE id 100在 Seata 代理下会走下面这条链路DataSourceProxy拿到连接后把Connection包装成ConnectionProxy绑定当前 XID。执行 SQL 前Seata 先解析 SQL拿到表名、主键条件、更新列等信息。在执行更新前先执行一条SELECT ... FOR UPDATE查询出当前数据作为前镜像。在同一个本地事务里执行真正的 UPDATE 语句。再次查询同一条数据拿到更新后的值作为后镜像。对比前后镜像如果更新结果没有变化就认为不需要补偿记录。把 beforeImage 和 afterImage 序列化成 JSON构造出一条 UNDO_LOG 数据填入 xid、branchId、rollback_info 等字段。把 UNDO_LOG 插入到和业务表同库的undo_log表中。向 TC 注册分支事务提交 lockKeys本次修改涉及的表名和主键。本地事务正常提交UNDO_LOG 和业务更改一起落库或者一起回滚。很多人容易忽略的一点是Seata 是先查前镜像再执行业务 SQL。如果反过来先执行 UPDATE 再查旧值就永远拿不到修改前的数据了。这也是为什么 Seata 要求业务表一定要有主键因为镜像查询和全局锁记录都依赖主键定位数据。2.2 UNDO_LOG 里到底存了什么UNDO_LOG 表可以说是整个 AT 模式的心脏。它的核心字段不多但每个都很关键字段作用branch_id分支事务 ID和 TC 上的分支注册对应xid全局事务 ID标记这条日志属于哪个全局事务rollback_info序列化后的前后镜像数据JSON 格式log_status0 表示待回滚1 表示已回滚2 表示回滚失败log_created / log_modified创建和修改时间rollback_info里的 JSON 大致长这样{ branchId: 12345, sql: UPDATE product SET stock stock - 1 WHERE id 100, tableName: product, beforeImage: { rows: [ { id: 100, stock: 10 } ] }, afterImage: { rows: [ { id: 100, stock: 9 } ] } }回滚时Seata 拿到这条 JSON先把 afterImage 和当前数据库里的数据做比对确认这期间没有人动过然后根据 beforeImage 反向生成一条 UPDATE 语句把 stock 从 9 改回 10最后删除这条 UNDO_LOG。整个过程用到的全是标准 JDBC 操作所以只要数据库支持普通事务AT 模式就能工作不必依赖特定的数据库 XA 实现。2.3 为什么 UNDO_LOG 必须和业务表同库我见过有同学把 undo_log 表建到了 Seata 服务端的库里结果一阶段插入 undo_log 时和业务数据不在同一个本地事务里本地事务回滚了而 undo_log 还在直接导致后续回滚逻辑错乱。这是个很致命的理解误区。UNDO_LOG 必须和业务表放在同一个数据库里目的就是利用本地事务的原子性业务 UPDATE 和 UNDO_LOG 插入要么一起成功要么一起失败。这样即使某个分支在一阶段就执行失败本地事务整体回滚也不会留下孤立的 undo_log 记录。如果放在不同库等于把原子性交给分布式事务去保证那就本末倒置了。3. 全局锁与隔离级别为什么回滚时不会覆盖别人的修改3.1 全局锁不是数据库行锁而是 TC 上的一组标记AT 模式里最容易被误解的概念就是全局锁。它不是在 InnoDB 引擎层加的任何东西而是 TC 端记录的一组表名 主键值 - 全局事务 ID的映射。Seata 集群模式下这些数据存在 TC 的存储介质里默认是文件或数据库所以全局锁本质上是应用层的一个分布式协作锁。当 RM 执行一条 UPDATE 时Seata 在解析完 SQL 之后会拿到这张表涉及的主键值向 TC 申请获取全局锁。如果这些主键对应的数据没被其他全局事务锁住申请成功就继续执行本地 SQL。如果已经被其他全局事务占用就进入自旋等待默认会等待一段时间后抛异常。所以全局锁控制的是同一个全局事务之间对同一行数据的串行性而不是数据库行锁。3.2 写隔离是怎么实现的假设全局事务 A 把 product 表 id100 的 stock 从 10 改成了 9一阶段提交后InnoDB 的行锁已经释放但 TC 上仍然记录着product:100 被全局事务 A 占用。此时如果全局事务 B 也想更新同一行Seata 在执行前会去 TC 申请全局锁发现被 A 占着B 就会卡在等待里。A 不结束B 永远拿不到锁直到超时。这就保证了全局写隔离同一行数据在某个全局事务提交或回滚完成之前不能被另一个全局事务修改。数据库行锁在阶段一就释放了全局锁接替它继续保护数据这个接力设计非常巧妙。但要注意这个保护是有前提的所有访问数据库的路径都必须走 Seata 的代理数据源。如果运营后台直连数据库改了一行数据Seata 完全感知不到全局锁的世界里根本没有这次修改。文章开头我说的那次事故就是这种绕过代理的蜘蛛侠式修改让镜像校验失败最终幸好没有覆盖运营的数据否则后果更严重。3.3 读隔离默认是读已提交想要更强要靠当前读很多同学以为 Seata 提供了可重复读隔离级别这是个普遍的误解。AT 模式的默认读隔离级别是读已提交针对全局事务而言因为一阶段本地事务已经提交其他事务通过普通SELECT可以读到这个中间结果。如果业务要求更强的一致性可以在读取数据时使用SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODESeata 会把这些语句也纳入全局锁的检查范围。当一条SELECT ... FOR UPDATE尝试读取一个被其他全局事务锁住的行时它同样会等待或超时从而实现当前读的阻塞语义。隔离级别Seata 是否支持说明全局读未提交支持直接读取一阶段提交后的数据风险较高全局读已提交默认级别普通 SELECT 可读到其他全局事务的中间结果全局可重复读有限支持无法完全复刻数据库的可重复读只能靠当前读辅助我在线上确实见过有团队对热点账户表做先查再更的逻辑由于默认读已提交两个服务同时读到余额都是 100后写覆盖前写最终余额对不上。排查到最后发现不是 Seata 的问题而是业务逻辑没有用SELECT ... FOR UPDATE做当前读。所以 AT 模式解决的是分布式事务的原子性和回滚不是多服务并发读写的互斥这个边界必须心里有数。4. 二阶段提交与回滚的决策链路从全局提交到 UNDO_LOG 清理4.1 分支提交阶段为什么只需要清日志当 TM 发起全局提交TC 会向所有分支发送 branchCommit 指令。很多第一次读源码的人会觉得奇怪一阶段业务 SQL 已经执行并且本地提交了二阶段提交到底在提交什么答案是基本什么都不用做只需要清理 UNDO_LOG 并释放全局锁。本地的业务变更已经在阶段一落库二阶段提交的动作只是确认这个分支可以正式结束然后删除对应的 UNDO_LOG 记录。有些版本还把这个删除操作做了异步化先给 TC 返回成功后台异步批量清理过期的 undo_log减少对业务高峰的 IO 干扰。所以你在监控里看到分支提交非常快这是正常现象。4.2 分支回滚阶段为什么那么重全局回滚就不一样了。RM 收到 branchRollback 指令后要做的工作包括根据 xid 和 branch_id 找到对应的 UNDO_LOG。校验 log_status 是否为 0防止重复回滚。解析 rollback_info取出 afterImage 和 beforeImage。用当前数据库的数据和 afterImage 做比对确认期间没有别的修改。校验通过后根据 beforeImage 生成反向 SQL。执行反向 SQL把数据恢复成 beforeImage 的状态。把 log_status 更新为 1标记已回滚。清理 UNDO_LOG 记录。其中第 4 步是整个回滚安全的守门员。如果当前数据已经不等于 afterImage说明这条数据在分支提交之后、全局回滚之前被别的东西改过了。此时 Seata 不会强行覆盖而是把分支标记为回滚失败上报给 TC切换到人工处理流程。4.3 TC 端的全局事务状态机TC 维护的全局事务状态并不是只有 Committed 和 Rollbacked 两个终态。真正跑生产时你会看到很多中间状态状态含义Begin全局事务刚创建TM 还没发起提交或回滚Committing正在向各分支发提交指令CommitRetry提交指令发送失败等待重试Rollbacking正在向各分支发回滚指令RollbackRetry回滚指令发送失败等待重试TimeoutRollbacking事务超时被触发进入回滚流程Unknown状态未知需要人工介入TC 和 RM 之间的通信是网络通信可能丢消息、可能超时。所以 Seata 把状态机设计成可重试的branchCommit 或 branchRollback 可能被重复投递RM 侧必须做幂等。这也是为什么回滚第一步要检查 log_status如果当前日志已经标记为已回滚重复的回滚指令直接返回成功绝不能执行第二次反向 SQL。否则同一个回滚被执行两次数据就被改飞了。5. 回滚校验、幂等与人工补偿源码里才能看到的边界5.1 镜像校验失败时Seata 为什么选择宁可失败也不覆盖我在排查线上问题的时候翻到 UndoLogManager 的回滚逻辑才真正理解 Seata 的设计哲学。当 afterImage 和当前数据不一致时Seata 判断这条数据在分支提交后被外部修改了。如果它强行执行反向 SQL把数据恢复成 beforeImage就会把外部修改一并覆盖掉造成更严重的丢失更新。所以 Seata 选择了报错、标记失败、交给人工。这种宁可失败也不猜的策略在数据一致性这种敏感场景里是正确且有担当的。代价就是运维侧要有兜底机制定期扫描undo_log表里log_status 2或长时间未清理的记录及时发现回滚失败的分支。5.2 一次典型的回滚失败排查链路我以文章开头那个事故为例完整还原一下排查思路先在 TC 日志里找GlobalSession is not active或者branch rollback failed之类的关键字确认是不是分支回滚失败。接着去业务库查undo_log表看log_status是不是变成了 2RollbackFailed拿到rollback_info里的 JSON。把 JSON 里的afterImage和数据库当前数据对比确认哪些字段被谁改过。查应用日志和审计日志确认改动来源。我们那次查出来就是运营脚本直连数据库改了库存。确认修改合理后手工修正数据如果修改不合理需要通过业务补偿接口把数据还原。最后把这条undo_log的状态清理掉保证不会再次触发告警。整个过程要冷静不能看到回滚失败就手工瞎改。因为回滚失败意味着当前数据已经和快照偏离必须先搞清楚偏离的原因和业务方意图再决定以哪边为准。5.3 幂等设计还藏着哪些坑Seata 的幂等设计很完善但越完善越容易让使用者忽略底层逻辑。比如branchCommit和branchRollback是异步投递的RM 可能先收到回滚再收到提交吗理论上不会但网络乱序或者 TC 重试时可能产生重复。代码里通过log_status和状态机做了双重保护普通的业务开发者根本不用操心。但有一个坑是很多人踩过的如果业务表的主键是逻辑删除场景即主键值被复用、UNDO_LOG 里的 beforeImage 对应一条已被删除的记录反向 SQL 可能影响 0 行或者影响错行。我在实际项目中遇到过一次用户注册时事务失败要回滚但用户数据随后被另一个服务逻辑删除了回滚时按主键去 UPDATE结果影响行数为 0Seata 会把这个当作回滚异常处理。这种场景建议在设计表结构时避免让主键值频繁复用或者给逻辑删除表增加额外的唯一业务键。6. 和 XA、TCC、Saga 的边界在哪里选型时的心里底线6.1 AT 模式适合什么场景用了几年的 Seata我的心里底线是这样的AT 模式最适合表结构清晰、SQL 可控、业务表都有明确主键、热点竞争不高的交易型系统。比如电商的订单创建、库存扣减、积分变更这些场景的 SQL 都是简单的主键更新前后镜像生成成本低全局锁竞争也不激烈AT 模式几乎是无脑最佳选择。它有足够出色的性能因为一阶段就释放了数据库行锁业务链路的并发能力比 XA 高一个量级。同时对研发的心智负担最小大家还是写本地事务一样的代码只是加一个注解。这也是 Seata 在社区能火起来的根本原因。6.2 AT 模式不适合什么场景反过来下面这些场景我建议谨慎使用 AT高频更新同一行数据的热点账户场景。全局锁会让所有事务串行超时概率飙升。UPDATE 语句的 WHERE 条件不走索引。Seata 做镜像查询和全局锁记录时可能锁住大量行甚至全表锁。依赖存储过程、触发器或者绕过数据源代理的团队。这些路径 Seata 管不住镜像校验会频繁失败。大宽表、批量更新场景。前后镜像动辄几百 KBUNDO_LOG 的写入开销和存储成本都不低。资金类强一致场景。AT 模式的最终一致性和回滚失败风险意味着你必须有一套完善的人工补偿和监控机制如果你的团队没有这个能力建议上 TCC。6.3 四种模式怎么选模式侵入性一致性性能典型场景XA最低强一致低资源锁到全局结束对一致性要求极高、并发量可控AT低最终一致高普通交易链路、库存、订单TCC高需要写 Try/Confirm/Cancel强一致取决于实现中资金、积分、跨系统资源Saga低中最终一致高长事务、异步流程、可容忍补偿我现在的选型原则很简单系统对强一致要求没那么苛刻、团队成员分布广、SQL 可控的优先 AT涉及资金、账务、余额这类强一致场景宁可多写几个 TCC 方法也要求稳长流程审批类任务用 Saga 配合消息队列做补偿XA 只在数据库团队能接受锁资源到全局结束的前提下使用。6.4 落地时一定要加的体检项最后分享几个我在生产环境沉淀下来的检查项每次新系统接 Seata 都要过一遍确认所有涉及分布式事务的业务表都有主键且主键不会频繁复用。SQL Review 阶段重点看 UPDATE/DELETE 的 WHERE 条件是否走索引。在监控大盘上增加全局锁等待超时指标GlobalLockWaitTimeoutException要能第一时间告警。定时任务扫描undo_log表中log_status 2的异常数据接入告警和自动通知。长事务要拆分尽量把一个全局事务内的远程调用次数降到最低。AT 模式虽然把数据库锁释放了但全局锁还占着远程调用节点越多全局锁占用的时间越长热点行被拖死的风险越大。所有直连数据库的脚本、后台运维工具必须走统一的 DBA 审核通道明确告知哪些表会被 Seata 全局事务管理不能随意裸写 SQL。我个人的体会是Seata AT 模式本身设计得足够精巧但无侵入更像是一把双刃剑。它确实让业务开发爽了但也容易让人放松对 SQL、对数据访问路径的警觉。真正出问题的往往不是 Seata 的原理而是使用者绕过了 Seata 的保护却还指望着 Seata 来兜底。把上面这些边界和体检项想清楚AT 模式才真正能在生产环境里睡得着觉。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门