恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Seata AT模式原理与实战:分布式事务全局锁与回滚机制详解
首页
资讯中心
/
Seata AT模式原理与实战:分布式事务全局锁与回滚机制详解
Seata AT模式原理与实战:分布式事务全局锁与回滚机制详解
发布时间:2026/10/21 12:18:46
做微服务的时间长了几乎都会撞上分布式事务这道坎。单体应用里一个 Transactional 就能搞定的原子性拆成多个服务、多个数据库之后就变了味订单服务扣了库存账户服务扣款失败这两笔数据怎么收场Seata 的 AT 模式Automatic Transaction就是为这种场景设计的一套开源分布式事务方案它把两阶段提交的大部分复杂度收进中间件业务侧往往只需要加一个注解。这篇文章我从原理讲到踩坑实录适合刚准备接 Seata 的同学也适合已经在用却在为全局锁、回滚失败头疼的人。1. 项目概述与核心思路拆解1.1 分布式事务到底在解决什么问题先说清楚需求。假设你手下有一套微服务下单链路订单服务负责落订单表库存服务负责扣 sku 库存账户服务负责扣用户余额。三个服务三套库甚至可能是三套不同的数据存储。如果它们是三个独立的本地事务就会出现最经典的对账事故库存扣了、余额扣了、订单没生成用户骂街或者订单生成了、库存没扣超卖仓库骂街。单体时代我们没有这个烦恼一个关系型数据库里的事务天然具备 ACID。但服务一拆本地事务的作用域就只在自己的库内跨库、跨服务的一致性问题就冒出来了。这个问题的本质是我们需要一个“全局事务”它能把多个参与者服务/数据库的状态变更串成一个整体要么全部成功要么全部不生效。分布式领域有一条 BASE 理论基本可用、软状态、最终一致。它是对强一致性的妥协。Seata 的定位正好落在“Base两阶段提交”的加强版上它不是纯理论框架而是一个可落地的中间件把全局事务的协调工作收归到一个独立的 TC事务协调器来统一管理。我习惯把问题分层来看一致性要求有多强订单这种链路频繁出现但失败率低适用“最终收敛为一”的模式业务侵入有多深能不能只加注解不改业务逻辑性能开销能忍多少每次业务操作多出来的 SQL 和网络调用是多少。这三条是后面选型和对齐问题的锚点也是一旦出问题时要回到原点重新思考的地方。1.2 AT 模式的整体设计思路Seata 里有三个核心角色理解了它们后面所有配置都不会晕TCTransaction Coordinator独立部署的服务端记录全局事务状态、协调各分支的提交回滚。它的作用像一个全局的“交管中心”。TMTransaction Manager嵌入在应用里的组件负责开启全局事务、提交或回滚整体事务。通常一个注解就能驱动它。RMResource Manager也是嵌入在应用里的组件管理每个数据源的分支事务执行分支注册并上报执行结果。TM 开启全局事务后会拿到一个全局唯一标识 XID。XID 会被塞进 RPC 调用的上下文里跟着 Feign 或者其他调用链一路传播到下游服务。下游服务只要在同一个 XID 下执行事务操作就会被识别成同一个全局事务的分支。这个“XID 链路透传”是 AT 模式能不能跨服务生效的关键我下面的常见问题里还要专门讲。AT 模式里的“自动”两个字是核心卖点。它借鉴了两阶段提交协议但对业务基本无感。秘密武器是三样东西数据源代理DataSource Proxy、镜像快照before/after image、undo_log 表。数据源代理会在真实执行 SQL 之前做一次旁路解析自动记录这行数据的“执行前快照”然后放行业务 SQL执行完再抓一张“执行后快照”。这两张快照连同分支信息写进一个专门的 undo_log 表整个过程跟业务 SQL 处于同一个本地事务里。为什么敢称“自动”因为回滚所需的补偿 SQL 不需要你写中间件根据 before image 反推生成。这就是它比 TCC 省事的地方TCC 要求每个业务手写 Try/Confirm/Cancel 三段逻辑AT 模式直接替你干了。从设计目标看AT 模式是“用空间换时间、用协议换侵入”多写一张 undo 表、多几次镜像查询换来业务代码几乎零改造换取比 XA 强一致协议好得多的可用性。2. AT 模式核心机制原理解析2.1 一阶段本地事务 镜像快照现在我们把一个全局事务的执行过程放大来看。假设 A 服务是全局事务的发起方B 服务被远程调用后作为分支参与者下面这串动作在每个参与者本地都会发生。第一步TM 获取 XID 并开启全局事务。第二步业务 SQL 走到 Seata 代理的数据源上。注意这里要求所有写操作都必须经过代理连接这是很多人埋坑的地方后面会说。进入代理之后整个过程大致是解析业务 SQL根据主键或更新条件定位目标行在执行前执行一次查询拿到 before image修改前那一瞬间的行数据放行真实的 INSERT/UPDATE/DELETE再次查询拿到 after image修改完成后的行数据把 before image、after image 连同 XID、branchId 编码后写入 undo_log 表向 TC 注册分支事务申请全局锁拿到全局锁之后提交本地数据库事务。为什么要在一个本地事务里同时完成“业务改动”和“undo_log 写入”道理很简单如果业务改了、日志没写系统恰好在这时崩溃回滚就没依据了如果日志写了、业务没改后续校验又会不对。只有把两者放进同一个事务才能保证要么同时成功、要么同时失败。还有一个值得品的设计注册分支和申请全局锁发生在本地事务提交之前。这意味着某个全局事务 T1 虽然本地数据已经改完、但还没拿到 TC 的最终裁定这期间其他全局事务不能在同一个数据行上继续“抢跑”。全局锁就是在这里起作用的。2.2 二阶段异步提交与镜像回滚全局事务进入二阶段后TC 会根据所有分支的上报结果来定夺全部成功就发“提交”有任何一个分支失败就发“回滚”。先看提交路径。二阶段提交时RM 接到 TC 的通知要做的事情非常轻量删除对应分支的 undo_log。这不是非删不可的强同步操作在实际实现里可以异步批量删除。为什么提交不用回滚业务数据因为业务数据在本地事务提交那一刻已经真实落库了所谓全局提交本质上只是“确认不需要反悔了”把退路undo_log清掉即可。再看回滚路径。这是 AT 模式最见功力的一环。RM 拿到 XID 和 branchId 之后找到对应的 undo_log先把 after image 拿出来跟数据库当前行对比。这一步叫“脏写检查”。如果当前数据和 after image 一致说明这段数据从分支执行完毕到现在没人动过可以放心回滚。中间件就根据 before image 生成一条反向 SQL原来是 UPDATE就回滚成 BEFORE 行的全部字段值原来是 DELETE就按 before image 重新 INSERT原来是 INSERT就按 before image 的主键 DELETE。回滚完成后删除 undo_log分支回滚结束。脏写检查绝不是走过场。假如有一条数据被分支事务改过快照是“余额从 100 变 50”但回滚时发现当前余额已经变成 20说明这期间有一个脱离全局事务控制的操作动了它。这时候中间件不会盲覆盖因为一旦按 before image 覆盖成 100就把别人的 20 这个合法结果也抹掉了。它会标记失败、打印异常日志把问题留给人处理。这个设计是安全底线也是大家在日志里偶尔看到“无法回滚”的真正原因。2.3 全局锁与并发冲突控制Seata AT 模式里其实有两把锁。一把是大家熟知的数据库本地行锁持锁期间就是一阶段那段本地事务的执行期事务提交即释放。另一把是全局锁记录在 TC 侧锁的粒度精确到某个数据资源实际实现里通常按主键或唯一键。全局锁的存在意义是为了解决一个微妙的时间窗口服务 A 的业务数据已经在本库提交但从全局视角看整个分布式事务还没有被裁定。此时如果服务 B 的另一个全局事务也想改同一行它必须先拿到全局锁。全局锁被 T1 占着T2 就得等。等不到就抛“获取全局锁超时”的异常T2 的本地事务回滚业务看到的就是明确失败而不是静默覆盖。这个机制会在热点行的场景下放大成一个明显的性能问题。比如秒杀场景里大量请求对同一个库存行做扣减每个全局事务都要排队等全局锁吞吐量会掉得很难看。所以选型之前一定要清楚自己的写冲突密度否则上线后才发现被锁排队拖垮。调优层面主要有两个方向调大 RM 侧获取锁的重试次数和重试间隔不同版本参数名略有差异比如 client.rm.lock.retry-times 和 client.rm.lock.retry-interval以及在业务侧缩短事务的持续时间——锁只在该全局事务存续期间持有事务窗口越短竞争越小。3. 实操从零接入 Seata AT 模式3.1 环境准备与基础配置做一次最简单的本地联调我建议按下面顺序来。第一步下载并启动 TC 服务端。我用的是 seata-server 的 file 模式日志和会话都落本地文件适合学习。命令大致是这样sh seata-server.sh -p 8091 -m file -n 1生产环境不要用 file 模式TC 的状态需要放到共享存储里并做集群部署否则 TC 一挂全局事务状态全丢。这点切记。第二步在每个参与全局事务的业务库中建一张 undo_log 表。官方标准 DDL 类似下面这样我一般会在建表语句后面额外加注释标明这个表的用途方便后来的人一眼看懂CREATE TABLE undo_log ( id BIGINT(20) NOT NULL AUTO_INCREMENT, branch_id BIGINT(20) NOT NULL, xid VARCHAR(100) NOT NULL, context VARCHAR(128) NOT NULL, rollback_info LONGBLOB NOT NULL, log_status INT(11) NOT NULL, log_created DATETIME NOT NULL, log_modified DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY ux_undo_log (xid, branch_id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8;注意几个点表名必须是 undo_log除非你改过自定义前缀的配置字段名和类型都要和中间件期望保持一致引擎一定要是 InnoDB原因你应该懂——回滚操作本身就是事务操作MyISAM 在这里连基本条件都不满足。第三步在服务里引入 starter 依赖。以 1.8.0 版本为例dependency groupIdio.seata/groupId artifactIdseata-spring-boot-starter/artifactId version1.8.0/version /dependency第四步在 application.yml 里配上最简的 file 模式配置spring: application: name: order-service seata: enabled: true application-id: order-service tx-service-group: default_tx_group registry: type: file config: type: file service: vgroup-mapping: default_tx_group: default grouplist: default: 127.0.0.1:8091 client: rm: lock: retry-times: 30 retry-interval: 1000这段配置里最容易出错的是 tx-service-group 与 vgroup-mapping 的对应关系。客户端用事务群组名去找 TC 集群群组映射必须跟 TC 侧配置对上否则你会看到服务启动正常但一发起全局事务就报 no available service 或者注册不到 TC。3.2 核心代码写法与数据源代理接入之后业务侧的改动小到让人怀疑是不是漏了什么。给最外层的方法加一个 GlobalTransactional 就行Service public class OrderService { GlobalTransactional(name create-order-tx, rollbackFor Exception.class, timeoutMills 10000) public Long createOrder(OrderCreateRequest req) { Order order buildOrder(req); orderMapper.insert(order); // 本地分支操作 inventoryClient.deduct(req.getSkuId(), req.getCount()); // 远程扣库存 accountClient.deduct(req.getUserId(), order.getAmount()); // 远程扣余额 return order.getId(); } }GlobalTransactional 的三个属性值得花 30 秒理解name 只是全局事务的名字方便排查日志rollbackFor 决定了哪些异常触发回滚默认只对 RuntimeException 和 Error 生效遇到 checked exception 时想回滚必须显式指定 Exception.class这是我见过最多的“注解失效”原因timeoutMills 是全局事务的超时时间超过它 TC 会强制把事务标记为超时回滚所以这个值要留足余量不能比最长的业务链路还短。数据源代理这块starter 默认会开启自动代理seata.enable-auto-data-source-proxy 默认 true它会把业务方的 DataSource 包装成 DataSourceProxy。但有几种情况代理不生效后续常见问题里我详细说。核心原则只有一条你的所有写 SQL 都要走被 Seata 代理过的连接别图省事自己在代码里 new 一个裸连接。还有一个容易被忽略的细节是 XID 透传。如果你们用的是 Feign而 starter 里自带的拦截器没生效就需要自己补一个Component public class FeignXidInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { String xid RootContext.getXID(); if (xid ! null) { template.header(RootContext.KEY_XID, xid); } } }这个拦截器做的事情很单纯把当前线程里的 XID 塞进 HTTP 头下游服务再从请求头里把它取出来塞回自己的线程上下文。一旦这一步丢链下游就永远意识不到自己属于哪个全局事务。3.3 一个完整的下单链路演示我习惯直接用“余额不足”的场景来验证 AT 模式是否真的在工作。步骤很简单调 createOrder让账户服务在扣款时故意抛异常然后看库存服务那边的库存是否回滚。链路是这样的。TM 在订单服务里开启全局事务XID 生成。订单服务先落订单本地事务提交。随后调用库存服务库存服务通过透传过来的 XID 加入同一全局事务扣减库存并写好 undo_log注册分支提交本地事务。最后调用账户服务账户服务同样加入全局事务但在余额校验时抛了业务异常。账户服务的本地事务回滚分支上报失败。TC 裁决全局回滚通知库存服务回滚。库存服务的 RM 拿到 undo_log对比 after image 和当前行确认数据没被额外改动于是依据 before image 把扣掉的库存加回来再把 undo_log 删除。整个下单接口最终表现为“订单不存在、库存没变、余额没少”三库数据归位。我在本地复现时会在日志里盯着三行关键输出全局事务开启的 XID库存服务注册分支的 branchId回滚完成后 undo_log 被删除的记录。三者都能对上说明 AT 模式的闭环真的走通了。A 同学踩过一个很经典的坑他只给订单服务开了异步线程池里的 Feign 调用XID 在异步线程里丢了下游库存服务根本没进全局事务全局回滚时自然没有库存分支数据就乱了。后面我用代码显式传递了 XID 上下文才解决。4. 常见问题与排查经验4.1 回滚失效与脏写检查先看一个高频现象接口确实抛了异常全局事务日志里也显示回滚了但数据库里数据没还原。这种“假回滚”的根源90% 是分支根本没进全局事务另外 10% 是回滚路线上被脏写卡住。分支没进全局事务常见原因有三个。第一XID 没有透传到下游Feign 或 RPC 的拦截器缺失下游不认识这个全局事务它只是在普通本地事务里各自为政。第二数据源没有被代理你自己手动创建了 DataSource 或者在特定代码路径直接拿了原始连接中间件就没机会生成 undo_log。第三tx-service-group 映射错误分支服务映射到的 TC 集群跟发起方不一致看起来都连上了实际上不是同一个全局事务。脏写检查失败又是另一种情况。日志里如果出现 “Status of data changed in global transaction” 这类表述意思是回滚时拿到的当前行数据跟 after image 对不上。我在真实项目中碰到过一次一个定时任务用裸 JDBC 直接改了订单表的备注字段恰好订单行处于某个全局事务的窗口期内回滚校验就失败了。这种问题没法靠中间件自动解决只能限定所有写路径都走代理或者把这种离线任务统一收口。4.2 全局锁等待超时另一个高频且更折磨人的问题全局锁获取超时。日志往往长这样Could not get global lock, xid xxx, branchId yyy原因几乎都是同一个某个热点行被另一个未结算完的全局事务占着。常见于秒杀、抢购、库存扣减、余额扣减这类场景。我的排查套路是三步。第一步看 TC 消息或日志确认持锁的 XID 是谁、持了多久。第二步看这个 XID 的全局事务为什么迟迟不结束——是不是业务里混了大查询、慢 SQL 甚至手工 sleep把事务窗口拉长了。第三步才会去考虑调参数。调参也只是缓解不是根治把锁重试次数和间隔调大能容忍更长的排队但吞吐上不去。真正的解决方案在前面提过缩短事务时间拆小事务粒度或者对热点资源做前置串行化。如果业务上允许把热点更新从全局事务里摘出去改用消息驱动的最终一致是比死磕锁参数更健康的选择。4.3 其他高频坑速查表我把这几个月的实战问题整理成了一张速查表基本可以贴在 IDE 旁边现象常见原因处理建议发起全局事务时报 no available service客户端 tx-service-group 与 vgroup-mapping 对不上核对所有参与方的群组映射和 TC 地址回滚流程没有覆盖下游服务XID 没有通过 Feign/异步链路透传启用或补一个 Feign/HTTP 拦截器传递 XID日志显示回滚成功但数据没还原分支 SQL 没有走代理数据源检查自建数据源、原生连接等旁路路径回滚时脏写检查失败窗口期内有非代理操作改了同一行所有写路径统一走代理必要时业务补偿GlobalTransactional 对 checked exception 不回滚默认只回滚 RuntimeException/Error显式设置 rollbackFor Exception.classundo_log 表持续膨胀二阶段异步删除不及时或事务频繁回滚定期清理评估能否开异步 commit 优化热点行场景吞吐骤降全局锁排队严重缩短事务窗口或考虑改用 TCC/消息方案这张表里每一行都是我或者身边同事真金白银踩出来的。建议先收藏上线前对着过一遍能省掉好几个凌晨的告警电话。5. 使用边界与实战心得5.1 到底什么场景适合 AT 模式选型的时候我的判断顺序是这样的先问业务能不能接受最终一致如果能优先考虑消息异步如果必须强一致再看能不能接受 AT 模式的锁竞争和额外开销。AT 模式最舒服的场景是写冲突不高、失败率低、但失败后必须严格回滚的链路。典型的比如普通商城的下单、出库、支付回调。这类业务日常并发不夸张热点行竞争不明显代码改造量又小AT 模式几乎是性价比最高的选择。反过来这些场景要慎重极高并发下对同一个行反复读写热点竞赛单个全局事务里揉进大量慢 SQL、外部 HTTP、文件操作导致事务窗口动辄几十秒数据库不是 InnoDB 或不具备造 undo_log 表条件的环境。另外如果你只追求跨服务调用最终一致、且对数据丢失不敏感直接上消息队列做可靠异步投递比引入分布式事务中间件都轻。5.2 常见分布式事务方案对比做个直给的对比方便你心里有个坐标系方案一致性侵入性性能适用场景XA 两阶段强一致中低锁持有时间长低并发强一致场景Seata AT读已提交加强低注解代理中多一次镜像和全局锁绝大多数微服务写链路TCC最终一致高手写三方法中高强约束的资源预留如库存预占Saga最终一致高手动补偿高长流程业务编排我个人的感受是AT 和 TCC 不是替代关系而是梯度关系。项目初期链路简单先上 AT 把一致性兜住等业务增长到热点冲突成为瓶颈再针对那一条热点链路替换成 TCC 或者异步消息其他链路继续保持 AT。死磕单一方案往往得不偿失。5.3 个人经验与调优建议最后分享几条压测和运维层面的建议。第一先量一下“同一条热点行的并发写”能扛到多少。我见过一个项目前期联调都正常压测到一定并发后全局锁排队超时像是约好了一样集体爆发。提前压测心里有数。第二事务窗口能缩多短就缩多短。把无关的远程调用、大查询挪到事务外面只为真正需要一致性的几步开全局事务。第三别忽略 TC 的高可用。TC 挂了全局事务就没法收工生产必须用共享存储加多节点部署再配合配置中心和注册中心做动态发现。第四日志和监控要让 XID 贯穿始终不然出问题你连从哪个分支开始查都找不到。如果只是查询场景需要保持一致性别轻易套全局事务用 GlobalLock 或者 select for update 往往更轻快。这个细节容易被忽略但压测时差距很明显。我在实际操作中的体会是Seata AT 模式是“成本最低的分布式事务入门方案”但它不是银弹。你越早把它当成一个有锁竞争、有额外开销的工程组件来看待越少在线上被它打脸。好好设计事务边界配合合理的压测和监控它会比你想的可靠得多。