资讯动态

Sharding-JDBC与MyBatis-Plus整合XA分布式事务的完整实践与排坑

发布时间:2026/10/14 19:43:39 来源:尧图企业网站定制
做分库分表做得越深越会发现一个让人睡不着觉的问题原来一个Transactional就能保证原子性的操作拆到多个库之后事务直接就“散”了。这个系列前面几篇已经把分片策略、读写分离、绑定表这些基础过了一遍真正在项目里落地到关键业务时分布式事务才是最后的拦路虎。这篇就写写我在项目里整合 Sharding-JDBC 与 MyBatis-Plus用 XA 协议把跨库事务擦干净的完整过程——从原理到代码再到排坑一次说透。XA 在分布式事务里算元老级方案思路很直观让每个分片数据库自己参与事务表决最后由一个总协调者拍板提交还是回滚。它跟 Seata AT、本地消息表这些方案不是一回事取舍也完全不同。这篇不会吹哪个方案天下第一只把 XA 适用的场景、代码长什么样、实际踩过的坑讲清楚。你如果正准备把单体单库的项目往分片结构上迁移这章内容可以直接拿去做技术预演。1. 分库分表之后一个事务怎么就管不住了1.1 单库时代的本地事务为什么到了分片场景会失效在只有一个数据库的时候本地事务的表现大家都熟开启事务、执行几条 SQL、全部成功就 commit中间任何一步失败就 rollback。数据库内部用 undo log 和 redo log 保证原子性和持久性整个过程是单机自洽的开发者只需要在业务方法上盖一个Transactional剩下的交给数据库处理即可。这种模式的根基是“所有要写的数据都在同一个数据库实例里”。一旦做了分库分表比如订单表t_order按订单号取模拆到ds0和ds1两个库里场景马上变了。一笔下单操作可能在ds0里插入一条订单去ds1里扣掉用户的账户余额。两个库之间没有共享事务上下文本地事务只能保证自身这一个库里的操作原子性没法让另一个库跟着一起提交或回滚。我见过很多第一次做分库分表的同学代码里已经把注解换成分布式事务专用注解了但心里并不明白为什么原来的方案不管用。其实一句话就能说清本地事务管的是“一个库里的多行数据”分布式事务要管的是“多个库之间的多行数据”后者需要一个跨实例的协调者单靠数据库自身的本地事务机制根本无法完成。1.2 两个分库同时写入出问题时的真实表现这里用一个实际例子来看问题到底有多严重。假设业务是“创建订单 扣减库存”订单按order_id取模路由到ds0库存则路由到ds1。正常路径下服务先往ds0插入订单成功再往ds1更新库存两条 SQL 都成功数据没问题。但如果在ds1更新库存时抛出了一个运行时异常本地事务只能回滚ds1上这条更新操作ds0上的订单插入已经被上一次 commit 落盘了。结果就是订单存在库存没扣两边数据不一致。在下游对账时这单怎么都对不上。系统刚上线时流量小这种偶发不一致很难被发现。一旦流量上来失败概率被放大一天几百笔脏数据流进数据库靠人工修数据根本不现实。这就是必须要引入分布式事务的直接原因不是框架想复杂而是数据模型变复杂了事务的边界必须跟着变复杂。1.3 XA 协议的两阶段提交到底在做什么先说结论XA 是一套数据库层面的分布式事务规范核心操作分两个阶段。第一阶段叫准备阶段全局事务协调者通知所有参与的数据库“准备提交”每个数据库写完 redo 日志、把事务置为就绪状态后向协调者回复 yes 或 no第二阶段叫提交阶段协调者根据所有人的准备结果决定统一 commit 还是统一 rollback。如果所有分片都回复 yes协调者就广播 commit每个数据库完成各自的本地提交。只要有任意一个分片回复 no协调者就广播 rollback所有分片把事务回滚。关键逻辑在于参与表决的每个分支只有两种结果全成或者全败没有第三种可能性这正是 XA 能提供强一致的原因。这套机制并不神秘MySQL 从 5.7 开始对 XA 的支持已经比较可靠Oracle、PostgreSQL 也有成熟的 XA 实现。Sharding-JDBC 做的是把这套协议包装成普通的 Java 事务接口让上层业务代码尽量不用关心底层细节。需要留个心眼的是XA 的准备阶段会一直持有数据库资源锁从 prepare 到最终 commit 这个窗口期内锁不会被释放所以事务执行时间绝对不能拉长这是后面排坑的重灾区。2. 分布式事务方案那么多为什么先谈 XA2.1 XA 和 Seata AT、本地消息表的本质差异现在分布式事务方案很多最常被拿出来对比的是 Seata AT、本地消息表可靠事件和 XA。三者经常被混在一起讨论但解决问题层级完全不同。本地消息表的核心思路是“把跨库操作拆成可以逐步推进的本地操作”主业务先写本地库和消息表再异步通知下游处理本质上是最终一致性方案。Seata AT 更接近补偿型方案它通过代理数据源记录 undo_log业务直接提交本地事务如果后续事务失败回滚阶段用 undo_log 生成反向 SQL 补偿数据性能比 XA 好但要维护 undo_log 表且对 SQL 有解析限制。XA 是真正的“数据库原生参与两阶段提交”不需要额外建日志表代码侵入度也低。它保证的是刚性的原子性事务提交那一刻所有分库要么全部能读到新数据要么全部读不到。想把这类边界对齐到“钱款入库后立即可以查询”这种强一致场景XA 是最干净的工具。代价是事务过程中锁的持有时间更长并发吞吐要打折扣。三种方案并不是谁完全替代谁的关系。实际项目里常见做法是核心资金、库存这类强一致链路用 XA异步通知、积分累计这类允许短时间延迟的链路用本地消息表或 Seata AT。看项目的正确姿势是先把一致性需求分等级再挑方案而不是追着某个框架的名字跑。2.2 Sharding-JDBC 里的 XA 事务管理器是怎么接管的Sharding-JDBC 自己没有重写 XA 协议它做的是把事务管理器的角色和数据库资源串起来。在 Spring 环境下事务控制类拿到 ShardingDataSource 的一个连接当业务调用触发事务开启后Sharding-JDBC 内部会把路由过程中实际命中的每个物理分片库都注册成全局事务的 XA 分支。这里面有一个容易忽略的点同一个事务里的多条 SQL 可能路由到不同的分片但整个事务上下文必须绑定在当前线程里。Sharding-JDBC 用类似TransactionHolder的机制保存当前线程的事务状态保证同一条业务链路先后访问多个分片时各个分支都被划进同一个全局事务编号下最后统一提交或回滚。在具体实现上Sharding-JDBC 默认选择了 Atomikos 作为底层的 XA 事务管理器。Atomikos 在 Java 领域是一个比较老牌的 JTA 实现负责生成全局事务编号、维护两阶段提交的状态、处理崩溃恢复。开发人员不直接接触 Atomikos 的 API而是在方法上写DSTransactional或Transactional由框架自动把事务控制转发给 Atomikos。理解这一层之后配置和排错会容易很多代码里看到 XA 日志、看到 Atomikos 字样都是正常现象。另外注意版本差异早期 3.x、4.x 版本里 Sharding-JDBC 提供的是DSTransactional注解配合ShardingTransactionType(TransactionType.XA)到 Apache ShardingSphere 5.x 之后事务注解统一为 Spring 的Transactional再加上ShardingTransactionType指定事务类型。网上资料很多是旧版本写法如果你在 5.x 里还抄DSTransactional大概率编译直接挂这点在整合时最先确认清楚。3. 整合 mybatis-plus从依赖到代码一次说清3.1 pom 依赖要进哪些版本坑有哪些项目基础是 Spring Boot MyBatis-Plus分库分表走 Sharding-JDBC。先说旧版本 3.x 的依赖组合下面这段配置是我在几个老项目里实际用过的dependency groupIdio.shardingsphere/groupId artifactIdsharding-jdbc-spring-boot-starter/artifactId version3.1.0/version /dependency dependency groupIdio.shardingsphere/groupId artifactIdsharding-transaction-xa-spring-boot-starter/artifactId version3.1.0/version /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency如果你用的是 Apache ShardingSphere 5.x则改成下面的坐标组合dependency groupIdorg.apache.shardingsphere/groupId artifactIdshardingsphere-jdbc-core/artifactId version5.3.2/version /dependency dependency groupIdorg.apache.shardingsphere/groupId artifactIdshardingsphere-transaction-xa-core/artifactId version5.3.2/version /dependency这里要特别提醒两点。第一MyBatis-Plus 的版本和 Spring Boot 版本必须匹配Boot 3.x 需要引入mybatis-plus-spring-boot3-starter否则启动时直接报循环依赖或找不到 SqlSessionFactory第二Sharding-JDBC 旧版 starter 会传递引入一些老版本的 druid、guava 依赖如果和项目里其他模块冲突轻则启动容器校验失败重则运行时路由错乱我在一个项目里就因为guava版本被顶上去了导致某个分片算法计算结果和预期不一致排查了大半天。建议引入完后在 Maven 依赖树里过一遍冲突情况别偷懒。3.2 分片配置里暗藏的事务要点基础的分片配置文件长这样spring: shardingsphere: datasource: names: ds0,ds1 ds0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.10:3306/order_0?useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 ds1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.11:3306/order_1?useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 sharding: binding-tables: t_order,t_order_ext tables: t_order: actual-data-nodes: ds$-{0..1}.t_order table-strategy: inline: sharding-column: order_id algorithm-expression: t_order_$-{order_id % 2} props: sql.show: true这个配置本身不复杂但从 XA 事务的角度看有几个点比分片规则本身更重要。首先是连接池参数。XA 事务从 prepare 阶段开始占用连接一直要持续到最终 commit 或 rollback期间连接不能归还给连接池。如果maximum-pool-size配得偏小并发事务稍一上来就会把连接池抽干。其次是事务超时。XA 阶段如果某个分片执行慢整个事务会被拖住其他分片的锁也全部不释放。建议把事务超时时间调到合理范围不要让慢 SQL 无限期占用链路。第三点是binding-tables绑定表一定要配否则两个分片表做 join 时会有笛卡尔积路由一次查询会多出很多无效的分片访问事务场景下这些访问虽然不需要全部进入 XA 分支但网络开销会明显放大事务耗时。还有一个配置项容易被忽略事务管理器类型。5.x 里可以通过spring.shardingsphere.props.xa-transaction-manager-type: Atomikos显式指定不配置时各版本默认值不同。项目如果部署在容器环境里xa-transaction-manager-type写成 Default 会去探测容器提供的 JTA 实现没有容器事务支持时会莫名走成别的路径直接显式写死 Atomikos 最省心。3.3 代码里的注解怎么打边界在哪里先看 Mapper 层。因为我用的是 MyBatis-Plus实体和 Mapper 走的是标准模板方式代码量很少Data TableName(t_order) public class OrderDO { TableId(type IdType.ASSIGN_ID) private Long orderId; private Long userId; private BigDecimal amount; private Integer status; }Mapper public interface OrderMapper extends BaseMapperOrderDO { }这里刻意把主键策略设置成ASSIGN_ID也就是雪花算法。分库分表场景下千万别用数据库自增主键两个库各自维护一套自增序列必撞车。MyBatis-Plus 3.x 默认配置下用ASSIGN_ID就能生成全局唯一 ID不需要额外引组件。重点在 Service 层的事务写法。旧版本 Sharding-JDBC 支持这样写Service public class OrderServiceImpl implements OrderService { Resource private OrderMapper orderMapper; Resource private AccountMapper accountMapper; DSTransactional ShardingTransactionType(TransactionType.XA) Override public void createOrder(OrderDO order, Long accountId, BigDecimal amount) throws Exception { orderMapper.insert(order); AccountDO account new AccountDO(); account.setAccountId(accountId); account.setBalance(amount.negate()); accountMapper.deductBalance(account); // 模拟异常观察两个分库是否都回滚 throw new RuntimeException(做一个 XA 回滚测试); } }新版本 Apache ShardingSphere 5.x 则建议改用 Spring 原生事务注解Transactional(rollbackFor Exception.class) ShardingTransactionType(TransactionType.XA) Override public void createOrder(OrderDO order, Long accountId, BigDecimal amount) throws Exception { orderMapper.insert(order); AccountDO account new AccountDO(); account.setAccountId(accountId); account.setBalance(amount.negate()); accountMapper.deductBalance(account); throw new RuntimeException(做一个 XA 回滚测试); }两个版本的共同要点是一致的事务注解必须打在public方法上且是通过 Spring 容器注入的 Bean 调用不能是类内部this.xxx()自调用。自调用会绕过 Spring 代理事务注解形同虚设。还有个常见问题是方法内部把异常用 try/catch 吞掉再正常返回这种情况下事务管理器认为方法执行成功自然不触发回滚。排错时先确认异常有没有抛出到事务边界之外。事务方法内部也不建议提交线程池异步任务XA 的事务上下文是绑定在线程上的异步线程拿不到当前事务两个分库的操作不会加入同一个 XA 分支最终该回滚的没回滚。4. 实测验证 XA 事务是否生效以及路上的坑4.1 模拟异常看两个分库是不是真的都回滚了代码写完不等于事务一定生效必须做验证。我的标准验证步骤是这样的先把ds0和ds1里的数据状态记下来然后调用createOrder接口在方法末尾故意抛异常最后再查两个分库里的数据是否都保持原样。如果事务生效日志里会有一段很明显的 XA 交互过程。旧版 Sharding-JDBC 环境下控制台能看到类似XA start、XA end、XA prepare、XA rollback的日志关键字Atomikos 也会输出协调过程。如果只在ds1上看到回滚ds0没有就说明事务没有覆盖到所有分片优先检查事务注解是否打在入口方法上、方法有没有被正确的代理对象调用。还有一个测试细节值得说验证查询时最好带上分片键去查比如select * from t_order where order_id ?。如果不带分片键Sharding-JDBC 会做全分片广播查询两个库里都去扫一遍业务倒是能查到结果但你没法和具体分片对应容易误判。带分片键的查询可以直接定位到确切分片库排查时效率高很多。4.2 并发上来之后连接池先被 XA 拖垮这个坑我印象特别深。有个上线后的服务单事务跑得好好的一压并发就报“HikariPool-1 - Connection is not available, request timed out”。一开始以为是数据库连接数不够调大maximum-pool-size也没用后来仔细看线程栈才发现全部卡在事务提交阶段。原因就是开头说的那个特性XA 两阶段提交期间每个分库连接都要被事务占用不能归还到连接池。一个事务如果涉及两个分片库同时就占着两条连接哪怕业务耗时只有几十毫秒高并发下积少成多连接池照样被抽空。这个问题的解法不是无限加大连接池而是从两个方向同时收口一是优化业务代码尽量缩短事务内耗时把远程调用、RPC、耗时的计算全部移出事务方法二是把maximum-pool-size和minimum-idle按并发预期算好余量别按单库经验拍脑袋。XA 本身就是一种用性能换一致性的方案如果事务里还夹着外部 HTTP 调用那基本是在给系统埋雷。4.3 事务注解不生效先检查这三件事这类问题我在群里被问过很多次总结下来就三个原因。第一是 Spring 代理没生效。要么是类没有交给 Spring 管理要么是同一个类内部用了this调用事务拦截器根本没机会进入前面已经提过。第二是事务管理器没有接管 ShardingDataSource。项目中如果手工创建了数据源并塞给了 MyBatis-Plus而不是完全由 Sharding-JDBC starter 自动装配事务边界就可能断在 Spring 和你自己的 Bean 之间。这种情况最直接的表现是业务方法加不加事务注解效果都一样分片库之间的数据照常不一致。第三是注解标错了层级。有的同学把ShardingTransactionType标在 Mapper 接口方法上期望每个数据库操作自己事务这在分片场景下是无效的事务一定是从入口 Service 方法控制的Mapper 只需要管 SQL 执行。排查顺序我习惯固定成先看异常有没有抛出来再看事务注解位置再看代理对象最后看事务管理器绑定的是哪个数据源。按这个顺序走大部分注解失效问题都能定位到。4.4 悬挂事务和 XA 日志恢复别等出了事故才关注XA 有一个本地事务没有的问题事务在两阶段提交时如果协调者或者某个分片数据库突然宕机就可能留下悬挂事务。比如ds0已经 prepare 了ds1也已经 prepare 了协调者在广播 commit 之前崩了这个时候ds0、ds1上各有一个处于 prepared 状态的 XA 事务既没提交也没回滚。应对这件事依赖两层东西。第一层是 Atomikos 的事务日志。它会把全局事务状态写到本地磁盘默认目录经常落在系统临时目录下生产环境必须把日志目录配置到持久化磁盘否则机器重启后日志丢失协调者就无法恢复现场。第二层是数据库侧的 XA 管理命令。MySQL 下可以用XA RECOVER查看当前实例上的 XA 分支确认悬挂事务的GTRID再用XA COMMIT或XA ROLLBACK手工收尾。这个问题平时不出现但一旦出现没有预案会非常被动。我在压测环境里专门做过一次“杀掉协调者进程”的演练确认日志恢复链路是通的才敢把方案推到生产。4.5 对性能和锁的真实体感最后说点体感层面的东西。XA 事务比本地事务慢多少网上说法很多实际上跟分片数量、网络延迟、事务内 SQL 条数强相关。我自己在跨两个物理机的分片环境上测过简单插入场景XA 比本地事务长接近一个数量级因为多了一次 prepare 的网络往返并且 prepare 之后不能马上释放资源。这里没有要劝退的意思只是想说明一个边界XA 只适合短事务、写多读多但并发可控的核心链路一旦事务内要批量更新上千条数据或者一个事务要跨三个以上分片库就要重新评估方案选型了。锁的方面也要有心理准备。XA 准备阶段拿到的锁比普通本地事务持有时间更长。两个事务同时操作同一行数据时后到的那一个只能等等待时间一旦超过数据库锁等待超时就直接报错回滚。所以上线前一定要确认事务里涉及的 SQL 都有合理索引避免全表扫描把锁范围放大否则并发稍微上来系统会从“慢”变成“死”。个人经验是XA 是解决跨库原子性的兜底工具不是所有分布式事务问题的银弹。项目里最终一致性能接受的链路优先用本地消息表这类异步方案真正强一致、双方数据必须同时可见、丢了就要出大问题的链路才把 XA 放上去。这个边界想清楚事务方案就不再是纠结而是按需求清单对号入座。

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

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

免费获取报价 →
↑