资讯动态

Seata分布式事务原理与实战:全局锁、undo_log与生产落地

发布时间:2026/10/14 23:50:58 来源:尧图企业网站定制
做微服务的人早晚都会撞上分布式事务这堵墙。Seata是我这些年实际用下来最顺手的开源分布式事务框架它把全局事务、分支事务、全局锁、undo_log这套机制整合得相当成熟解决跨服务数据一致性问题时不像传统XA那样把数据库卡得太死也不像纯MQ方案那样只能保证最终一致。这篇文章打算从底层原理一路聊到生产落地把我配置过的坑、调过的参、看过的锁等待超时日志都摊开讲适合正在做技术选型、被跨库数据不一致问题困扰或者想彻底理解Seata内部机制的后端开发者。很多人一听到分布式事务就头大觉得理论太高深、落地太复杂。实际上只要把Seata的核心思路理清楚后面无论是排查问题还是二次开发都有清晰的方向。我尽量不写教科书式的介绍而是按我自己的理解把人话讲透。1. 先搞清楚Seata到底在解决什么问题1.1 分布式事务难在哪单体应用时代事务是数据库说的算。一个BEGIN TRANSACTION包住多个SQL要么全部提交要么全部回滚ACID由数据库引擎保证开发基本不用操心。但服务拆分成微服务之后一个业务操作往往要跨多个服务、多个数据库。比如最常见的下单场景订单服务写入订单表库存服务扣减库存账户服务扣减余额。这三个动作分布在三个独立的数据库里每个数据库各自有自己的本地事务。如果库存扣减成功了账户扣减却失败了订单服务已经没有能力让库存服务回滚——这就产生了跨服务的数据不一致。Seata要解决的就是这件事让多个服务参与的一次业务操作在外界看起来像是一个本地事务一样具备原子性。它给这个跨服务事务起了一个名字叫全局事务。分布式事务难的根本原因在于网络不可靠和数据库之间的独立约束。两个数据库之间不可能直接共享锁和undo日志也做不到像单库那样靠binlog和锁机制实现强一致。业界很早就有XA协议它的问题是资源被事务长时间锁定高并发下数据库基本扛不住而且很多业务团队没有专职DBA去调XA的参数用起来非常痛苦。1.2 Seata的思路把两阶段提交重新设计了一遍Seata从整体上看就是一个分布式版本的两阶段提交框架但它和传统XA的2PC有本质区别。传统XA协议要求所有参与者在第一阶段就锁定资源等全局协调者发出提交指令之后才能释放锁。这个机制在跨库场景下会导致锁持有时间被网络通信放大一旦协调者宕机或者网络抖动参与者可能要等很久系统吞吐量直线下降。Seata的思路是把资源锁定和数据变更解耦。它允许业务SQL正常执行、正常提交本地事务只把每个分支事务的数据变更前后镜像记录下来生成undo_log。全局事务需要回滚的时候再根据undo_log反向补偿把数据恢复成原样。这套机制设计上的巧妙之处在于本地事务提交了数据库的行锁就释放了而不是像XA那样一直等到全局事务结束。另外Seata把参与方角色分得很清楚TCTransaction Coordinator事务协调器独立部署的server端负责全局事务的开启、提交、回滚以及全局锁的登记。TMTransaction Manager事务管理器通常内嵌在发起全局事务的业务应用里负责向TC发起全局事务的Begin、Commit、Rollback请求。RMResource Manager资源管理器负责管理分支事务的资源把本地事务的执行结果上报给TC并处理TC下发的回滚指令。这三者配合起来业务代码只需要在入口方法上加一个注解事务的开启、分支注册、提交回滚都由框架自动完成。这是Seata最吸引人的地方对业务改造的侵入性极小不需要像TCC那样手动写三套接口。1.3 选它之前要认清边界必须说清楚Seata不是银弹。终极解决方案这个说法是相对它的易用性和覆盖场景而言的不代表任何场景都能无脑上。如果你的事务内操作全是单库本地事务不需要Seata。如果你的业务要求不强一致能接受消息队列加定时任务对账这种最终一致方案也不必上Seata因为Seata无论哪种模式都会有额外的全局锁开销和网络交互开销。但如果你的业务确实需要跨服务强一致又不想把代码改得面目全非Seata确实是目前开源生态里最值得考虑的一套方案。它不是一个简单的组件而是一整套事务治理框架这也就意味着使用它之前需要对它的事务模型有基本的认知否则出了问题会无从下手。2. 核心机制AT模式到底是怎么做到基本无侵入的2.1 AT模式的完整执行链路AT模式是Seata最出名、用得最多的模式全称是Automatic Transaction自动事务。所谓自动指的是框架自动完成数据快照、自动生成回滚SQL、自动协调分支事务提交回滚业务方不需要额外写补偿逻辑。我用一个下单场景把完整链路走一遍。假设订单服务调用库存服务两个服务都接入了Seata事务入口在订单服务的某个方法上方法加了GlobalTransactional注解。第一步TM向TC发起全局事务开启请求TC返回一个全局事务IDXID。这个XID会通过调用链传递到下游服务传递方式可以是Dubbo的attachment也可以是HTTP请求头里的tx-xid。第二步订单服务执行本地SQL比如插入一条订单记录。RM在本地事务提交前拦截这条SQL生成before_image变更前的数据镜像执行SQL后生成after_image变更后的数据镜像这两张镜像连同主键、表名、SQL类型一起写入undo_log表。第三步订单服务的本地事务提交注意这里是真的提交数据库行锁在这一步就释放了。RM同时向TC注册分支事务上报undo_log的位置和影响行数等信息。第四步订单服务通过Feign或者Dubbo调用库存服务。库存服务收到XID之后同样按照上述流程执行扣减SQL、生成undo_log、提交本地事务、注册分支事务。第五步订单服务方法执行完毕TM向TC发起全局提交请求。TC检查所有分支事务的状态如果全部成功就通知各个RM异步删除自己的undo_log数据。整个全局事务完成。如果中间任何一步失败比如库存扣减抛了异常TM会向TC发起全局回滚。TC通知各个RM回滚分支事务。RM拿到undo_log里的before_image生成反向SQL把数据恢复成原来的值。这个回滚是物理执行SQL不是简单的删数据所以能精确恢复到事务开始前的状态。这里有一个很多人初学时容易懵的点为什么本地事务都提交了还能回滚关键就在于undo_log。它相当于把后悔药留在了数据库里全局回滚时找到这颗药吃下去数据就回退了。代价是每个业务数据库都要额外建一张undo_log表每条变更数据都要多写一份镜像日志。2.2 全局锁与隔离级别的真相Seata AT模式能够工作的另一个核心是全局锁。为什么需要全局锁因为如果两个全局事务同时操作同一行数据A事务回滚时会按undo_log恢复数据如果B事务已经提交了修改A的回滚就可能把B的改动覆盖掉。为了防止这种互相覆盖Seata在TC端维护了全局锁记录当前哪些行被哪个全局事务锁定。这里要特别理解全局锁和数据库行锁的区别。数据库的行锁是物理锁由数据库引擎管理锁的是数据记录Seata的全局锁是逻辑锁存储位置在TC的SessionManager里锁的是某张表的某条主键记录。AT模式下分支事务执行本地SQL时会先向TC申请全局锁申请成功之后才提交本地事务。因为本地事务提交后数据库的行锁立刻释放了这期间其他事务可能已经改了这行数据。所以全局锁的申请窗口实际上横跨了整个全局事务生命周期。高并发下如果多个全局事务同时操作同一行热点数据很容易出现全局锁等待。Seata默认会重试拿锁超过重试次数就抛出类似Global lock acquire timeout的异常。AT模式的隔离级别默认是读已提交听起来不够高大上但这是性能和数据安全之间的平衡。Seata能保证已提交的全局事务不会丢失数据能防止写覆盖但对于未提交的全局事务产生的中间状态别的服务通过普通SQL查询可能看得到因为分支事务的本地事务已经提交了。如果需要更高的隔离级别查询时必须使用SELECT ... FOR UPDATE这样Seata会走当前读检查全局锁如果遇到未提交事务的数据就会阻塞等待。我在生产环境就遇到过因为普通查询读到中间态数据导致的bug后来统一约束团队对敏感数据查询必须加FOR UPDATE。2.3 TCC、Saga、XA什么时候不能选ATAT模式虽好但有些场景下并不合适这时候就需要了解Seata家族的其他三种模式。先说TCC模式全称Try-Confirm-Cancel。它要求业务方把每个资源操作拆成三个方法Try阶段做资源预留Confirm阶段做真正提交Cancel阶段做补偿回滚。典型场景是账户转账Try阶段冻结冻结资金Confirm阶段扣减冻结资金并增加对方余额Cancel阶段释放冻结资金。TCC的好处是不依赖数据库undo_log适合操作第三方接口、缓存、NoSQL等非关系型资源坏处是业务代码要写三套逻辑开发成本高而且Try阶段的资源预留方案设计不好容易出Bug。Seata对TCC的支持主要是提供框架层面的协调具体的幂等、悬挂、空回滚处理仍然要业务自己兜底。Saga模式适合长事务和业务流程编排比如旅行预订这类跨多个服务且链路很长的业务。Saga把一个全局事务拆成一系列子事务每个子事务都有对应的补偿事务执行失败后沿着调用链反向执行补偿。它比TCC更灵活但实现复杂度更高Seata的Saga是通过状态机引擎来定义的不太适合业务规则频繁变化的系统。我个人的看法是除非已经有成熟的状态机平台否则别轻易在核心交易链路里用Saga补偿流程的维护成本会被业务迭代速度拖死。XA模式是Seata对标准XA协议的兼容封装利用数据库原生的事务能力事务执行期间资源被真正锁定隔离性最强但吞吐量最差。适合对强隔离有硬性要求、并发量不高的场景。一般来说如果数据库层面能接受XA不如先评估一下业务是否真的需要这种级别的隔离多数情况下读已提交加当前读已经够用。四种模式的选择没有绝对的好坏主要看业务对一致性、吞吐量、开发成本三个维度的取舍。AT适合大多数核心交易链路TCC适合跨系统资源操作Saga适合长流程业务XA适合强隔离低并发场景。3. 从零接入Server部署与业务改造实操3.1 部署TC Server使用Seata的第一步是部署TC事务协调器Server。不同版本下载的结构会有差异我这里以常见的1.5.x版本为例讲配置思路新版本2.x基本沿用同样逻辑。Server端核心有两个配置文件registry.conf和application.yml。registry.conf用来配置注册中心和配置中心。Seata支持nacos、consul、etcd、eureka等注册中心也支持纯文件模式。小规模部署直接用file类型最简单registry { type file } config { type file }如果用file类型那么事务分组等配置会写到application.yml里。但生产环境我建议还是把注册中心配起来因为Server端要支持高可用和集群。注册配置里核心是让TC把自己注册到注册中心业务应用通过注册中心发现TC地址。application.yml里最关键的配置是store模式。Seata的全局事务会话数据可以存在内存、文件或者数据库里。部署单机测试用file模式生产环境一定要用db模式把事务会话数据持久化到数据库否则TC重启会丢失运行中的全局事务状态而且无法支持多节点集群。store { mode db db { datasource druid dbType mysql driverClassName com.mysql.cj.jdbc.Driver url jdbc:mysql://your-db:3306/seata?useUnicodetruecharacterEncodingutf8 user your-user password your-password } }同时需要在TC的数据库里建全局事务相关的表。不同版本的建表脚本有差异官方文档的script/server/db/mysql.sql里包含global_table、branch_table、lock_table三张核心表。命名上不同版本前缀可能不同但逻辑都是记录全局事务、分支事务和全局锁。注意一点lock_table在TC的高并发场景下是热点表如果后面发现TC性能瓶颈优先排查这里。启动Server的命令是sh seata-server.sh -p 8091 -h 本机IP端口默认8091。启动完之后检查日志确认注册成功再继续下一步。很多第一次部署的同学忘了配置application.yml里的store默认用file也能启动后面一旦遇到TC重启导致全局事务丢失的诡异问题回来检查这一项基本都是原因。3.2 业务工程接入Seata业务应用的接入我给一个最小化的清单照着加就不会漏。第一步引入依赖。我用的是Spring Boot项目在pom.xml里加上seata-spring-boot-starter版本尽量和Server端保持一致大版本不一致很容易出现协议不兼容的问题比如1.x客户端连2.x服务端可能直接握手失败。第二步配置application.yml。核心是事务分组配置seata: enabled: true application-id: order-service tx-service-group: my_tx_group registry: type: nacos nacos: server-addr: 127.0.0.1:8848 group: SEATA_GROUP service: vgroup-mapping: my_tx_group: default grouplist: default: 127.0.0.1:8091tx-service-group是一个逻辑分组名vgroup-mapping把这个逻辑分组映射到TC集群名grouplist是TC集群的地址列表。如果配置了注册中心grouplist可以不配客户端自动从注册中心发现TC地址。这里容易搞混的是事务分组并不是TC端的真实集群名它是一层路由封装目的是让客户端不直接感知TC集群地址的变动。第三步配置数据源代理。这是Seata接入最关键的环节。Seata要拦截SQL、生成undo_log就必须代理你的数据源。用Seata提供的DataSourceProxy包装一下Configuration public class DataSourceConfig { Bean ConfigurationProperties(prefix spring.datasource) public DataSource dataSource() { return new DruidDataSource(); } Bean public DataSourceProxy dataSourceProxy(DataSource dataSource) { return new DataSourceProxy(dataSource); } Bean public SqlSessionFactory sqlSessionFactory(DataSourceProxy dataSourceProxy) throws Exception { SqlSessionFactoryBean factory new SqlSessionFactoryBean(); factory.setDataSource(dataSourceProxy); return factory.getObject(); } }很多新人第一次接入忘了让SqlSessionFactory使用代理后的数据源结果Seata根本没有拦截到SQL事务注解看着加了实际上啥也没干。这个坑我再三强调务必检查MyBatis/MyBatis-Plus的SqlSessionFactory用的是不是DataSourceProxy。第四步建undo_log表。每个参与全局事务的业务数据库都要执行CREATE TABLE IF NOT EXISTS undo_log ( id BIGINT NOT NULL AUTO_INCREMENT, branch_id BIGINT NOT NULL, xid VARCHAR(100) NOT NULL, context VARCHAR(128) NOT NULL, rollback_info LONGBLOB NOT NULL, log_status INT NOT NULL, log_created DATETIME NOT NULL, log_modified DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY ux_undo_log (xid, branch_id) ) ENGINE InnoDB AUTO_INCREMENT 1 DEFAULT CHARSET utf8mb4;这张表结构来自官方标准脚本字段作用分别是记录分支事务ID、全局事务XID、回滚镜像数据、日志状态和创建修改时间。如果业务表用了奇怪的字符集注意保持utf8mb4一致否则写入undo_log时可能出现编码错误。3.3 一个完整的全局事务示例下面用代码展示一个最典型的下单场景订单服务发起全局事务调用账户服务扣款调用库存服务扣库存。这个例子只写核心代码省去完整的DTO和Mapper。订单服务入口方法Service public class OrderService { GlobalTransactional(rollbackFor Exception.class) public void createOrder(OrderDTO orderDTO) { // 1. 本地插入订单 orderMapper.insert(orderDTO); // 2. 远程扣减账户余额 accountClient.deduct(orderDTO.getUserId(), orderDTO.getAmount()); // 3. 远程扣减库存 inventoryClient.deduct(orderDTO.getProductId(), orderDTO.getQuantity()); // 4. 其他业务操作如果这里抛异常上面所有操作都会回滚 } }账户服务和库存服务不需要加GlobalTransactional只需要在自己的本地方法上保留Transactional执行普通的本地事务即可。Seata通过XID把整个调用链串起来下游服务只要接入Seata并且能从调用上下文拿到XID就会自动注册为这个全局事务的分支事务。这里说一个细节Transactional一定不要写在加了GlobalTransactional的方法内部再嵌套一个事务方法。一个全局事务里最好只有一层本地事务边界嵌套本地事务容易导致事务传播行为不符合预期我曾经见过一个全局事务里嵌套了REQUIRES_NEW子事务提前提交后全局回滚时undo_log还能覆盖但数据库的其他连接可能已经读到中间数据最终查问题查了很久。此外事务上下文在HTTP调用链上的传递依赖Seata的拦截器。如果使用了自定义线程池异步调用服务XID不会自动传递需要手动处理。最稳妥的方案是全局事务内不要使用异步线程去执行分支事务因为异步线程丢失XID会导致分支事务变成无主事务前端看着成功后端数据却对不上。4. 生产环境踩坑记录从全局锁超时到回滚失败4.1 全局锁超时的排查思路全局锁等待超时是AT模式最经典的报错错误信息一般是GlobalLockWaitTimeoutException或者Global lock acquire timeout核心异常码是1301。遇到这种报错第一反应不是去看SQL性能而是去看TC端的锁表记录。先定位是哪一行数据被哪个全局事务占着锁。可以在TC对应的数据库里查lock_tableSELECT * FROM lock_table WHERE row_key jdbc:mysql://your-db:3306/business_db:1:order:123row_key的格式由数据源URL、表名、主键值拼接而成。定位到锁定事务后去global_table里看这个全局事务的状态。如果状态是Begin却一直没提交大概率是事务对应的服务挂了或者线程卡死这时候需要人工确认业务状态必要时主动清理这个全局事务的会话释放全局锁。还有一个经常被忽略的原因全局事务打了很大的循环比如在一个方法里循环100次插入操作即使都是在同一张表Seata会为每次分支注册都申请全局锁锁持有时间被拉得很长。这种场景正确的做法是批量插入或者把循环移出全局事务方法只保留真正的核心写操作在事务内。调整锁等待参数也是有必要的。默认的重试间隔和重试次数在低并发下够用但热点数据场景可以适当调大seata: client: rm: lock: retry-interval: 100 retry-times: 30不过参数只是缓解手段本质上如果全局事务长时间不结束再多的重试也没用。全局事务里千万不要做耗时的外部调用比如短信、邮件、支付回调这些操作会让全局锁挂很久拖垮整个链路。4.2 回滚日志相关的坑全局回滚失败也是常见问题核心原因是undo_log里的before_image和数据库当前的数据对不上。举一个真实的情况你的全局事务A更新了订单金额为100生成了before_image原始值50。这时候另一个全局事务B更新了同一个订单金额改成了80并成功提交了。事务A后续失败需要回滚按照before_image执行反向SQL想恢复成50但这个行为会把B的提交结果80覆盖掉。Seata在设计上通过全局锁尽量避免这种交叉但前提是B也走了Seata。如果B服务没有接入Seata直接修改了同一行数据AT模式的保护就会失效回滚时必然产生脏覆盖。遇到这种回滚异常日志里通常会有Rollback failed或者undo_log conflict。排查步骤是先看undo_log里的rollback_info字段用工具反序列化出before_image和after_image再查业务表当前实际值三者对比就能判断是并发覆盖还是undo_log数据异常。另一个和undo_log相关的坑是清理策略。Seata全局事务提交成功后RM会异步删除undo_log。但极少数情况下删除会失败或者事务提交成功后客户端没来得及清理就宕机了导致undo_log表里残留大量过期数据。我建议上线定期任务清理超过一定时限且状态为已提交的undo_log否则表越来越大回滚查询速度会下降。不过清理时一定要确认对应的全局事务已经结束否则可能把未回滚的补偿依据清掉。4.3 性能和事务治理的经验Seata引入的开销是多方面的最明显的是全局锁竞争和额外的SQL解析。有些团队测出Seata性能不好其实是没做事务治理把大事务、长事务、循环事务都塞进了一个全局事务边界里。我总结了几个比较实用的治理原则控制事务粒度。一个全局事务只包含完成业务所必要的最少操作查询、非关键写操作尽量放在事务外。事务内停留时间越短全局锁持有时间越短。热点数据拆分。如果某个账户、某个商品是典型热点行尽量把高并发操作设计成不冲突的粒度比如积分累计改为异步批量入账。数据库连接池要留余量。AT模式下全局事务中每个分支事务会占用一个本地事务连接如果连接池过小等待连接本身就会拉长事务时间。建议核心链路的连接池上限至少比普通链路多30%。TC集群要独立部署。不要和业务应用混部TC的存储用db模式并配置好主从。开启Metrics监控。Seata支持暴露Prometheus指标至少监控全局事务提交数、回滚数、锁等待次数这些指标能帮你早发现事务链路的异常趋势。还有一个容易被忽略的点GlobalTransactional默认只对RuntimeException回滚如果你在方法里捕获了异常并吞掉事务不会回滚。我自己习惯在所有接入Seata的业务方法上显式声明rollbackFor Exception.class并且不要把异常吞掉。一旦发现分支失败应该尽快抛出让全局事务进入回滚流程。4.4 问题排查速查表现象可能原因处理方式全局锁超时热点行被长时间锁定、事务内耗时操作多查lock_table定位持有者缩短事务耗时调大锁重试参数XID丢失跨线程传递未处理、异步调用、拦截器未生效确认调用链上下文传递避免全局事务内异步分支回滚失败并发脏写、undo_log被清理、before_image异常对比rollback_info和业务数据确认参与者都接入了Seata数据源未代理业务SQL没被Seata拦截检查SqlSessionFactory的DataSource是否为DataSourceProxyTC重启丢事务store模式为file切换db模式持久化全局会话版本不兼容客户端和服务端大版本不一致统一升级到同一版本线5. 关于Seata的选型思考与个人体会用了这么多年Seata我最深的感受是分布式事务的本质问题不是框架能解决的框架只是把复杂性收敛到了自己能管住的范围。Seata把常规场景的复杂度降得非常低AT模式下业务代码几乎不用动这是它最大的价值。但我也越来越认同一个观点能用降级方案解决的场景不要轻易引入分布式事务框架。如果业务对强一致没有硬性要求或者可以接受短时间的数据不一致加对账补偿用消息队列的最终一致性方案无论是成本还是稳定性都更优。Seata适合的是那些这笔钱不能错、这个库存不能超卖的核心强一致场景。如果你确定要用Seata我个人的建议是先小范围试点一个非核心链路把全局锁竞争、超时参数、异常处理都跑顺了再推广到核心链路。别一上来就把支付、订单这种关键链路全量接入因为你还没摸清自己系统的并发模型和数据库瓶颈出问题的时候可能同时面对业务故障和框架学习成本。另外Seata版本升级要谨慎。我吃过一次亏从旧版本升到新版本之后TC端和业务端的全局锁存储结构有变化导致in-flight的全局事务全部失败。升级前一定要确认迁移文档最好在灰度环境完整跑一轮回归测试并且保证升级窗口内没有进行中的全局事务。最后分享一个小技巧如果某个接口频繁出现Seata相关的锁等待与其反复调参数不如重新审视这个接口的并发设计。很多时候问题不在Seata而在业务把太多本该串行化的操作放进了同一个高并发入口里。事务是给数据一致性兜底的不是用来承载高吞吐的想明白这一点很多分布式事务的排障思路就会清晰很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑