ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Seata XA模式深度解析:从两阶段提交原理到分布式事务实战

Seata XA模式深度解析:从两阶段提交原理到分布式事务实战 做微服务的同学大概率都遇到过同一个痛点单体应用里一个Transactional就能把多张表锁在同一事务里拆成服务之后订单要落库、库存要扣减、账户要记账三个服务三套数据源任何一步失败数据就花了。这时候就必须引入分布式事务方案而 Seata 是目前国内最主流的中间件之一。今天聊的这颗“、SEATA分布式事务——XA模式分”正是 Seata 提供的四种事务模式中最贴近数据库原生规范、一致性最强的那个——XA 模式。本文我会从 XA 的底层设计出发结合一个订单-库存-账户的实战案例把 Seata XA 模式的原理、落地步骤、参数选型和调试经验一次讲透。无论你是在做技术选型还是已经被分布式事务问题折磨过几天这篇文章都能给你直接能用的参考答案。1. 分布式事务问题域与 Seata 的整体设计1.1 分布式事务到底在解决什么问题先回到最基础的场景。在单体应用里事务的 ACID 特性由数据库自己保证你执行一条 update数据库写 undo log、redo log提交时要么全部生效要么全部回滚这种“本地事务”由单库单机完成逻辑非常清晰。微服务化之后每个服务都有自己的库。下单这个动作涉及订单服务的订单表、库存服务的库存表、账户服务的账户表它们分布在三个独立数据库中没有任何一个数据库能同时管理这三个数据源的提交和回滚。这个时候你调用库存服务扣减成功了但账户服务因为网络超时失败了订单数据已经写入账上钱没扣成高峰期这种问题最容易出现。分布式事务要解决的就是跨服务、跨数据源场景下多个资源操作的原子性。注意这里的关键不只是 ACID 里的原子性还有一致性、隔离性和持久性多个参与者要么全部成功要么全部失败并且过程中不能被其他请求读到中间态脏数据。这就是为什么分布式事务比普通接口调用的“失败重试人工对账”麻烦得多因为分布式事务介入的层面不是业务方法而是资源管理器层面也就是数据库事务层面。1.2 Seata 的三个核心角色与事务模型Seata 把分布式事务抽象成三个角色理解这三个角色后面看任何模式都会清晰很多TCTransaction Coordinator事务协调者独立部署的 Seata Server负责维护全局事务状态、驱动分支事务的提交或回滚。它相当于舞台上的总指挥。TMTransaction Manager事务管理器业务代码里加GlobalTransactional注解的那个服务就是事务发起方。TM 负责向 TC 申请开启一个全局事务并决定最终提交还是回滚。RMResource Manager资源管理器关系型数据库连接、或者消息队列等资源由 Seata 客户端代理接入负责把本地事务注册为例行分支事务向 TC 上报状态并执行 TC 下发的提交或回滚指令。这三者之间的配合逻辑我用一个比较生活化的例子来解释TM 是公司的项目经理TC 是监理方RM 是各个外包团队的负责人。项目经理发起一个项目全局事务通知监理TC登记各团队负责人RM接手自己那部分活分支事务完成后上报监理最后项目经理拍板交付监理根据各个团队的完成情况统一通知大家交付或者全部返工。全局事务会生成一个全局唯一的事务编号 XID从 TM 发起之后这条 XID 会通过 Seata 的拦截器自动传递到下游服务的调用链中。下游服务收到带 XID 的请求就会把本地事务注册到同一个全局事务下面这就保证了业务上的一整条链路能纳入同一个分布式事务边界里管控。1.3 XA 模式在 Seata 四种模式中的定位Seata 提供了 AT、XA、TCC、SAGA 四种模式各模式的取舍方向完全不同AT 模式Seata 的“招牌模式”用一张 undo_log 表记录数据变更前镜像一阶段直接本地提交二阶段通过反向 SQL 补偿。特点是侵入小、性能好但隔离级别较弱。XA 模式基于数据库原生 XA 规范两阶段提交物理层面的强一致适合对数据一致性要求极高、并发量可以接受一定损耗的场景。TCC 模式业务层面的三阶段 Try-Confirm-Cancel需要手写三段代码性能最好但开发量最大。SAGA 模式长事务场景的最终一致性协调方案适用于业务流程长、中间允许补偿的业务。如果把这四种模式排个“从强一致到最终一致、从易用到难用”的序列XA 和 AT 在最两侧XA 靠数据库锁强保证一致性AT 靠补偿机制换取性能。很多新同学一上来就陷入“选哪种模式”的纠结我的建议是如果你追求的是严格的读已提交级别以上的隔离效果且数据库支持 XA那 XA 是逻辑最简单、最不容易出幺蛾子的选择。2. XA 模式原理深度拆解2.1 X/Open DTP 模型与 XA 规范的由来XA 是 X/Open 组织发布的分布式事务处理DTP标准的一部分这个标准定义了分布式事务环境下应用AP、事务管理器TM、资源管理器RM三者之间的交互规范。XA 这个名称其实就是“eXtendedArchitecture”的缩写它在 1991 年推出迄今为止几乎所有主流关系型数据库MySQL、Oracle、PostgreSQL、SQL Server都原生支持。XA 规范最核心的价值是定义了一套用数据库自身能力实现跨库事务的协议而不需要对业务代码做锁、补偿之类的设计。开发的角度上它把“跨库一致性”的复杂度收拢到了数据库层面。XA 事务的执行过程涉及三个角色全局事务管理器负责协调整体资源管理器负责单个数据库节点的事务执行应用负责启动和结束全局事务。MySQL 的 InnoDB 存储引擎从很早的版本就支持 XA 语法你完全可以在命令行手动执行XA START xid、XA END xid、XA PREPARE xid、XA COMMIT xid、XA ROLLBACK xid这一组命令来完成单机上的两阶段提交实验。2.2 一阶段 Prepare二阶段 Commit or RollbackSeata 的 XA 模式本质上是把标准 XA 流程嵌入到微服务调用链里。全局事务开启后每个分支事务的执行流程如下第一阶段的 Prepare预提交分支事务的本地 SQL 正常执行但执行的连接不是普通的 JDBC 连接而是被包装成 XA 连接的连接。在 SQL 执行完成后数据并不会真正提交而是执行一次XA PREPARE把事务分支的执行结果固化到数据库的事务日志中。此时该分支相关的行记录都会加上事务锁其他事务修改同一行数据会被阻塞。Prepare 操作执行成功资源管理器 RM 就向 TC 上报“分支事务已准备完成可以提交”。第二阶段的 Commit / Rollback全局提交或回滚TC 收集到所有分支事务的 Prepare 结果之后如果全部成功就向所有 RM 下发 Commit 指令各个数据库执行XA COMMIT一次性释放锁并对外可见如果任何一个分支 Prepare 失败或者 TC 的全局事务超时就会下发 Rollback 指令所有数据库执行XA ROLLBACK锁立刻释放。因为 Prepare 阶段已经把数据变更固化在数据库内部事务日志中所以二阶段的 commit/rollback 在数据库层面一定会成功不太会出现干到一半丢失状态的情况。这里有一个非常关键的细节XA 模式下数据库的锁是从 Prepare 开始持有一直到全局事务完成才释放。这一点与 AT 模式完全不同AT 的一阶段提交后锁就释放了靠的是全局锁和补偿来兜底XA 则用“锁得久”换“不会错”。这也是 XA 模式在高并发、长事务场景下容易成为瓶颈的根源。2.3 Seata XA 与 AT 模式的核心差异我整理了一张对比表把 Seata 的 AT 和 XA 放在一起看对比维度XA 模式AT 模式一致性强度数据库物理层强一致无中间态逻辑层最终一致一阶段提交后有短暂中间态隔离级别由数据库事务锁保证默认读已提交使用全局锁控制写冲突读未提交级别参与方所有数据库必须支持 XA 协议需要额外建 undo_log 表对数据库类型要求较低一阶段行为执行 SQL XA PREPARE不提交执行 SQL 本地提交同时记录 undo_log二阶段行为全部成功则 Commit任一失败则 Rollback干净利落全部成功则删除 undo_log失败则拿 undo_log 反向补偿性能特征锁持有时间长并发下降明显一阶段快速释放锁性能较好代码侵入性只需数据源代理 注解只需要数据源代理 注解典型适用场景交易、账务、强一致性要求高大多数互联网业务性能敏感AT 模式的核心功臣是 undo_log 表。它在本地事务提交前把数据修改前后的镜像记录下来二阶段需要回滚时用镜像反向执行一次 SQL。这样数据库锁的持有时间被压到了本地事务范围内性能自然好。但代价是在另一个事务并发访问被 AT 事务处理过的数据时如果不加全局锁可能读到还没补偿完成的中间数据所以 Seata 为这个场景设计了全局锁机制。XA 则完全没有这个问题因为在 XA 分支事务 Prepare 之后锁就在数据库层面稳稳卡住了其他事务连读都会受到一致性约束这种强一致是物理级别的不是业务代码层面玩出来的花活。3. Seata XA 模式落地实操3.1 环境准备Seata Server、依赖和数据库动手之前先把环境搭建好。我用的是 Seata 1.6.1 配合 MySQL 8.0这是一个经过大范围生产验证的稳定组合。第一步是启动 Seata Server。从 Seata 官方 GitHub 的 release 页面下载 server 安装包解压后可以看到bin、conf目录。1.6.1 版本推荐使用 Nacos 作为配置中心和注册中心你也可以用 file 模式减少依赖。我为了演示简单用的是 file 模式主要改conf/registry.confregistry { type file loadBalance RandomLoadBalance loadBalanceVirtualNodes 10 } config { type file }修改好后在bin目录执行启动脚本# Linux/macOS sh seata-server.sh -p 8091 -m file # Windows seata-server.bat -p 8091 -m fileSeata Server 默认监听 8091 端口正常启动后日志里出现Server started说明协调者已经就绪。第二步是确认数据库版本。XA 模式要求数据库原生的 InnoDB 引擎支持 XA 事务语义MySQL 5.7 以上的 InnoDB 表都可以MyISAM 之类非事务引擎不能参与。还需要确认 JDBC 驱动支持 XA 连接我用的 MySQL Connector/J 8.0.x 没有问题。第三步是在业务服务中引入依赖。如果你是 Spring Boot 项目直接在pom.xml中加入dependency groupIdio.seata/groupId artifactIdseata-spring-boot-starter/artifactId version1.6.1/version /dependency然后在application.yml里做基础配置seata: enabled: true application-id: order-service tx-service-group: my_test_tx_group registry: type: file config: type: file注意tx-service-group对应的事务分组多服务之间必须保持一致这是 TM 和 RM 加入同一个全局事务的必要条件。3.2 数据源改造XAConnection 是核心有个新手特别容易踩的坑就是以为加了GlobalTransactional就能用上 XA 模式结果数据源还是普通DataSource。实际上 Seata 的 XA 模式要求对数据源做一次代理包装用 Seata 提供的DataSourceProxyXA把原来的物理数据源包一层。以 Spring Boot Druid 为例配置一个代理数据源的BeanConfiguration public class DataSourceConfig { Bean ConfigurationProperties(prefix spring.datasource.druid) public DataSource dataSource() { return new DruidDataSource(); } Bean public DataSourceProxyXA dataSourceProxy(DataSource dataSource) { return new DataSourceProxyXA(dataSource); } }DataSourceProxyXA内部做了什么它会在获取数据库连接时把普通连接包装成XAConnection后续每次 SQL 都会自动触发 XA 事务流程连接获取时绑定当前全局事务的 XID事务执行结束后自动执行XA PREPARE。这个包装过程对业务代码完全透明你该写 JPA、MyBatis 照常写。这里有一个特别重要的细节全局事务里每个分支服务的数据源都要用DataSourceProxyXA代理。如果订单服务代理了、库存服务忘了代理那库存服务的本地事务注册不进全局事务一旦订单回滚了库存已经扣掉数据照样不一致。3.3 全局事务注解与分支事务的编写方式数据源代理到位后业务代码的改动量其实很小。事务发起方在入口方法上加GlobalTransactionalService public class OrderService { Resource private StorageClient storageClient; Resource private AccountClient accountClient; Resource private OrderDao orderDao; GlobalTransactional(rollbackFor Exception.class, timeoutMills 300000) public void createOrder(OrderDTO orderDTO) { // 1. 本地订单库插入订单记录 orderDao.create(orderDTO); // 2. 调用库存服务扣减库存RM 参与全局事务 storageClient.deduct(orderDTO.getProductId(), orderDTO.getCount()); // 3. 调用账户服务扣减余额RM 参与全局事务 accountClient.debit(orderDTO.getUserId(), orderDTO.getAmount()); } }库存服务和账户服务的实现长这样Service public class StorageServiceImpl implements StorageService { Resource private StorageDao storageDao; Transactional(rollbackFor Exception.class) public void deduct(String productId, Integer count) { int rows storageDao.deduct(productId, count); if (rows 0) { throw new BusinessException(库存不足); } } }你可能注意到了分支服务里没有加GlobalTransactional而是用的普通Transactional。这是因为 Seata 的全局事务上下文 XID 会通过 RPC 调用链自动传递分支服务只要检测到当前线程绑定了 XID就会在自己的Transactional事务执行后自动把本地事务注册到全局事务中。这个逻辑由 Seata 客户端的拦截器在框架层面完成业务代码不需要关心。我建议在服务间调用时尽量使用 OpenFeign、Dubbo 这类 Seata 已经做了内置拦截器的框架它能自动传递 XID。如果是自己封装的 HTTP 客户端就得手动把 XID 塞到请求头里否则下游服务感知不到全局事务这是排障时最容易忽略的盲区。3.4 关键配置参数解析Seata XA 模式的相关配置项比较多这里挑几个直接影响事务行为的重点说明timeoutMills全局事务超时时间GlobalTransactional注解里的timeoutMills是全局事务的最长执行时间默认一般是 60 秒。如果全局事务运行时间超过这个值TC 会直接判定超时并触发失败回滚。这里要特别提醒由于 XA 模式在 Prepare 之后一直持有数据库锁超时时间如果设得太短一个耗时长的业务链路比如调用外部接口、排队等待很容易被误杀设得太长数据库端积累的锁等待又可能导致大面积连接阻塞。我的经验是先设一个 3 到 5 分钟的上限结合压测实际情况往下调。客户端与服务端的通讯超时这类参数在seata-spring-boot-starter的默认配置里都有合理默认值一般不需要动。但如果你的业务服务调用链特别长、分支事务特别多建议调大seata.client.rm.reportRetryCount事务执行结果上报重试次数避免网络抖动导致的“误判失败”。连接池大小XA 模式下数据库连接从 Prepare 阶段到二阶段结束会一直被占用。这个特性意味着你的连接池大小直接决定全局事务的并发上限。假设每个全局事务有 3 个分支每个分支在 Prepare 后会占 1 个连接数据库连接池总共 20 个那能同时并发执行的全局事务最多也就 6 个左右。所以如果你的业务并发量较高需要适当调大数据库连接池大小并关注 Seata Server 所在机器的连接数限制。4. 实战订单-库存-账户三服务分布式事务4.1 业务场景与服务拆分设计为了讲清楚 Seata XA 模式的完整落地我用一个电商下单场景来做演示。整个业务链路涉及三个独立的微服务和三个独立数据库order-service订单服务负责创建订单记录是全局事务的发起方。storage-service库存服务负责扣减商品库存如果库存不足抛出业务异常。account-service账户服务负责扣减用户账户余额余额不足则抛出业务异常。这三个服务各连各的库订单库里有order_info表库存库里有storage表账户库里有account_info表。通过一次模拟的下单请求我希望能达到的效果是订单、扣库存、扣余额要么全部成功要么全部回滚绝不会出现“订单建了、库存扣了、余额没扣”这种脏数据。4.2 分支服务的数据源与事务配置三个服务的数据源配置方法完全一样都需要用DataSourceProxyXA包装物理数据源。以账户服务为例Configuration public class AccountDataSourceConfig { Bean ConfigurationProperties(prefix spring.datasource) public DataSource accountDataSource() { return DataSourceBuilder.create().build(); } Bean public DataSourceProxyXA accountDataSourceProxy(DataSource accountDataSource) { return new DataSourceProxyXA(accountDataSource); } }账务服务的 Mapper 接口Mapper public interface AccountDao { Update(UPDATE account_info SET balance balance - #{amount} WHERE id #{userId} AND balance #{amount}) int debit(Param(userId) Long userId, Param(amount) BigDecimal amount); }注意这里 update 语句里加上了balance #{amount}条件如果余额不足受影响行数为 0业务层就能据此抛出异常。这种写法既完成了扣减又借助数据库层面条件判断避免了并发超扣是非常实用的做法。账户服务实现类中我保留了Transactional注解Service public class AccountServiceImpl implements AccountService { Resource private AccountDao accountDao; Transactional(rollbackFor Exception.class) public void debit(Long userId, BigDecimal amount) { int rows accountDao.debit(userId, amount); if (rows 0) { throw new BizException(账户余额不足); } } }4.3 全局事务的发起与回滚验证订单服务的createOrder是全局事务的入口之前已经贴过代码。现在着重说说回滚验证。我启动三个服务后做了一组手动测试。正常路径请求创建一个订单下单商品的库存是 100 件账户余额是 2000 元订单金额是 100 元。调用接口后依次查询三张表订单表多了一条记录库存表变为 99余额变为 1900全程没有任何报错数据全部提交成功。库存不足路径把下单数量改成 1000 件库存只有 100 件触发库存服务里“库存不足”的业务异常。此时再去看订单表发现订单记录并不存在账户余额也没有发生变化说明全局事务把所有分支全部回滚了。这个场景是最能直观感受 XA 模式价值的订单和账户两个分支都已经执行了 Prepare如果用的是没有强一致保证的方案这里大概率会留下脏数据。余额不足路径将账户余额改为一个低于订单金额的数值触发账户服务抛出“账户余额不足”异常。同样订单表没有新增记录库存表的数量也没有变化。正是因为 XA 的 Prepare 阶段把库存扣减锁在了数据库事务里全局回滚时 Release 就是一次完整的XA ROLLBACK不需要像 AT 模式那样去反算补偿 SQL。4.4 性能压测与并发观察XA 模式一致性强代价是并发能力受限。我在这套三服务环境上做了一轮简单压测用 50 个并发线程连续下单对比同一套逻辑在 AT 模式和 XA 模式下的 TPS 和平均响应时间。结果 XA 模式下的 TPS 大约是 AT 模式的 55% 到 70%平均响应时间涨了约 50%。原因很直观XA 分支事务在 Prepare 之后库存行、账户行上的锁要等到全局事务结束才释放高并发竞争同一行数据时后面的请求只能排队等待前一个全局事务提交。这说明 XA 模式不适合“秒杀”这类对单行数据并发修改极其密集的场景。但如果你的业务是账务对账、订单创建这类对一致性要求高、并发竞争没那么极端的情况XA 模式带来的强一致收益远大于性能损失。选型的时候一定要先评估自己的业务锁竞争程度再决定上不上 XA。5. 常见问题与排查技巧实录5.1 高频问题速查表我在调 XA 模式的过程中陆续整理了一批高频问题这里以表格形式放出来方便你直接对照排查现象可能原因处理建议全局事务不生效只提交不回滚数据源未用DataSourceProxyXA代理检查分支服务数据源是否全部使用 XA 代理报错XAER_NOTA或XAER_RMFAIL分支事务在 Prepare 后长时间未提交数据库连接被回收或连接超时增大全局事务超时时间调整数据库连接池超时参数分支事务长时间挂起数据库锁越积越多全局事务超时时间设置过长或某个分支服务调用耗时太长精简业务链路合理设置timeoutMills给分支服务配置熔断降级服务间调用后下游事务没有加入全局事务XID 传递失败可能是自定义 HTTP 客户端没有传递 XID 请求头使用 OpenFeign/Dubbo 等已支持 XID 传递的框架或手动传递 XID 头高并发下连接池耗尽XA 分支连接在 Prepare 后一直被占用调大连接池上限必要时合并小事务减少分支数量回滚后发现部分数据丢失或回滚不完整普通Transactional与全局事务嵌套使用隔离级别冲突保证分支服务只注册为一个分支事务避免内层重复开启代理事务启动报DataSource被重复代理多个配置类同时注册DataSourcebean只保留一个数据源配置类排除 Spring Boot 自动装配的数据源5.2 常见问题速查表的技术细解上面表格里的问题有不少需要进一步展开。比如XAER_NOTA这个错误MySQL 官方文档里对应的是“XID 未知”出现这个错误意味着数据库端找不到与之匹配的 XA 分支事务状态。常见触发场景是全局事务执行时间过长事务协调者超时把全局事务判成失败或者数据库连接池检测到连接空闲时间过长主动断开了连接当 TM 下发二阶段提交指令时数据库已经不认识这个 XID 了。我建议遇到这个错误时第一件事不是去翻代码而是去 Seata Server 的控制台查这个全局事务的执行耗时和状态变化同时看数据库端的SHOW ENGINE INNODB STATUS输出确认是否存在大量鎖等待或连接被回收的迹象。再比如连接池耗尽问题。XA 模式锁的是数据库行但长期占用的其实是应用侧到数据库的连接。一个典型的 50 并发压测场景如果连接池配置了maximum-pool-size20很可能在压测刚开始几十秒就触发 “Connection is not available, request timed out” 报警。这个问题的解法不是无脑调大连接池而是先减少并发链路里不必要的分支事务数量。如果某个分支只是查询操作完全不需要加入全局事务就不应该把它设计成全局事务的参与者。5.3 实操心得与避坑指南接下来分享几个我在实际项目中磕出来的经验这些细节很少写在官方文档里但往往比框架本身更容易绊倒人。第一XA 模式千万不要做长事务。SQL 执行本身可能只需要几毫秒但如果业务代码里在网络调用、消息发送、甚至Thread.sleep上花了几百毫秒那么整个链路的数据库锁都要陪着等。有一次我们把全局事务边界从订单创建一直延伸到发货通知结果发现库存行锁阻塞了大量前端加购请求。后来把发货通知从全局事务里踢出去改成事务成功后通过消息队列异步触发锁竞争瞬间就缓解了。第二自定义线程池时XID 需要手动传递。Seata 的 XID 默认绑定在线程的 ThreadLocal 里。如果你在全局事务执行过程中用了ExecutorService提交异步任务子线程拿不到父线程的 XID也就不会参与全局事务。解决办法是在提交任务前手动绑定String xid RootContext.getXID(); executor.submit(() - { RootContext.bind(xid); try { // 子线程中的分支事务操作 } finally { RootContext.unbind(); } });第三分支服务不要随意使用REQUIRES_NEW传播级别。这个传播级别会挂起当前事务并开启一个独立的新事务导致 Seata 误判把同一个分支事务拆成两个或者干脆注册不到全局事务里。团队里一致约定全局事务链路里的分支服务统一使用默认的REQUIRED传播级别。第四XA 模式的分支服务要设置合理的重试和熔断策略。一旦网络抖动导致分支事务上报失败TC 会在二阶段下发回滚但这时分支事务可能其实已经成功数据库端执行 Rollback 后数据本身没有损失。可如果因为重试机制导致同一笔业务被重复处理反而会产生脏数据。所以全局事务接口建议做好幂等设计比如用订单号做唯一索引避免重复请求落地。第五监控数据一定要有。Seata Server 自带的控制台能查看全局事务列表和分支事务状态但生产环境最好把 Seata 的指标接入 Prometheus 或者日志采集系统。重点监控几个指标全局事务完成数、回滚数、平均执行耗时、分支事务持有的连接数。这些指标能在事务还是“有点慢”的阶段就暴露问题而不是等数据库连接被拖垮之后才去找原因。最后总结一下我对 XA 模式定位的理解它不是性能最优的分布式事务方案但它是正确性最容易保证的方案。如果项目里数据库种类统一、都支持 XA业务链路不长、并发竞争可控那 Seata XA 模式的工程复杂度其实是四种种模式里最低的因为一致性完全由数据库原生机制兜底不像 TCC、SAGA 那样依赖业务代码写补偿逻辑。反过来如果你已经在用 Seata 的 AT 模式并且被时不时冒出来的“脏读”“全局锁冲突”问题困扰那就值得认真评估一下迁移到 XA 模式的成本——改动量往往只需要换一个数据源代理类事务注解和业务代码完全不用动。从我踩过几次坑之后的经验看分布式事务选型的核心不是追逐“热门方案”而是先想清楚业务到底能不能接受补偿中间态想清楚这一点XA 和 AT 之间的选择基本就不会犹豫了。
返回列表