资讯动态

分库分表后如何保证数据一致性?Sharding-JDBC整合XA分布式事务实践

发布时间:2026/10/13 1:25:40 来源:尧图企业网站定制
先说一句分库分表这玩意儿一旦上了线最让人睡不着觉的就是数据一致性。本地事务只对单库有效一旦你的订单表被分到两个库扣库存又落在另一个库跨库写入就变成了一场“无法保证原子性”的赌博。这也是我在系列第五篇选这个题目的原因——把 Sharding-JDBC 和 Mybatis-Plus 整合到一起用 XA 协议解决分布式事务问题让跨库操作也能具备“全成功或全失败”的能力。这篇文章适合正在做分库分表落地、或者已经分完库但还没解决跨库事务的开发者参考。会先把为什么要引入分布式事务讲透再拆 XA 两阶段提交的工作原理然后直接给你能跑的依赖配置、代码写法、测试方法和踩坑记录尽可能还原我在实际项目里验证过的完整路径。1. 先想清楚分库分表之后事务问题到底出在哪1.1 本地事务为什么救不了分库分表没分库之前你只要在方法上贴一个 Transactional数据库自己就能保证原子性。比如插入订单和更新库存都在同一个库里事务要么全提交、要么全回滚完全没有歧义。但分库之后情况就变了。假设我把订单表拆分到 order_db_0 和 order_db_1把账户表放在 user_db一个“下单”业务里面既要写订单又要扣用户余额三个操作落在两三个不同的物理库上。你原本的 Transactional 只能管住当前线程拿到的那一个数据库连接另外几个库的连接不在本地事务管辖区内于是很容易出现“订单表写入成功余额表没扣成功或者反过来”。这种脏数据一旦出现排查起来特别痛苦因为你根本不知道是哪一步断了要对账才能发现问题。这就是分布式事务要解决的“跨多资源一致性”问题。1.2 主流分布式事务方案有哪些各自什么脾气业界现在能用的方案不少但各有各的适用边界。我这里直接列个表给你一个直观的横向对比方案类型一致性强度对业务侵入额外依赖典型代表XA 两阶段提交强一致低几乎无侵入事务管理器通常内嵌在中间件里Atomikos、Narayana、BitronixTCC 补偿事务最终一致/弱一致高需要写 Try/Confirm/Cancel需要服务协调者自行实现或依赖 Seata本地消息表 消息队列最终一致中需要额外表与重试逻辑消息队列RocketMQ 等Seata AT 模式全局读已提交写隔离低但需要服务端协调Seata Server 独立部署Seata从“好不好落地”角度看XA 最大的吸引力在于业务代码几乎不需要改。你只是把原来的 Transactional 换成支持 XA 的全局事务底层由事务管理器去协调多个数据源完成提交或回滚。而且 Sharding-JDBC 本身已经内置了 XA 事务管理器抽象你在配置里指定一个事务管理器实现就能让所有经过 Sharding 路由的连接统一走两阶段提交不需要去写自研协调服务。1.3 为什么我选 XA 而不是 TCC 或消息事务一句话项目里很多跨库写操作都是短事务XA 的强一致模型最契合这种诉求且实现成本最低。TCC 虽然对性能损耗更可控但要把每个业务拆成 Try、Confirm、Cancel 三套接口涉及大量重构。比如下单扣库存你要把原本一个库存扣减方法拆成预留库存、确认扣减、释放预留库存三个动作每个动作都要幂等设计。这个工作量业务还在快速迭代的时候很难接受。消息事务走最终一致库存和订单之间允许短暂不一致。但不少业务场景对一致性要求非常高比如账户扣款哪怕延迟几秒也是不能接受的。消息方案还要额外部署 MQ、设置回调监听、处理重试幂等链路复杂度上来了。所以结论很直接如果你的业务是跨库写入、数据敏感度高、事务持续时间短优先用 XA 跑通主链路这是性价比最高的路。下面我要讲的就是基于 Sharding-JDBC Mybatis-Plus Atomikos 实现 XA 事务的完整过程。2. XA 两阶段提交的核心机制你实际在依赖什么2.1 XA 里的三个角色XA 是由 X/Open 组织定义的分布式事务处理规范它规定了一套标准接口让事务管理器Transaction ManagerTM和资源管理器Resource ManagerRM之间能够跨多个数据库协同操作。你写业务代码的应用被称为 APApplication Program。AP你的服务Spring 里的 Service 层就是 AP 的角色。TM事务管理器负责流程控制。在 Sharding-JDBC 中常见实现就是 Atomikos它决定“所有库都准备好了就一起提交有任何一个库没准备好就全部回滚”。RM资源管理器这里就是各个数据源它们需要支持 XA 协议。MySQL、PostgreSQL 等主流数据库都支持。2.2 两阶段提交到底做了什么第一阶段叫准备阶段Prepare Phase。TM 向所有 RM 发送 PREPARE 指令每个数据库收到后会把事务内的所有变更写进 Redo/Undo 日志并锁住相关数据行。这时候数据库并不真正提交只告诉 TM“我的准备工作完成了随时可以提交”。只要有一个 RM 返回失败TM 就会向所有 RM 广播回滚。第二阶段叫提交阶段Commit Phase。TM 收到全部 RM 的 PREPARE 成功响应后再向所有 RM 发送 COMMIT 指令RM 才真正落盘提交。这个阶段如果某个 RM 提交失败了TM 会根据日志不断重试直到确认提交完成。你可以类比朋友约聚会先挨个打电话问“晚上有没有空”所有人都说有空才发消息说“定下来了都过来”只要有一个人说没空这个局就取消。XA 就是这种“先确认再执行”的思路保证各方对最终结果的认知是一致的。2.3 这个机制有什么代价必须提前知道XA 不是没有缺点理解它才好做取舍。第一全局锁带来的阻塞。第一阶段数据库已经锁行了如果某个 RM 处理特别慢或者锁等待特别多其他事务就会被一直阻塞。所以 XA 事务不能拖太久一般建议控制在几百毫秒到一两秒以内。第二协调者宕机的极端情况。如果 TM 在第一阶段结束后突然宕机部分库已经 PREPARE部分库没有恢复后需要靠事务日志判定最终是提交还是回滚。这也是为什么生产环境要配置 Atomikos 的日志文件持久化而不是只在内存里跑。第三不适合长事务。如果一个全局事务里塞了一百次网络调用或者循环插入几万条数据锁的时间会非常可观。XA 只适合短、平、快的事务那些需要长时间占用资源的场景请老实换 TCC 或者消息事务。这些代价我在同一个项目里全部真实踩过后面“常见问题”章节会展开讲。总之选型之前先掂量自己的业务事务时长千万别因为 XA 代码写起来简单就无脑上。3. 项目准备依赖、配置与版本选型一步都不能含糊3.1 版本组合怎么选我强烈建议参考这一套组合是目前经过反复验证比较稳定的搭配Spring Boot2.7.xMybatis-Plus3.5.xSharding-JDBC4.1.14.x 分片配置在 xml 或 yml 中比较直观且与 Spring Boot 2.x 兼容性较好数据库驱动mysql-connector-java 8.0.x内置 XA 事务管理器Atomikos 自带Sharding-Sphere 的 sharding-transaction-xa-core 模块封装好了当然 ShardingSphere 也有 5.x 新版本分片配置语法略有不同事务内核思想没有变。如果你是第一次实操我建议先用 4.1.1 把事务逻辑跑通再考虑升级。因为 5.x 在配置上变化比较大网络上资料版本参差不齐容易把自己绕进去。3.2 Maven 依赖清单下面是我项目里的核心依赖直接参考。需要提醒的是Sharding-JDBC 的 XA 事务模块以及 Atomikos 必须显式引入否则默认只走本地事务。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdorg.apache.shardingsphere/groupId artifactIdsharding-jdbc-spring-boot-starter/artifactId version4.1.1/version /dependency dependency groupIdorg.apache.shardingsphere/groupId artifactIdsharding-transaction-xa-core/artifactId version4.1.1/version /dependency dependency groupIdorg.apache.shardingsphere/groupId artifactIdsharding-transaction-xa-atomikos-manager/artifactId version4.1.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.22/version /dependency如果你的项目里还用了其他数据源组件要特别注意“优先排除自动配置的 DataSource”这个细节我在 3.4 和第六章会重点讲。3.3 分片规则配置主库、分库、广播表一个都不能少我设计的模拟场景是订单表拆到两个库 order_db0、order_db1每个库里面再按订单号模数拆成 2 张表用户余额表在 user_db 库不分表。逻辑表名为 t_order、t_user_balance。配置如下application.yml 或 application.properties 均可spring: shardingsphere: datasource: names: order-db0,order-db1,user-db order-db0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/order_db0?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root order-db1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/order_db1?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root user-db: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/user_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root sharding: default-data-source-name: user-db binding-tables: - t_order broadcast-tables: - t_config tables: t_order: actual-data-nodes: order-db$-{0..1}.t_order_$-{0..1} database-strategy: inline: sharding-column: user_id algorithm-expression: order-db$-{user_id % 2} table-strategy: inline: sharding-column: order_id algorithm-expression: t_order_$-{order_id % 2} key-generator: column: order_id type: SNOWFLAKE props: sql: show: true几个点解释一下。default-data-source-name 设置为 user-db这样不带分片键的 t_user_balance 查询会直接落到用户库免去每次带库前缀的麻烦。broadcast-tables 里的 t_config 是广播表比如配置字典表它在所有库都存在。XA 事务里如果涉及广播表更新所有库都会参与更新正好能验证全局事务跨库协调能力。actual-data-nodes 我写成了 order-db$-{0..1}.t_order_$-{0..1}表示每个物理库里有 2 张物理表。分库键用 user_id分表键用 order_id这样同一用户的数据能尽量集中到同一个库减少跨库访问。3.4 主数据源和事务管理器的坑这一步非常关键。引入 sharding-jdbc-spring-boot-starter 之后它创建的是一个 ShardingSphere 包装过的数据源。如果你还在启动类上保留了默认的数据源自动配置Spring Boot 的 DataSourceAutoConfiguration 会再创建一个原始数据源两个数据源并存事务管理器可能会绑定到错误的那个。标准做法是在启动类排除 DataSource 自动配置和事务管理器自动配置SpringBootApplication(exclude { DataSourceAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class })然后在配置类里显式定义 ShardingDataSource并将其设置为 Primary。因为数据源由分片引擎代理了所有连接、事务边界都必须经过这个代理对象。其实也不需要过多手工定义只要引入了 starter它会自动把分片数据源注入容器你只需要把它标记为 Primary并把事务管理器指向它。3.5 Mybatis-Plus 的配合使用Mybatis-Plus 在这里没有特殊要求正常配置 Mapper 扫描即可。需要注意所有 Mapper 操作的表名要写逻辑表名比如 t_order而不是 t_order_0 或 t_order_1分片引擎会自动帮你路由到物理表。很多人第一次跑通后开始手写 SQL上来就写 t_order_0结果怎么查都查不到就是这个原因。4. 代码实现让 XA 事务真正生效的三个关键步骤4.1 第一步事务类型必须显式声明为 XAShardingSphere 默认的本地事务类型是 LOCAL如果你只写 Transactional它走的是老一套本地事务根本无法跨库保证原子性。要让事务走 XA 通道必须在入口方法上加一个注解ShardingSphereTransactionType(TransactionType.XA)这个注解专门用于告诉分片引擎这个方法里产生的所有数据源连接都要以 XA 资源的方式注册到事务管理器。它的生效前提是方法同时也要有 Spring 的事务边界即 Transactional。两个注解叠在一起Transactional 负责拥抱 Spring 的事务传播与回滚规则ShardingSphereTransactionType 负责把底层连接切换成 XA 模式。少一个都不行。4.2 第二步一个真实的跨库下单服务下面是我当时用来验证事务的 Service 代码逻辑很简单插入订单同时扣减用户余额。订单落在 order-db0 或 order-db1余额在 user-db。Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private UserBalanceMapper userBalanceMapper; Override Transactional(rollbackFor Exception.class) ShardingSphereTransactionType(TransactionType.XA) public void createOrderWithDeductBalance(Order order, Long userId, BigDecimal amount) { // 插入订单分片键 user_id 决定它落到哪个库 orderMapper.insert(order); // 扣减用户余额落在 user-db UserBalance balance userBalanceMapper.selectByUserIdForUpdate(userId); if (balance null) { throw new RuntimeException(用户余额账户不存在); } if (balance.getBalance().compareTo(amount) 0) { throw new RuntimeException(余额不足); } balance.setBalance(balance.getBalance().subtract(amount)); userBalanceMapper.updateById(balance); } }注意 selectByUserIdForUpdate 这条 SQL 本身要带 FOR UPDATE保证在 XA 事务里面扣减余额时这一行数据会被锁住直到最终的 COMMIT 或 ROLLBACK。不加行锁就可能出现两个事务同时读到相同余额然后各自扣减最终账对不上的情况。4.3 第三步原子性校验的关键点XA 事务真正保证的是“跨库要么全提交要么全回滚”。验证时要特别注意两点第一不要在一个事务里混入 INF 级操作以外的外部服务调用。如果事务中间有调用外部 HTTP 接口并且外部接口长时间不返回数据库锁会一直握着XA 第一个阶段就会很尴尬。第二不要捕获异常后不往外抛。Spring 事务回滚靠的是运行时异常RuntimeException或显式标记 rollbackFor Exception.class。如果你用 try-catch 把异常吞掉XA 不知道这个事务要回滚最终会照常 COMMIT数据照样脏。这个坑比依赖配置踩到的概率大得多。4.4 动态切换事务类型事务类型持有器有些场景可能需要在一个类里既有 XA 事务又有本地事务这时可以不用注解改用 TransactionTypeHolder 动态设置TransactionTypeHolder.set(TransactionType.XA); try { // 执行业务... } finally { TransactionTypeHolder.clear(); }不过还是建议能用注解就用注解全局搜索一目了然动态切换方式代码容易散落不利于排查。我自己用注解方案就把项目维护得很好没出过问题。5. 实操验证从日志和数据两个层面确认回滚生效5.1 测试设计造一个必现失败的场景为了直观看到 XA 回滚我故意设计了一个失败用例插入订单时金额设为 1000但用户的余额只有 500。执行 createOrderWithDeductBalance订单插入成功扣减余额时抛“余额不足”那么这个订单必须一起被回滚。这段测试代码直接贴在 Spring Boot 的集成测试里跑SpringBootTest class XaTransactionTest { Autowired private OrderService orderService; Test void shouldRollbackWhenBalanceInsufficient() { Order order new Order(); order.setUserId(3L); order.setAmount(new BigDecimal(1000)); order.setStatus(1); try { orderService.createOrderWithDeductBalance(order, 3L, new BigDecimal(600)); } catch (RuntimeException expected) { // 预期异常余额不足 } // 这里查询 t_order 的总数应该不包含刚才的订单 Long count orderService.countOrdersByUserId(3L); assertTrue(count 0); } }这个测试看起来简单实际上非常有用。它能同时验证三件事订单表有没有落库余额有没有变动X 事务是否真正回到初始状态。5.2 通过日志确认两阶段提交过程打开 SQL 输出日志spring.shardingsphere.props.sql.showtrue成功提交的场景下会看到类似下面的关键日志片段XA_START事务管理器为每个数据源开启 XA 分支XA_END业务事务边界结束准备进入准备阶段XA_PREPARE每个 RM 完成准备锁住数据XA_COMMIT / XA_ROLLBACK最终统一提交或回滚对应 literal 到项目里成功场景最后应该出现 XA_COMMIT失败场景应该出现 XA_ROLLBACK。如果失败场景看到的是 XA_COMMIT说明事务类型没切过来或者异常被吞了立刻回头查 4.1 和 4.3 的配置与写法。除了日志Atomikos 还会生成事务日志文件。默认情况下文件在项目运行目录下的 tmlog 文件夹里。看到 .tml 文件被写入说明 Atomikos 在做崩溃恢复时的持久化记录这个文件不要随意删。5.3 直接查库验证数据最终状态日志只能说“提交/回滚指令发了”最终还是要看数据。测试失败后连上 order_db0 和 order_db1 分别执行SELECT COUNT(*) FROM t_order_0 WHERE user_id 3; SELECT COUNT(*) FROM t_order_1 WHERE user_id 3;正常情况下两个表里都不该有刚才那条订单。再看 user_db 的余额表余额保持不变。如果某个库里还残留着订单数据基本可以判断是“部分提交”了这也是一致性问题最典型的症状。我当时第一次跑的时候就是因为 Mapper 里手滑写了 t_order_0 而不是 t_order结果订单直接绕过分片引擎写进了一张物理表XA 事务根本感知不到这个连接的存在自然就不会回滚它。这种问题靠日志不好排查一定得在写完代码后实际去看库里的数据。6. 实际项目中常见的坑一份排查速查表6.1 依赖冲突JTA 与项目内其他组件打架XA 底层依赖 JTAJava Transaction APIAtomikos 实现了 JTA 接口。如果你的项目里同时引入了其他 JTA 实现比如 NarayanaSpring 容器会不知道用哪一个事务管理器直接启动报错。解决思路很直接检查 maven 依赖树确保只有 Atomikos 一个 XA 实现。一般我们引入 sharding-transaction-xa-atomikos-manager 会传递带好 javassist 等依赖其他 JTA 自动消失如果还有冲突可以手动排除掉没有使用到的 JTA 实现。6.2 事务不生效的几类典型场景这类问题在实操里出现频率最高我按频率排一下异常被 catch 住没有抛出去事务管理器收到不了异常信号自然不回滚。类内部自调用同一个类里一个方法调用另一个带 Transactional 的方法Spring AOP 代理就失效了因为你调用的是 this 的方法而不是代理对象的方法。解决办法是注入自身代理或者把事务方法拆到另一个 Service 里。方法不是 publicSpring 默认无法代理非 public 方法上的事务注解。事务传播属性设置不当时比如内部方法挂了但外层没抛异常。要保证调用链路里异常能传到源头的代理。我发现一个比较隐蔽的坑在 ShardingSphereTransactionType 注解存在的情况下如果你的 Service 方法被同类内部的普通方法调用了Mybatis-Plus 的 Mapper 正常执行但底层连接可能没有进入 XA 通道。最终现象是单库写成功跨库回滚失效。所以每次写完这种事务方法我都要用“成功提交”和“故意失败”两条用例都跑一遍确保真的生效。6.3 Sharding-JDBC 4.x 和 5.x 配置差异网上很多文章用的是 ShardingSphere 5.x 的语法什么 dataSources、rules 之类的 YAML 风格跟 4.x 完全不一样。如果你照抄一个 4.x 项目的配置进 5.x启动直接报错。务必先确认自己项目引的哪个版本。我的建议是如果你不是有强需求升级用 4.1.1 更稳因为网上相关博文、官方文档案例相对丰富社区沉淀较久。5.x 在分片、事务 API 上有重构遇坑可能要翻源码。6.4 性能表现和压测预期XA 不是免费的。我自己实测过一个包含 2 个库 3 张表的写操作事务耗时相比本地事务大约增加 20%-30% 左右。这个数值在事务短、无锁竞争、并发不高的场景下完全可接受。但如果压测并发到了每秒几百线程且每个事务都要 FOR UPDATE 锁同一行余额数据就会出现明显的锁等待起因就是 XA 第一阶段锁了行第二阶段还没提交后来的事务只能等。这时候不要急着调优超时而要检视业务设计是不是同一个账户的更新过于频繁是不是可以考虑把账户余额拆分到多条账户行或者上异步化削峰6.5 大事务下避免风险的处理经验我在项目里也立了一条硬性约定所有进入 XA 事务的方法不允许执行远程 HTTP 调用、不允许执行大批量循环插入几百条以上、不允许执行超过 5 秒的复杂 SQL。原因前面说了XA 的阶段一锁资源阶段二才能释放这期间的任何耗时都会被放大成全链路的锁占用。与其到处调参数不如在业务设计上把事务切小。比如批量扣库存拆成一条 SQL 批量更新不做循环逐条更新既快又安全。循环逐条更新除了慢还会搞出嵌套事务的传播问题非常不值得。6.6 如何确认当前事务走的是 XA 而不是本地事务我平时会开一个全局的拦截器给每个事务打上标记但最直接的办法还是看日志。LOCAL 事务在 ShardingSphere 的 SQL 日志里只是简单打印 SQL而 XA 事务会额外出现 Atomikos 的 XA_START / XA_PREPARE / XA_COMMIT 日志。如果你看到的是普通 JDBC 提交日志没有任何 XA 字样基本可以断定事务没有切到 XA。另外可以直接在代码里临时加一行TransactionType actualType TransactionTypeHolder.get(); log.info(current transaction type {}, actualType);如果输出结果为 LOCAL而你有 ShardingSphereTransactionType(XA) 注解那么就要怀疑是不是引入了多个 ShardingSphere 包导致注解没被正确识别检查依赖树和版本保持 sharding-jdbc-spring-boot-starter 和 sharding-transaction-xa-core 版本一致这个现象我见过两三次几乎都是版本不一致导致。7. 我的实操体会与后续扩展建议整套 Sharding-JDBC Mybatis-Plus XA 的组合我大概在三个项目里跑过最长的已经稳定运行了大半年。个人感受是XA 作为分布式事务解决方案在短事务、强一致需求的场景里确实省心代码侵入小回滚可控非常适合分库分表初期快速补齐事务短板。但也要清醒认识它的边界。遇到高并发、长事务、热点行集中更新的场景XA 不是第一选择。这时候我更建议把 Seata AT 模式或者 TCC 方案纳入评估甚至从业务模型上做拆分尽量让需要强一致的数据落在同一个库。技术方案的最终目的从来不是炫技而是让业务在可接受的成本和复杂度下保持稳定。后续如果你想继续深入可以做两件事第一把当前项目从 Sharding-JDBC 4.x 平滑迁移到 ShardingSphere 5.x对比两者在分片配置和事务链路上的差异第二给项目补上 TCC 或 Seata AT 的备选实现压测对比 XA 与 AT 在相同业务下的吞吐差距。这些我都已经在准备下一个主题了等实际验证跑完后再和你分享。

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

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

免费获取报价 →
↑