ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Seata分布式事务实战:四种模式原理与生产落地解析

Seata分布式事务实战:四种模式原理与生产落地解析 我最早开始认真研究Seata是因为一次让我凌晨爬起来救数据的线上事故。在单体应用里下单这件事很单纯扣库存、扣余额、写订单在同一个数据库事务里要么全成要么全不执行。可一旦把系统拆成订单服务、库存服务、账户服务这三个动作就散到了三台机器、三个数据库里原本数据库自己就能保证的原子性一下子没人管了。那晚我对着对不上的库存和账户余额第一次真正理解了什么叫分布式事务。后来我花了大量时间把Seata的AT、TCC、SAGA、XA四种模式逐个在项目里滚了一遍今天这篇东西就是想把Seata从头到尾讲清楚——它到底解决了什么问题、内部是怎么设计的、实际部署时有什么坑以及你在什么场景下应该选哪种模式。1. 微服务拆完之后扣钱为什么突然变难了1.1 从本地事务到跨库事务边界到底碎在哪在讲Seata怎么解决问题之前得先把问题本身描述清楚。单体应用时代所有业务数据都在一个库里事务边界和业务边界天然重合。你执行UPDATE account SET balance balance - 100 WHERE id 1数据库通过锁和日志保证这条语句要么生效、要么完全不影响根本不给你出错的机会。微服务改造后事务边界变了。按钮提交之后订单服务要往order库写一条记录库存服务要往stock库扣一笔数量账户服务要往account库减一个金额。三个库各自独立各管各的锁和日志。你无法保证账户扣款成功之后库存扣款失败时能撤回账户那边已经生效的修改。更麻烦的是跨服务调用本身可能超时——账户服务执行成功了但响应报文在网络上丢了订单服务这边看到的是超时它到底是重试还是回滚重试可能造成重复扣款回滚又可能把已经成功的扣款取消掉。这就是分布式事务的本质难题你需要在多个独立的资源管理器数据库、消息队列等之间维持一种在单机上天然的原子性。Seata做的就是把本地事务的经验升维成全局事务的协调机制下文拆开细说。1.2 为什么Spring的Transactional管不了跨库的事很多刚接触分布式事务的人第一反应是我在Service方法上继续加Transactional不行吗这里要区分两个概念——本地事务注解管的是当前应用连接的那个数据源你让它管多个库它做不到。Spring事务抽象的核心是PlatformTransactionManager默认的DataSourceTransactionManager把事务绑定到单一连接上跨库就是跨两个连接两个连接无法共享同一个事务上下文。你当然可以用JTAJava Transaction API走XA协议让数据库自己参与两阶段提交但这会引入两个实际问题一是XA事务周期内数据库资源被长时间锁住高并发场景下吞吐量骤降二是很多连接池比如早期的Druid版本对XA连接的管理并不完善处理不好连接归还的时机。我在项目里看到过不少团队勉强上了JTA最终被全局锁拖垮的例子。1.3 分布式事务的几种失败场景先建立直觉为了避免后面看Seata原理时一头雾水先归纳一下分布式环境下典型的失败情形场景现象直观后果部分成功账户扣款成功库存扣款失败多扣了用户的钱账面不平调用超时库存服务实际成功但响应丢失下单端显示失败库存被扣记录却存在回滚落空订单服务尝试回滚但其他服务已提交全局不一致需要人工对账修复重复提交重试机制触发了两次扣款用户被重复扣款产生资损并发错乱两个全局事务互相读到中间态数据出现脏读业务数据错乱Seata的价值不在于让这些情况不发生而在于当它们发生时有机制把这些分支的最终状态重新拉回到一致线上。理解Seata最关键的是理解它用什么样的代价换取了这种全局一致性下面从它最核心的三组件架构入手。2. 三组件各司其职TC、TM、RM怎么串起全局事务2.1 三个角色的职责划分Seata把分布式事务的参与者划分成三个角色名字很有误导性第一眼看容易记混TCTransaction Coordinator事务协调者独立部署的服务端进程负责维护全局事务的状态记录分支事务的注册信息驱动全局提交或全局回滚。它是一个中心裁判的角色所有分支事务的推进都要通过它。TMTransaction Manager事务管理器业务代码里的发起方。它负责开启全局事务、决定最终提交还是回滚。TM本身不做数据操作只是对TC发号施令我要开始一个全局事务了这个全局事务整体提交/回滚。RMResource Manager资源管理器数据操作的参与者。每个要纳入全局事务的微服务里都有一个RM模块它负责管理分支事务的状态向TC注册分支并接收TC的提交/回滚指令真正操作本地数据库事务。打个生活化的比方TC是裁判TM是队长RM是队员。队长TM向裁判TC申请一场比赛全局事务队员RM每做一步操作都要向裁判报备我这边准备好了发现有人犯规时队长喊停裁判根据报备信息挨个通知队员你的动作无效撤销。2.2 从开启到结束一场全局事务的完整时间线以经典的下单扣库存扣余额为例假设调用链路是订单服务TM→ 账户服务RM1→ 库存服务RM2实际时序如下订单服务的TM向TC发起GlobalBeginTC生成一个全局事务IDXID返回给TM。TM在后续的远程调用中把XID放到RPC调用的上下文里Seata对Dubbo和Spring Cloud都做了透明传输。账户服务的RM1收到带XID的请求先在本地执行UPDATE account SET balance balance - 100 WHERE id 1同时向TC注册分支事务本地事务提交时RM1会额外记录一个undo_log用于后续回滚。库存服务的RM2同样执行本地SQL注册分支事务。订单服务里的TM执行完所有业务逻辑后向TC发起GlobalCommit。TC核对所有分支事务的状态如果都成功就通知所有RM执行BranchCommitRM各自提交本地事务AT模式下其实本地事务在步骤3/4已经提交了TC只负责清理全局锁如果有分支失败TC通知所有RM执行BranchRollbackRM读取undo_log做补偿回滚。TC把全局事务状态改为Finished。你仔细看会发现一个关键点在AT模式下分支事务的本地数据库提交发生在全局提交之前也就是我们常说的先本地提交后全局仲裁。这是Seata和传统2PC最大的差异也是它能扛住高并发的根本原因下一章展开。2.3 Seata与经典2PC两阶段提交的异同经典XA式的两阶段提交第一阶段先问所有参与者能不能提交所有参与者回答能之后第二阶段再让所有人真正提交。这个方案的缺陷很明显从第一阶段到第二阶段之间所有参与者持有的数据库锁必须一直不释放。如果某个参与者迟迟不响应全局事务就僵在那里锁也一直占着高并发下数据库连接和锁资源很快就耗尽了。Seata的AT模式做了改进——一阶段直接提交本地事务并释放本地锁把回滚需要的信息提前记录在undo_log里二阶段只有在需要回滚时才去做补账。这本质上是把先锁后提交的强同步模型优化成了先提交后补偿的最终一致模型。所以说Seata更像业务层面的补偿方案是有道理的这一点在业务模型上非常关键。3. AT模式的无侵入真相全局锁、镜像数据和两阶段执行3.1 执行SQL前后RM到底做了什么手脚AT模式最吸引人的卖点是无侵入——业务方不需要把SQL改造成XA风格的语句不需要写TCC那样的大量补偿方法只需要在业务表旁边加一张undo_log表然后照常写UPDATE、DELETE、INSERT就行。但这个无侵入背后藏了一整套自动化的逻辑我按一条UPDATE的完整经过来说明。假设库存表的原始数据是id1, stock100库存服务收到一个扣减库存的分支请求执行SQL时RM的动作顺序如下解析SQL生成WHERE条件对应的查询把要修改的行前镜像查出来。这条UPDATE执行前RM会先执行一条基于主键或唯一索引的SELECT stock, id FROM stock WHERE id 1得到stock100。执行业务SQL也就是真正执行UPDATE stock SET stock stock - 1 WHERE id 1此时数据库落库后stock变成99。查询后镜像再次SELECT同一行得到stock99。把前镜像和后镜像组织成一行数据连同XID、分支事务ID一起插入到undo_log表中。向TC注册分支事务请求获取全局锁。注册获锁成功提交本地事务。这套流程里最关键的一点是拿到全局锁之前RM不会提交本地事务。如果拿不到全局锁本地事务就会一直挂着直到异常或超时。3.2 回滚不是执行反向SQL而是比对镜像做反操作很多人会误以为Seata回滚时是把UPDATE反过来执行一次。实际上它的机制比这复杂也比这可靠得多。当TC判定全局事务需要回滚并通知RM执行BranchRollback时RM会做这几件事根据XID和分支ID找到对应的undo_log记录取出前镜像和后镜像。校验当前数据库行的数据是否等于后镜像。这一步叫脏写校验。如果数据等于后镜像说明这行数据从分支事务提交后没有被其他事务动过此时RM直接用前镜像的数据覆盖回当前行。如果数据不等于后镜像说明有别的全局事务或本地事务改了这行直接覆盖会造成脏写。此时RM会报告异常给TC使全局事务进入无法自动回滚的状态只能靠人工或手工脚本去处理。这个校验逻辑非常重要。它保证了Seata的回滚不是闭眼执行反向操作而是基于快照的精确还原。很多分布式事务框架说最终一致到底怎么一致其实就是靠这一对镜像数据把数据精确恢复到事务开始前的状态。3.3 全局锁到底是什么锁它怎样避免脏写上一节提到ATC组件里还有一个全局锁的概念。全局锁不是数据库的行锁而是Seata TC服务端维护的一张内存/数据库层面的锁表锁的粒度是数据库名 表名 主键值。为什么需要它考虑一个并发场景全局事务A改了stock表中id1这行已提交本地事务但还没收到TC的全局提交通知此时全局事务B也要改同一行。如果B不去校验A的存在B的本地事务直接提交后提交的B就会覆盖A的修改。如果后续A通知TC回滚A的RM发现当前数据与自己的后镜像不一致回滚就会失败。更麻烦的是B也有可能回滚二者互相覆盖数据最终状态完全不可控。有了全局锁之后流程变成A注册分支并持有了stock/1这行的全局锁B尝试修改同一行时也要申请全局锁申请不到就进入重试等待。A的全局事务结束无论提交还是回滚之后TC释放全局锁B才能继续。所以全局锁的引入本质是把全局事务级别的并发控制从数据库层提升到了协调者层。这里有个必须明确的取舍全局锁是串行化的瓶颈。两个全局事务如果操作同一行热点数据比如爆款商品的库存后面的那个事务会一直等待。因此使用AT模式时热点行的更新吞吐会明显下降这是分布式事务解决的代价不是框架缺陷。3.4 undo_log表的结构和建表要点AT模式回滚依赖undo_log表如果表结构不对或字段不兼容回滚时会直接报错。标准建表SQL各家文档里都有但有几个点在实际建表时很容易踩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, ext varchar(100) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY ux_undo_log (xid, branch_id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4;这里有三个细节是文档不强调但我实际用下来很影响体验的rollback_info一定要用longblob。前镜像和后镜像包成BranchUndoLog对象后要序列化成二进制存储用text类型在数据量大时容易截断。一旦回滚时读取的镜像不完整脏写校验直接失败。xid和branch_id要建联合唯一索引。这是Seata查分支记录时最常用的索引路径不建的话回滚查询会全表扫描数据量大时很恐怖。必须设置log_status字段的更新逻辑。正常未使用的记录是0回滚完成后置为1。如果回滚中途宕机log_status为0的残留记录就是你需要人工对账的线索来源。我甚至见过有人用定时任务去清理status1的记录避免表无限膨胀。4. 不只ATTCC、SAGA、XA三种模式什么场景换什么思路4.1 TCC模式把事务拆成Try、Confirm、CancelTCCTry-Confirm-Cancel是另一种在业务层实现分布式事务的模式它没有undo_log的自动镜像机制而是要求业务方自己实现三个方法。以账户服务为例Try预留资源。比如扣款场景先把用户余额中的100元冻结到冻结额度字段但不真正扣减可用余额。冻结操作的SQL不会让可用余额变化因此多个Try并发执行时不会互相覆盖。Confirm真正执行。Try成功后把冻结的100元转为扣减操作可用余额。Cancel撤销预留。把Try阶段冻结的100元释放回可用余额。TCC的优势在于没有全局锁完全靠业务手段做隔离因此吞吐量比AT高同时每个分支事务只操作自己的数据不会有镜像校验这种额外的存储和延迟开销。但它的工程量很大每个涉及分布式事务的业务方法都要写三个实现还要保证Confirm和Cancel的幂等性。我见过有些团队只在核心支付链路用TCC外围业务一律AT就是因为它对开发量的消耗实在不小。4.2 SAGA模式面向长流程的编排式事务SAGA模式的思想是大事务拆小步每步有对应的补偿。Seata的SAGA实现采用的是状态机加JSON配置的方式——把业务流程定义成一个状态机每一步动作对应一个Service方法同时配置一个反向的补偿Service方法。执行到第3步失败时状态机会逆向调用第2步和第1步的补偿方法。SAGA特别适合跨多个微服务、流程链条长、每个环节耗时高的场景比如订单流程拆成创建订单、锁定库存、清关、通知物流等。它没有全局锁也没有Try阶段的资源预留因此对业务数据几乎没有额外约束。但它的最终一致性窗口更长事务运行期间中间步骤已经对业务可见如果用户在此期间查询订单可能会看到订单已创建但库存还没锁定这种中间态。长流程系统对这种中间态要有容忍度或者兜底方案。4.3 XA模式让数据库原生事务参与全局仲裁Seata也支持标准XA协议这时的RM角色用的是支持XA的DataSource比如XAConnection。一阶段执行XA分支的prepare二阶段由TC统一commit或rollback。好处是隔离性最强完全交给数据库保证坏处是持锁周期长和2PC一样存在资源占用的问题。在我的经验里XA模式在Seata里使用比例远低于AT一般只用在无法通过undo_log做补偿、又必须保证强一致的老旧系统迁移场景。如果你的数据库本身不支持XA比如某些版本的MySQL或PG这条路就走不通。4.4 四种模式怎么选我刚经历过的几个真实场景选择Seata模式最核心的判断是业务对隔离性和吞吐量的要求。模式隔离性吞吐量侵入性适用场景AT全局锁保证写隔离中等低通用业务特别是数据库操作较多、不想改业务代码TCC业务预留实现隔离高高并发极高、资源敏感的支付/交易链路SAGA无隔离中间态可见高中长流程、多步骤、对强一致要求较低XA数据库原生强一致低低存量系统对接、强一致硬要求我做过一个电商平台下单主链路用的AT因为订单、库存、账户三张表的操作都很简单而且不想让团队为每个接口写三套TCC方法。后来碰到一个促销秒杀活动库存放在Redis预扣、数据异步回源MySQL这种场景AT的全局锁扛不住热点流量我换成了TCC——Try阶段预扣Redis库存Confirm阶段回源数据库扣减Cancel阶段释放Redis预扣。效果立竿见影。所以这里给个建议先画清业务链路的并发模型再定模式不要因为AT省事就所有链路一把梭。5. 从零落地Seata Server部署与Client配置的完整记录5.1 Seata Server的部署形态与配置选择Seata的TC协调者是一个独立Java服务叫seata-server。你可以在GitHub官方仓库的Release页面下载二进制包当前主流稳定分支推荐1.6.x或1.8.x按你项目依赖的兼容性来定。部署上我建议容器化用Docker把seata-server跑起来配置中心用Nacos注册中心用Nacos如果你的微服务治理体系就是Nacos这样TC实例可以水平扩展不至于单点故障。核心配置在conf/application.yml里关键项是server: port: 7091 seata: registry: type: nacos nacos: application: seata-server server-addr: 127.0.0.1:8848 namespace: public group: SEATA_GROUP config: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: public group: SEATA_GROUP store: mode: db db: datasource: druid db-type: mysql driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/seata?useSSLfalseserverTimezoneAsia/Shanghai user: seata_user password: your_password这里有个以前坑过我的点store.mode默认是file事务状态存在本地文件里TC重启后状态恢复逻辑比较脆弱。生产环境我强烈建议改成db模式让TC把全局事务状态落到数据库至少TC挂了重启后还能恢复未完成的事务。配套的global_table、branch_table、lock_table三张表建表SQL在seata-server/script/server/db/mysql.sql里直接执行即可。5.2 Client接入时的核心配置项Client端就是你的各个微服务接入Seata需要加依赖和配置。以Spring Cloud项目为例首先要引入dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId version对应你的Spring Cloud版本/version /dependency然后每个参与全局事务的微服务都要配置seata: enabled: true application-id: account-service tx-service-group: my_test_tx_group registry: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: public group: SEATA_GROUP config: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: public group: SEATA_GROUP这里最容易出问题的是tx-service-group事务分组。事务分组的逻辑是客户端根据tx-service-group的名字到配置中心查该分组对应的TC集群名vgroupMapping.my_test_tx_group再通过这个集群名去注册中心找到具体的TC实例。配置路径在Nacos的config.txt里如果你用的是默认配置需要确保所有服务端的vgroupMapping配置一致否则客户端会报no available server to connect。5.3 一个最简单的Spring Boot接入Demo下面是一个订单服务调用账户服务扣款的伪代码示例帮助你理解使用方式Service public class OrderService { Autowired private AccountFeignClient accountFeignClient; GlobalTransactional(name create-order-tx, timeoutMills 300000) public void createOrder(OrderDTO order) { // 本地业务操作比如插入订单表 orderDao.insert(order); // 远程调用账户服务扣款XID会通过RPC上下文自动传递 accountFeignClient.decreaseBalance(order.getUserId(), order.getAmount()); // 如果这里抛出异常全局事务会自动回滚账户服务的扣款也会被补偿 } }你只需要在入口方法上加GlobalTransactional注解然后像写本地事务一样写业务逻辑。Seata会拦截这个方法TM在进入时开启全局事务方法结束时根据有没有抛出异常决定GlobalCommit还是GlobalRollback。注意一个细节账户服务内部一定是加Transactional进行本地事务处理但全局事务的开启只由入口方法控制。我曾经见过有人把Transactional和GlobalTransactional混在一起定义导致本地事务提前提交分支事务注册时报错。标准做法是GlobalTransactional标注在总门面方法上内部各个服务的Transactional只管自己的本地事务。6. 生产环境里踩过的几个坑按排查链路逐一还原6.1 undo_log表建表后插不进去数据字符集在作祟有段时间我们账户服务固定报错日志里显示插入undo_log时Data truncation: Data too long for column rollback_info。第一反应是镜像数据太大可我们的更新语句明明只涉及一行三个字段不可能超长。后来排查了半天发现问题出在字符集上——建表时某次工单执行漏掉了DEFAULT CHARSETutf8mb4表用了latin1中文数据或者序列化结果转码后长度膨胀触发了截断错误。这个问题的排查链路没有任何技术含量但很典型先看异常信息定位到列再查表结构。建议直接把建表SQL固化在项目的初始化脚本里不要在工单系统里复制粘贴一劳永逸。6.2 热点库存行导致的全局锁超时与脏读秒杀场景下所有用户抢同一件商品的库存AT模式的全局锁让并发流量在TC的锁表上排队。有一次压测全局锁等待超时设置了20秒但其中一波慢请求把锁占住了后续请求全部等超时事务集体回滚。更严重的是超时后TM发起了回滚但另一个分支事务还在等锁回滚指令未能下发导致全局事务状态一直悬挂。处理思路是区分两个层面一是热点行不要放在AT模式下硬扛要么拆库存到多个子条目每行库存量小的库存桶把热点行从1行拆成100行全局锁冲突自然分散要么这个接口直接改成TCC预扣。二是全局锁超时时间要重新评估不是越长越好超时太长会拖死TC线程建议结合P99延迟设置通常15到30秒合理。6.3DataTransactionId未传递导致分支事务自成一派在Dubbo调用链路里如果provider服务没有被正确接入Seata的FilterXID就传不到下游服务下游RM不知道自己在全局事务里会单独提交本地事务。表面上看接口响应正常数据也不报错但全局事务实际只剩上半段数据不一致问题会延迟出现。排查方法很直接打开seata日志看下游服务的分支注册记录是否存在。正常情况下每个RM都会打印Registering branch with xid日志如果没有这行就检查依赖有没有引入、Dubbo Filter有没有配置。我在Spring Boot 2.x项目里就遇到过spring-cloud-starter-alibaba-seata版本和seata-all版本不一致导致Filter未生效的问题解决方法是统一BOM版本管理。6.4 回滚失败且脏写校验不过时怎么办脏写校验失败是AT模式最棘手的问题。出现这个情况的常见链路是全局事务A先提交全局事务B后提交A在提交前发现自己的后镜像和当前数据不一致于是回滚失败。日志里会看到UndoLogManager rollback failed和dirty data字样。这里有一个我实践下来的经验先不要急着用补偿SQL去改数据先看undo_log表里残留记录的rollback_info里面存了完整的前镜像人工可以据此判断当前的正确值应该是什么。然后定位是谁造成了脏写是不是有一个不在全局事务控制范围内的本地事务改动了这行数据。如果是必须把那个本地事务也纳入全局事务否则类似问题会反复出现。虽然Seata支持lock-retry-interval和lock-retry-times这种自动重试参数但真正干净的做法是让所有对这张表的写操作都纳入事务框架不能有漏网的本地写。6.5 多环境多分组导致的TC实例错配很多项目有dev、test、prod三套环境Nacos的分组或命名空间没有隔离好导致两个环境的seata-server注册到了同一个分组里。客户端在dev环境发起全局事务时TC分配的实例可能指向的是prod的库日志里全是跨库的连接错误甚至出现dev环境的回滚操作写进了prod的undo_log。这个问题几乎都要靠配置治理从根上解决Nacos的namespace按环境区分seata-server的registry.nacos.namespace和客户端保持一致tx-service-group的名字要带环境后缀比如dev_tx_group、prod_tx_group。排查时先看客户端日志里Get available server list导航到的是哪个TC地址基本能手眼定位。写在最后用Seata这几年我最大的体会是分布式事务没有一个包治百病的方案Seata的价值在于它给了你四种取舍路径让技术选型能跟着业务形态走。AT模式用镜像和全局锁换开发效率TCC用业务编码换吞吐SAGA用最终一致性换长流程能力XA用数据库强一致换灵活性。如果你刚开始接触我建议先在一个非核心链路上把AT模式完整跑通亲手制造一次回滚、看一眼undo_log再去做多模式选型。分布式事务的很多坑并不在框架本身而在于使用方对回滚机制和锁模型的理解深浅把原理吃透了踩坑的次数会直线下降。
返回列表