尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

微服务分布式事务:Seata原理与SpringCloud整合实战

微服务分布式事务:Seata原理与SpringCloud整合实战 做微服务时间不短的人大概率都遇到过这种场景订单服务下单成功了库存服务扣减却失败了然后用户下单页面弹个异常数据库里订单孤零零躺在那里库存原封不动。你说这算下单成功还是失败两边数据库不在同一个事务里本地事务管不着别的服务的数据这就是分布式事务问题的典型现场。“SpringCloud进阶”里绕不开的一道坎就是Seata。Seata是阿里开源的一套分布式事务解决方案主打一个“业务无侵入”靠GlobalTransactional一个注解就能把跨服务的数据一致性串起来。这篇我把Seata的原理拆开讲透再配合SpringCloud Nacos Seata的完整整合过程和避坑记录适合已经能熟练使用SpringCloud、但一碰到分布式事务就头皮发麻的同学。看完之后你至少能回答清楚Seata是怎么实现分布式事务的AT模式内部到底做了什么生产环境又该怎么配怎么排查1. 分布式事务到底卡在哪一个订单请求引发的连锁问题先看一个最常见的业务链路用户下单前端调用订单服务订单服务往订单表插入一条记录然后通过Feign调用库存服务扣减库存再调用积分服务加积分。如果这三个操作都成功自然皆大欢喜问题是任何一个环节失败另外两个已经成功的操作怎么办单体架构下不存在这个问题因为订单表、库存表、积分表都在同一个数据库里包在一个本地事务中任何一个SQL失败整条回滚。但微服务化之后订单数据在订单库、库存数据在库存库物理上已经是多个数据库了Transactional只能管住当前服务自己的数据源根本覆盖不到其他服务的数据库。跨服务的数据一致性本地事务彻底无力那就需要一种机制把多个服务里的多个数据库操作编排成一个“逻辑上的大事务”。这个大事务要么全成功要么全回滚让调用方感知到的结果是一致的。这就是分布式事务要解决的问题。1.1 为什么传统的“补偿”方案治标不治本很多团队早期搞分布式事务第一个想到的方案不是中间件而是“人工补偿”。订单服务先下单如果库存扣减失败再调一个“撤销订单”的接口把订单删掉或者改成无效状态。听起来可行实际跑起来问题一堆。比如撤销订单的接口本身也可能失败失败了怎么办补偿的调用链路可能又依赖其他服务形成一个“补偿的补偿”的递归问题。再比如下单成功、扣库存也成功、加积分失败这时候要撤销订单、回补库存还要保证和用户看到的页面状态一致这个状态机的复杂度非常可观。人工补偿方案不是说完全不能用而是维护成本极高链路越多越难控制出了问题排查链路长、定位困难。所以业界一直在找更通用的、能自动保证一致性的方案Seata就是这类方案的典型代表。它的思路是把分布式事务的协调工作抽出来做成一个独立的中间件业务代码只管自己的本地事务全局的提交/回滚决策交给Seata的协调者来做。1.2 从CAP到BASE分布式事务的核心思路转变要理解Seata为什么是这么设计的得先搞清楚分布式系统的两个基础理论CAP和BASE。CAP理论说一个分布式系统在一致性Consistency、可用性Availability、分区容错性Partition tolerance三者之间最多只能满足两个。分布式系统没法避免网络分区所以P是必须保留的那剩下的就是在C和A之间做取舍。事务本来追求的是强一致性C但跨服务的强一致性势必影响可用性因为所有参与方必须同步锁定资源直到全部提交。所以大多数分布式事务方案放弃了一部分强一致性转向BASE理论Basically Available基本可用、Soft state软状态、Eventually consistent最终一致性。先保证基本可用允许系统存在中间不一致的软状态通过某种机制最终达到一致。Seata的AT模式本质上就是一条“最终一致性”路线但在某些层面又尽量做到了接近强一致的体验。它用的是两阶段提交的变种第一阶段把本地事务正常提交掉第二阶段根据全局结果决定是提交还是回滚。是不是听着有点绕下面具体拆解。2. Seata整体架构与核心角色一张图记住三个关键词Seata的架构说复杂也复杂说简单也简单核心就是三个角色加一个交互模型。我先用一句话概括TM告诉TC“我要开一个全局事务”RM告诉TC“我这边参与了一个分支事务”TC负责记台账、做决策最后通知所有人“提交还是回滚”。TCTransaction Coordinator事务协调者独立部署的Seata Server。维护全局事务和分支事务的状态处理提交/回滚决策是Seata的大脑。TMTransaction Manager事务管理器嵌入在发起全局事务的微服务中。负责开启全局事务、提交或回滚全局事务。业务里那个GlobalTransactional注解就是TM在起作用。RMResource Manager资源管理器嵌入在参与事务的各个微服务中。负责管理分支事务的资源数据库向TC注册分支报告分支执行结果。2.1 一次完整的Seata全局事务交互流程用订单-库存的场景走一遍你就能看清这三个角色怎么协作。订单服务TM收到下单请求TM向TC申请开启一个全局事务TC生成一个全局事务IDXID返回给TM。注意这个XID非常重要它要通过Feign调用的调用链一路传递下去所有参与的服务都得知道自己属于哪个全局事务。订单服务执行本地SQL向订单表插入一条记录。此时RM上场它拦截到这条SQL做了三件事一是解析SQL生成执行前后的数据镜像二是生成一条undo_log记录到本地库三是向TC注册一个分支事务然后才提交本地事务。注意此时订单数据已经真正提交到数据库了这是AT模式和传统两阶段提交最大的区别第一阶段不放锁、不阻塞。订单服务通过Feign调用库存服务时XID会被传递过去。库存服务收到XID后同样执行自己的本地SQL、生成镜像、注册分支、提交本地事务。等所有分支事务都执行完TM向TC发起全局提交或回滚的请求。TC检查所有分支的状态如果都成功了就通知所有RM“可以提交”如果有任何一个分支失败就通知所有RM“需要回滚”。回滚的时候RM就靠undo_log里的数据镜像把数据库恢复到执行前的状态。如果全部成功RM只需要异步删除undo_log记录不影响业务。2.2 为什么把“决策”和“执行”分开能解决问题你可能会问这不就是两阶段提交吗Oracle里也有XA事务为啥不直接用XA传统XA的问题在于它是个“阻塞式”的两阶段提交。第一阶段prepare时所有参与的资源必须全部就绪并且锁定有一个不响应就只能一直等直到超时。这个等待过程会长时间锁住数据库资源并发一高就完蛋。Seata的做法是把“决策”和“执行”解耦第一阶段每个服务自己提交自己的本地事务不长时间持锁决策由TC在全局层面完成第二阶段根据决策做回滚。这样第一阶段不锁库第二阶段提交时也不需要锁库只有真正回滚时才动数据。这套机制让它在高并发下的表现远好于传统XA这也是业界普遍认为Seata更适合微服务场景的根本原因。3. AT模式原理深挖二阶段提交、镜像与全局锁Seata支持多种事务模式AT是“Automatic Transaction”的缩写也是目前用得最多、最无侵入的模式。它所谓的“无侵入”指的是业务代码不需要为了分布式事务做任何改造只需要在最外层方法上加一个GlobalTransactional注解实际上还是要加一个注解的准确说是“业务逻辑几乎无侵入”。AT模式有三大支柱二阶段提交协议、数据镜像、全局锁机制。这三者缺一不可。3.1 AT模式的两阶段到底在做什么先细化AT模式的一阶段。假设库存表里有条数据id100, stock10库存服务执行UPDATE storage SET stock stock - 1 WHERE id 100。RM拦截这条SQL先查询该行的当前数据生成before image快照一然后执行更新SQL再查询更新后的数据生成after image快照二接着把SQL语句、before image、after image组装成一条undo_log记录插入库存库的undo_log表然后向TC注册分支事务最后提交本地事务。整个一阶段只保证“本地事务成功”并不关心其他服务是否成功。二阶段分两种结果全局提交和全局回滚。全局提交时RM收到TC的通知异步删除undo_log记录即可。全局回滚时RM读undo_log用after image校验当前数据是否被其他事务改过如果没改过就用before image反向生成一条补偿SQL把数据改回去如果已经被改过就需要人工干预处理。校验机制是AT模式保证数据不被脏写的关键也是我后面要讲的全局锁的用途之一。3.2 before image与after image回滚的“后悔药”如果不理解镜像的作用你就会疑惑回滚的时候直接把数据改成执行前不就行了为什么要费劲生成两份镜像答案是回滚必须基于“当前状态”反推而不是死板地执行一条反向SQL。比如库存从10扣到9如果直接反向执行SET stock stock 1万一这期间别的服务又扣了1次库存变成8你再1就变成9了实际应该是10数据就错了。所以正确做法是回滚前先用after image比对当前数据确认中间没有被其他事务修改过只有确认一致才能用before image覆盖回去。如果发现不一致说明有并发写冲突AT模式会抛出异常由人工或兜底任务处理。3.3 全局锁AT模式如何防脏写、防超卖说完镜像再说全局锁。AT模式里每条被分支事务修改的数据都会在TC的lock_table里对应一条锁定记录这个锁就是全局锁。全局锁的作用很简单防止两个不同的全局事务同时修改同一行数据。事务A修改了库存行在提交全局事务之前锁记录一直存在事务B也想改同一行去TC申请全局锁时发现被A持有只能等待。A提交后释放锁B才能继续。这样做的直接效果是多个分布式事务并发操作同一数据时不会出现脏写。但代价是锁等待会增加RT。如果A这个全局事务执行时间很长B就会被卡住很久直到超时异常。这也是我在后面“问题排查”里要重点说的一个性能隐患。AT模式的读隔离默认是“读未提交”也就是说事务B虽然不能写同一行但它是可以读到A还没提交的数据的。因为A的一阶段已经把数据真实提交到本地库了其他事务用普通SELECT就能读到。要真正做到“读已提交”需要业务在查询语句里加SELECT ... FOR UPDATE并配合GlobalLock注解但这很少用绝大多数场景读未提交是可接受的。关于这一点面试时经常被追问要能说清楚。4. SpringCloud Nacos Seata 整合实操从零到跑通一个订单扣库存原理讲再多跑不起来等于零。下面进入实操环节我用一套SpringCloud Alibaba Nacos Seata的完整整合过程带你把环境搭起来、把订单-库存的分布式事务跑通。先说明一下技术选型注册中心和配置中心都用Nacos因为SpringCloud Alibaba体系里Nacos是标配Seata也原生支持Nacos做注册和配置链路最简单没有额外依赖。4.1 版本选型这一步错了后面全是泪Seata的版本坑非常多我见过太多人整合失败最后发现是版本不兼容。这里直接给一组我验证过、比较稳的搭配适用SpringCloud Alibaba 2023.x系列。组件推荐版本Spring Boot3.2.xSpring Cloud2023.0.xSpring Cloud Alibaba2023.0.1.0Nacos Server2.3.2Seata Server2.0.0Seata Client2.0.0如果你用的是SpringCloud Alibaba 2021.x那批旧项目也可以参考这组搭配Spring Boot 2.6.x Spring Cloud 2021.0.x Spring Cloud Alibaba 2021.0.5.0 Seata 1.6.1 Nacos 2.1.x。思路是一样的只是坐标版本不同。注意Seata 2.x和1.x在配置项上有不少差异比如2.x把配置项迁移到了application.yml不再用file.conf。下面的配置以Seata 2.0.0为准如果你仍在用1.x请对应调整。4.2 启动Seata Server并注册到Nacos先去Seata官网下载Seata Server 2.0.0的压缩包解压之后做两件事配置注册中心和存储模式。Seata 2.x的配置在conf/application.yml里主要改nacos注册相关配置server: port: 7091 seata: registry: type: nacos nacos: application: seata-server server-addr: 127.0.0.1:8848 group: SEATA_GROUP namespace: username: nacos password: nacos config: type: nacos nacos: server-addr: 127.0.0.1:8848 group: SEATA_GROUP namespace: >sh seata-server.sh -p 8091 -h 127.0.0.1 -m db -n 1-p是监听端口-h是注册到Nacos的IP千万不要用127.0.0.1去生产注册客户端连不上。-n是节点ID集群模式下每个节点要唯一。启动之后去Nacos控制台看服务列表应该能看到seata-server这个服务里面有default集群下的一个实例。看到这步说明Seata Server已经成功入驻Nacos。4.3 每个微服务接入Seata Client依赖、配置、数据源三步走现在看订单服务和库存服务各自要做什么。第一步引入依赖。在pom.xml里加dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId /dependency这个starter会带上Seata Client的依赖版本跟随Spring Cloud Alibaba的BOM管理不需要自己指定版本。第二步配置事务分组。订单服务和库存服务的application.yml里都要加spring: application: name: order-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 seata: enabled: true application-id: ${spring.application.name} tx-service-group: my_test_tx_group registry: type: nacos nacos: application: seata-server server-addr: 127.0.0.1:8848 group: SEATA_GROUP namespace: username: nacos password: nacos config: type: nacos nacos: server-addr: 127.0.0.1:8848 group: SEATA_GROUP namespace: >Configuration public class SeataDataSourceConfig { 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 factoryBean new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSourceProxy); return factoryBean.getObject(); } }同时还要在启动类上排除Seata自己的自动数据源配置否则会重复包装SpringBootApplication(exclude {DataSourceAutoConfiguration.class, SeataAutoConfiguration.class}) public class OrderServiceApplication { public static void main(String[] args) { SpringApplication.run(OrderServiceApplication.class, args); } }提示如果你用MyBatis-Plus这个配置类同样适用只要保证SqlSessionFactory使用的是DataSourceProxy就行。4.4 在业务代码里加一个注解GlobalTransactional环境就绪后业务代码的改动少得让人怀疑人生。订单服务创建订单的方法上加一个注解Service public class OrderService { Resource private OrderMapper orderMapper; Resource private StorageFeignClient storageFeignClient; GlobalTransactional(name create-order, rollbackFor Exception.class) public void createOrder(OrderDTO order) { // 1. 本地插入订单 Order record new Order(); record.setUserId(order.getUserId()); record.setProductId(order.getProductId()); record.setCount(order.getCount()); record.setStatus(CREATED); orderMapper.insert(record); // 2. 远程扣减库存 storageFeignClient.deduct(order.getProductId(), order.getCount()); // 3. 模拟其他业务 // otherService.doSomething(); } }库存服务的deduct方法正常写不加Transactional以外的特殊注解。Feign调用时Seata的拦截器会把当前XID放进请求头库存服务收到后自动取出并绑定到当前事务上下文。这个过程对业务透明你不需要手动传递。跑起来之后验证一下正常下单查订单表、库存表数据都对。然后故意让库存服务抛出异常再下一单你会发现订单表里没有新增记录——因为全局回滚把订单插入也撤掉了。这就是Seata最直观的效果跨服务的事务边界被真正打通了。4.5 别忘了在每个业务库建undo_log表AT模式的回滚依赖undo_log表这个表必须建在每个参与分布式事务的业务库中。哪个服务要参与Seata事务就在它的数据库里执行这个SQLCREATE 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 CHARSETutf8mb4;漏建这张表的症状很典型一阶段SQL执行正常数据能插入但一触发回滚就报Table xxx.undo_log doesnt exist。这个错在测试环境极易出现因为很多测试库是从老库克隆的根本没建过这张表。别问我怎么知道的问就是踩过。5. 常见问题与排查技巧实录我踩过的坑希望你一个都别踩Seata这套东西原理理解之后跑通不难真正让人头疼的是那些现身生产环境后才发现的问题。我把这些年遇到的高频问题整理成一份速查表每个都附上排查思路。5.1 全局事务不生效数据回滚不掉这是最常见的“假成功”问题。代码里加了GlobalTransactional日志里也能看到XID但库存服务抛异常后订单数据没被回滚。排查步骤看日志里有没有Global transaction is not active之类的话如果有说明XID没有传到当前服务先查Feign的拦截器有没有生效大概率是Seata依赖没引入完整。看库存服务的调用链确认RM有没有向TC注册分支事务。打开库存服务的日志搜register branch如果没有说明数据源代理没生效检查DataSourceProxy配置。确认异常没有被吞掉。如果你在createOrder方法里用try...catch把异常捕获了没有重新抛出TM以为事务成功了自然不会回滚。GlobalTransactional的回滚前提是异常向外抛出触发二阶段回滚决策。5.2 全局锁等待超时Global lock wait timeout这个报错出现时往往并发量已经上来了。两个全局事务同时操作同一行数据后到的那个一直等不到锁超过重试次数后直接抛异常。Seata里和全局锁相关的两个参数是client.rm.lock.retryInterval默认10ms和client.rm.lock.retryTimes默认30也就是说默认情况下一个事务等锁的时间大概300ms。并发高时这300ms根本不够用可以适当调大重试次数比如调到100但治标不治本。根治思路是减少锁的持有时间把全局事务里的远程调用、慢SQL、非必要操作全部挪到事务外面尽量缩小GlobalTransactional包裹的业务范围如果某个热点数据并发极高考虑换一种一致性方案比如把库存扣减做成消息或者用RedisLUA预扣而不是死磕数据库行锁。5.3 TC报channel inactive客户端连接异常Seata Client要跟TC保持长连接如果一段时间没有交互某些网络环境下连接会被中间设备断开。Seata的Netty通道有重连机制但偶尔会出现重连不及时表现就是日志里一堆channel inactive甚至直接报can not connect to services-server。常见原因Seata Server所在机器防火墙没放行8091端口客户端和服务器的Nacos注册信息不一致客户端拿到的TC地址不对服务器配置了-h 127.0.0.1导致客户端连的是本机回环地址而不是真实IP。排查优先看客户端日志里连接的首选地址是什么对比Seata Server的注册IP不一致就停掉服务改完启动参数再试。5.4 回滚失败数据不一致但没报警最危险的问题往往不是报错而是不报错。AT模式回滚前会用after image比对当前数据如果发现当前数据已被其他事务修改回滚就会失败。我这个场景遇到过订单服务和积分服务同时修改同一条用户余额数据A事务回滚时发现B事务已经改了余额无法直接用before image覆盖Seata会标记这个分支的回滚失败。遇到这种情况千万别手动去改数据库。正确做法是查TC里的branch_table状态结合undo_log里的rollback_info判断是否真的产生了不一致然后根据业务语义决定怎么补偿。这属于极端情况概率不高但一旦出现就要有人盯着处理。5.5 性能问题Seata模式下RT明显上升AT模式一阶段要做SQL解析、镜像生成、undo_log写入二阶段提交要删undo_log再加上全局锁的等待开销RT比普通事务高是正常的。但如果高得离谱优先排查业务库和Seata Server的存储库是不是同一个库是的话拆开互相干扰严重。每个大事务里是否包含了多次远程调用每次调用都在延长全局锁的持有时间。数据库连接池大小是否够用。Seata一阶段需要额外的事务连接做undo_log操作连接池配置小了容易拿不到连接。有个调参经验如果业务允许把client.rm.reportSuccessEnable设为false可以减少RM与TC的通信次数。但这个要看版本支持情况2.0有相关开关老版本没有。5.6 面试视角Seata常被追问的几个点顺便照顾一下在准备SpringCloud面试的同学。关于Seata高频问题基本集中在AT模式和XA、TCC的区别重点说XA是阻塞式两阶段、TCC需要业务实现try/confirm/cancel、AT靠镜像和undo_log自动补偿。Seata的全局锁隔离级别默认读未提交要读已提交得配合SELECT FOR UPDATE。事务分组是怎么路由到TC的通过tx-service-group和vgroupMapping找到集群名再通过注册中心找到具体TC实例。Seata能保证强一致吗严格说不能它是最终一致性模型只是在大多数场景下把不一致窗口缩得很小。回答这些问题的本质仍然是你能不能在原理层面把“TM、TC、RM怎么协作”完整讲清楚。原理通了面试题就是送分题。6. 分布式事务并不是只有Seata选型之前先想清楚写了这么多Seata的用法最后必须泼一盆冷水Seata不是所有分布式事务问题的银弹它在解决一类问题的同时也引入了新的复杂度。具体项目里分布式事务的解决方案至少还有本地消息表、事务消息、TCC、最大努力通知这几类。本地消息表和事务消息适合“异步最终一致性”场景核心思路是把“业务操作”和“发消息”放在同一个本地事务里再靠消息队列异步通知下游下游消费成功就完事消费失败就重试。订单创建后发一条“订单已创建”消息库存服务消费消息扣库存这就不用全局锁性能开销小很多。TCCTry-Confirm-Cancel适合需要强控制、并发要求极高的场景但每个参与方都要实现三个方法业务侵入非常强对开发要求也高。Seata的TCC模式我一般不建议新手直接用除非你对资源冻结和最终一致性有非常清晰的建模能力。选型时我的判断标准很简单能异步就异步别为了技术炫技强上Seata必须同步且强一致、分支不多、链路不长优先考虑Seata AT模式如果链路极长或性能敏感考虑消息补偿兜底。分布式事务的终局不一定是某一个框架很多高并发系统是“Seata管核心链路、消息队列管非核心链路、定时任务做最终对账”混着来的。根据我个人经验Seata的AT模式适合在业务初期快速解决分布式事务一致性问题但上线后一定要盯着全局锁的等待时间和TC的负载。真正要长期运维的反而是那些不用Seata也能靠幂等对账兜底的异步链路——少依赖一个强一致中间件系统的可用性上限往往更高。
返回列表