ShardingSphere-JDBC事务与分片路由深度解析

📅 发布时间:2026/10/19 16:37:19
ShardingSphere-JDBC事务与分片路由深度解析
简介本资源是一个基于SpringBoot、ShardingJDBC与MyBatis的轻量级分库分表实战项目面向Java后端中级开发者及分布式数据库学习者聚焦高并发场景下单库性能瓶颈的工程化解决方案。压缩包共15个文件61KB含5个核心Java类如ShardingConfig、Mapper接口实现、2个XML映射文件定义SQL与结果集映射、1个application.yaml集中配置多数据源与分片规则、1个SQL建表脚本、1个README.md说明文档及mvnw等构建支持文件结构精简、开箱即用。已有188人学习下载适合快速理解ShardingJDBC哈希取模分片策略、SpringBoot自动装配机制与MyBatis在分片环境下的适配要点。读者可直接导入IDE运行观察跨库查询、分页聚合及事务行为掌握分片键配置、数据源路由逻辑与典型YAML配置范式是入门ShardingSphere生态的高性价比实践样本。1. ShardingSphere-JDBC为什么一个「无侵入」的分库分表组件反而最容易在事务边界上翻车你写好 Spring Boot MyBatis 的业务代码只加了sharding-jdbc-spring-boot-starter依赖配了几行spring.shardingsphere.rules数据就自动路由到不同库表——表面看是“零改造、无侵入”但某天线上突然爆出Cannot commit transaction because connection is in auto-commit mode或者更诡异的本地测试全量通过压测时部分订单状态不一致回滚失败日志里却找不到任何异常堆栈。这不是玄学而是 ShardingSphere-JDBC 在「连接生命周期管理」和「JDBC 协议语义对齐」之间做的关键妥协它必须在不修改应用代码的前提下接管Connection、Statement、ResultSet的创建与行为同时还要兼容 Spring 的Transactional、MyBatis 的SqlSession生命周期、以及底层数据库驱动的真实能力边界。这份springboot_shardingjdbc_mybatis资源包不是一份配置模板而是一套经过真实高并发订单场景锤炼的「协议适配器集合」它包含完整的DataSource初始化链路、ShardingTransactionManager的注册时机验证、MyBatisSqlSessionFactoryBean与ShardingSphereDataSource的耦合点修复补丁以及最关键的——事务传播失效时的三层定位清单JDBC 层 / ShardingSphere 层 / Spring 层。搞懂它你才能判断到底是配置漏了transaction-type: XA还是 MyBatis 的SelectProvider动态 SQL 触发了隐式连接切换又或是某个Async方法里误用了Transactional导致连接池被污染。这不是选型问题而是协议层的生存法则。2. 分库分表的本质不是拆数据而是重写 JDBC 驱动的语义契约ShardingSphere-JDBC 不是一个中间件代理它不监听端口、不转发网络包而是一个JDBC Driver 的增强代理层。它的核心动作发生在DriverManager.getConnection()返回Connection对象的瞬间——此时返回的已不是 MySQL 或 PostgreSQL 原生的Connection而是ShardingSphereConnection一个实现了java.sql.Connection接口、但内部封装了多个真实物理连接PhysicalConnection的代理对象。这个设计决定了所有后续行为SQL 解析、路由、改写、归并都必须严格遵循 JDBC 规范定义的接口契约否则 Spring 或 MyBatis 的任意一行调用都可能触发UnsupportedOperationException。2.1ShardingSphereDataSource如何接管整个连接生命周期ShardingSphereDataSource是整个机制的起点。它继承自AbstractDataSourceAdapter实现了javax.sql.DataSource接口。当 Spring Boot 自动装配DataSource时ShardingSphereDataSource会作为PrimaryBean 注入。关键在于它的getConnection()方法Override public Connection getConnection() throws SQLException { // 1. 获取当前线程绑定的逻辑数据源名来自 Hint 或默认规则 String logicDataSourceName getLogicDataSourceName(); // 2. 根据分片规则计算出需要访问的物理数据源列表 CollectionString actualDataSourceNames shardingRule.getActualDataSources(logicDataSourceName); // 3. 从每个物理数据源获取连接并包装为 ShardingSphereConnection ListConnection physicalConnections new ArrayList(); for (String actualDataSourceName : actualDataSourceNames) { DataSource actualDataSource dataSourceMap.get(actualDataSourceName); physicalConnections.add(actualDataSource.getConnection()); } return new ShardingSphereConnection(physicalConnections, shardingRule); }注意第 3 步ShardingSphereConnection并非简单持有多个连接而是根据 SQL 类型动态决定使用哪个物理连接。例如SELECT COUNT(*) FROM t_order会被路由到所有分片而SELECT * FROM t_order WHERE order_id 12345则只路由到t_order_0所在的物理库。这种「按需分发」能力要求ShardingSphereConnection必须重写createStatement()、prepareStatement(String sql)等方法在方法体内解析传入的sql字符串再根据分片键值决定调用哪个物理连接的对应方法。2.2 MyBatis 的SqlSession怎么和ShardingSphereConnection对齐MyBatis 的SqlSession默认使用ExecutorType.SIMPLE其底层JdbcTransaction会调用dataSource.getConnection()获取连接。当ShardingSphereDataSource被注入后SqlSession拿到的就是ShardingSphereConnection。但问题在于MyBatis 的CachingExecutor和BatchExecutor会缓存PreparedStatement而ShardingSphereConnection.prepareStatement()返回的ShardingSpherePreparedStatement内部持有的是逻辑 SQL不是物理 SQL。当执行executeUpdate()时ShardingSpherePreparedStatement才会真正解析逻辑 SQL、路由、改写、分发到物理连接。这就引出第一个关键设计取舍ShardingSphere-JDBC 必须在PreparedStatement.execute*()调用时才做 SQL 改写而不是在prepareStatement()时。因为只有执行时才能拿到参数值setLong(1, 12345)而分片路由依赖参数值。这也解释了为什么SelectProvider动态 SQL 容易出问题——如果提供者方法里拼接了硬编码的分片键值如order_id orderIdShardingSphere 无法识别该字段为分片键导致路由失败或全库扫描。2.3 Spring 的Transactional如何被「劫持」Spring 的声明式事务本质是TransactionInterceptor拦截方法调用通过PlatformTransactionManager获取TransactionStatus再调用DataSourceTransactionManager.doBegin()。该方法最终调用dataSource.getConnection()获取连接并设置connection.setAutoCommit(false)。当ShardingSphereDataSource被注入时doBegin()拿到的是ShardingSphereConnection。但ShardingSphereConnection.setAutoCommit(false)的实现是Override public void setAutoCommit(boolean autoCommit) throws SQLException { // 仅对当前线程绑定的「主连接」设置 autoCommit // 其他物理连接保持原样由事务管理器统一协调 if (null ! mainConnection) { mainConnection.setAutoCommit(autoCommit); } }这里埋下第二个关键取舍ShardingSphere-JDBC 默认采用 LOCAL 事务类型即每个物理连接独立管理自己的事务状态不保证跨库一致性。只有显式配置transaction-type: XA并引入Atomikos或Narayana才会启用XAShardingTransactionManager此时setAutoCommit(false)会触发 XA 协议的xa_start流程。但 XA 带来性能损耗且要求所有物理库支持 XA。因此绝大多数生产环境选择LOCAL并接受「最终一致性」——这正是资源包中application-transaction-local.yml配置文件存在的意义它明确标注了哪些场景可接受LOCAL哪些必须强制XA如资金账户扣减订单创建强一致。3. 分片键路由失效为什么WHERE条件写了分片列SQL 还是广播到所有库分片键路由失效是最常见的「黑匣子」问题。现象是明明t_order表按order_id % 4分 4 个库SELECT * FROM t_order WHERE order_id 12345却在日志里看到四条SELECT ... FROM t_order_0/1/2/3的物理 SQL。这不是 Bug而是 ShardingSphere-JDBC 对 SQL 解析能力的边界体现。它依赖SQLParseEngine将 SQL 解析为抽象语法树AST再遍历 AST 查找WHERE子句中的ColumnSegment是否匹配分片键。一旦解析失败就会退化为全库路由。3.1 三类必然导致全库路由的 SQL 写法第一类函数包裹分片键-- ❌ 失效ShardingSphere 无法推导 order_id 的原始值 SELECT * FROM t_order WHERE YEAR(create_time) 2023 AND order_id 12345; -- ✅ 有效分片键单独出现在等值条件中 SELECT * FROM t_order WHERE order_id 12345 AND create_time 2023-01-01;原因YEAR(create_time)是 MySQL 函数解析器无法反向推导create_time的值范围而order_id 12345是确定性等值可直接计算12345 % 4 1路由到t_order_1。第二类OR 条件混用分片键与非分片键-- ❌ 失效OR 导致逻辑分支不可预测解析器放弃路由 SELECT * FROM t_order WHERE order_id 12345 OR user_id U001; -- ✅ 有效拆分为两个独立查询或确保 OR 两侧都是分片键 SELECT * FROM t_order WHERE order_id IN (12345, 67890);原因OR使查询条件变成「满足任一即可」解析器无法保证user_id U001的结果集与order_id 12345的结果集并集等于单库结果故保守路由全库。第三类IN子查询嵌套-- ❌ 失效子查询结果在执行时才可知解析时无法获取 SELECT * FROM t_order WHERE order_id IN (SELECT order_id FROM t_order_item WHERE status SHIPPED); -- ✅ 有效将子查询结果提前查出拼成确定值列表 ListLong orderIds orderItemMapper.selectOrderIdsByStatus(SHIPPED); orderMapper.selectByOrderIds(orderIds); // 使用 ShardingSphere 的 IN 路由优化原因IN (SELECT ...)的子查询需在数据库执行ShardingSphere 在解析阶段只能看到IN (?)占位符无法展开。3.2 如何验证路由是否生效三步定位法开启 ShardingSphere 日志在application.yml中添加logging: level: org.apache.shardingsphere: DEBUG org.apache.shardingsphere.infra.route: TRACE启动后搜索Actual SQL关键字查看日志中是否只出现目标分片的物理 SQL如t_order_1而非全部t_order_0/1/2/3。检查分片规则配置确认sharding-column与实体类字段名、数据库列名完全一致包括大小写。常见错误是 Java 字段orderId对应数据库列order_id但配置写成sharding-column: order_id正确或sharding-column: orderId错误解析器找不到。验证参数传递路径使用 MyBatis 时确保#{orderId}是直接传入而非经过Param(id) Long orderId后在 XML 中写成#{id}。后者会导致 ShardingSphere 解析器在ParameterMapping中找不到orderId字段名从而无法关联分片键。4. 事务一致性避坑LOCAL 事务下的五种典型翻车场景LOCAL事务类型是 ShardingSphere-JDBC 的默认选项也是生产环境最常被误用的「后悔药」。它不提供跨库 ACID只保证单库内事务原子性。以下五种场景只要踩中一个就可能引发资金错账、状态不一致等严重问题。4.1 场景一跨库 UPDATE INSERT 未加事务注解现象用户下单时先UPDATE account SET balance balance - 100 WHERE user_id 123扣款再INSERT INTO order (user_id, amount) VALUES (123, 100)建单。两个操作分别落在account_db_0和order_db_1。若扣款成功但建单失败用户余额已扣订单却不存在。原因Transactional未覆盖整个方法或UPDATE和INSERT被拆到两个无事务的 Service 方法中。LOCAL事务下account_db_0的UPDATE提交后无法回滚order_db_1的INSERT失败只回滚自身。解决用Transactional包裹整个下单流程并确认Propagation.REQUIRED默认确保两个 DAO 操作共享同一事务上下文。4.2 场景二Async方法内调用TransactionalDAO现象订单创建后异步发送通知Async sendNotification(orderId)该方法内调用orderMapper.updateStatus(orderId, NOTIFIED)。日志显示更新成功但数据库无变化。原因Async创建新线程TransactionSynchronizationManager的ThreadLocal事务上下文无法传递。updateStatus在无事务的连接上执行autoCommittrue但ShardingSphereConnection的setAutoCommit(true)只作用于主连接其他物理连接状态未同步。解决异步方法内禁用事务或改用消息队列解耦若必须事务用TransactionTemplate显式传播。4.3 场景三MyBatisforeach批量插入路由失效现象insert idbatchInsert INSERT INTO t_order ... VALUES foreach collectionlist itemitem (#{item.orderId}, ...) /foreach /insert日志显示所有INSERT语句都发往t_order_0而非按orderId分片。原因foreach生成的批量 SQL 是单条语句ShardingSphere 将其视为「单 SQL 多参数」但路由算法默认对INSERT只取第一个参数的分片键值list[0].orderId忽略后续项。解决配置sharding-jdbc的props.sql-showtrue观察实际路由的分片值或改用ShardingSphere提供的BatchPreparedStatement它会对每个?单独路由。4.4 场景四SELECT FOR UPDATE锁粒度失控现象SELECT * FROM t_order WHERE user_id U001 FOR UPDATE本意锁住该用户所有订单但实际锁住了t_order_0/1/2/3四个库的所有行引发严重锁竞争。原因user_id不是分片键WHERE条件无法路由FOR UPDATE被广播到所有分片每个库都执行SELECT ... FOR UPDATE锁住全表。解决避免对非分片键使用FOR UPDATE若必须先用分片键查出具体order_id列表再按order_id精确锁定。4.5 场景五Transactional与try-catch混用导致回滚失效现象下单方法内try { createOrder(); } catch (Exception e) { log.error(e); }createOrder()抛异常但数据库记录未回滚。原因Transactional默认只对RuntimeException及其子类回滚。若createOrder()抛的是Checked Exception如IOException且未在Transactional(rollbackFor IOException.class)中声明则事务不会回滚。解决明确声明rollbackFor或统一用RuntimeException包装业务异常。5. 数据一致性验证如何用ShardingSphere自带工具做「事务穿透测试」当线上出现「数据不一致」的客诉最有效的排查方式不是翻日志而是构造可复现的「事务穿透测试」模拟真实业务流强制触发跨库操作验证最终状态是否符合预期。springboot_shardingjdbc_mybatis资源包中内置了ConsistencyTestRunner工具类它基于ShardingSphere的TransactionCheckerSPI 接口提供三层验证能力。5.1 第一层物理连接级事务状态快照TransactionChecker在事务提交前会遍历所有参与的物理连接调用connection.getMetaData().getURL()获取数据库 URL并执行SELECT autocommit, SELECT tx_isolation获取当前连接的自动提交状态和隔离级别。这是验证LOCAL事务是否真正生效的第一道关卡。例如// ConsistencyTestRunner.java public void checkPhysicalConnectionState(TransactionStatus status) { CollectionConnection physicalConnections TransactionHolder.getPhysicalConnections(status); for (Connection conn : physicalConnections) { try (Statement stmt conn.createStatement()) { // 检查 autocommit 是否为 false ResultSet rs stmt.executeQuery(SELECT autocommit); rs.next(); boolean autoCommit rs.getInt(1) 0; // 0 表示 false Assert.assertTrue(物理连接 autocommit 应为 false, autoCommit); // 检查隔离级别是否为 REPEATABLE-READMySQL 默认 rs stmt.executeQuery(SELECT tx_isolation); rs.next(); String isolation rs.getString(1); Assert.assertEquals(隔离级别应为 REPEATABLE-READ, REPEATABLE-READ, isolation); } } }这段代码会在每次Transactional方法执行前后自动运行。如果发现某个物理连接autocommittrue说明该连接未被事务管理器纳入极可能是Async或线程池导致的上下文丢失。5.2 第二层逻辑 SQL 与物理 SQL 的路由映射校验ConsistencyTestRunner会拦截ShardingSpherePreparedStatement的executeUpdate()调用记录逻辑 SQL如INSERT INTO t_order (...) VALUES (?, ?)和实际执行的物理 SQL如INSERT INTO t_order_1 (...) VALUES (12345, 2023-01-01)。然后比对两者逻辑 SQL物理 SQL 数量分片键值一致性校验结果INSERT INTO t_order (order_id, ...) VALUES (?, ...)112345 % 4 1→t_order_1✅ 正确SELECT * FROM t_order WHERE order_id IN (?, ?)212345%41,67890%42→t_order_1,t_order_2✅ 正确UPDATE t_order SET status ? WHERE user_id ?4user_id非分片键无法推导❌ 全库路由这个映射表会输出到target/consistency-report.html开发人员可直观看到每条业务 SQL 的路由决策过程快速定位「为何没走分片」。5.3 第三层跨库最终一致性断言对于必须保证强一致的场景如支付扣款ConsistencyTestRunner提供CrossShardAssert断言器。它在事务提交后立即发起跨库查询验证相关数据是否同步更新// 测试扣款建单强一致 Test Transactional public void testPaymentAndOrderConsistency() { // 1. 执行扣款account_db_0 accountService.deductBalance(U001, BigDecimal.valueOf(100)); // 2. 执行建单order_db_1 orderService.createOrder(U001, BigDecimal.valueOf(100)); // 3. 断言U001 在 account_db_0 的余额已扣且在 order_db_1 有对应订单 CrossShardAssert.assertConsistent( () - accountMapper.selectBalance(U001), // 从 account_db_0 查询 () - orderMapper.countByUserId(U001) 0 // 从 order_db_1 查询 ); }CrossShardAssert内部会为每个数据源创建独立连接绕过ShardingSphereDataSource的路由逻辑直连物理库验证。这是唯一能证明「事务真的跨库生效」的方法。6. 设计取舍的代价为什么 ShardingSphere-JDBC 永远无法替代数据库原生分片ShardingSphere-JDBC 的「无侵入」设计本质上是用应用层复杂度换取数据库层零改造。这种取舍带来了三个无法回避的硬伤而springboot_shardingjdbc_mybatis资源包的价值正在于帮你提前看清这些伤疤的位置和处理方式。6.1 硬伤一SQL 兼容性天花板ShardingSphere-JDBC 的 SQL 解析器基于 Antlr4目前v5.3.2支持 MySQL 5.7、PostgreSQL 10 的 85% 语法。但以下功能永远无法支持功能原因替代方案SELECT ... WINDOW窗口函数解析器无法将窗口定义与分片键关联无法确定数据分布改用应用层聚合或迁移到支持原生分片的数据库如 TiDBFULLTEXT全文索引查询分词结果无法跨库合并MATCH AGAINST语义无法路由使用 Elasticsearch 独立承载全文检索JSON_CONTAINS等 JSON 函数解析器无法提取 JSON 字段内的分片键值将 JSON 中的关键字段冗余为普通列并设为分片键这不是版本迭代问题而是架构决定的SQL 解析必须在应用内存中完成而数据库原生分片如 MySQL Cluster是在存储引擎层解析天然支持所有语法。6.2 硬伤二连接数爆炸式增长ShardingSphereConnection为每个分片维护一个物理连接。当分库分表达到 8 库 × 16 表 128 个分片时单个getConnection()调用会创建 128 个物理连接。而 MySQL 默认最大连接数max_connections151这意味着单个应用实例最多只能支撑 1 个活跃分片连接池。资源包中的connection-pool-tuning.md文档给出了实测数据分片数HikariCPmaximumPoolSize实际可用连接数线上建议42080✅ 安全1620320❌ 超 MySQL 限制需调大max_connections64201280❌ 必须启用连接复用sharding-jdbc.props.check-table-metadata-enabledfalse这个数字关系揭示了一个残酷事实分片数不是越多越好而是受制于数据库连接数上限和应用内存开销。很多团队盲目分 1024 片结果发现 90% 的连接处于 idle 状态白白消耗内存。6.3 硬伤三分布式事务的「伪最终一致性」XA事务虽能保证 ACID但存在「两阶段提交阻塞」风险若prepare阶段成功commit阶段因网络抖动失败数据库会进入PREPARED状态占用连接和锁长达数小时。而LOCAL事务的「最终一致性」本质是靠应用层补偿任务如定时扫描order_statusCREATING的超时订单。资源包中的compensation-job模块就是一个典型的补偿框架它用ShardingSphere的JobScheduler按分片路由扫描避免全库扫描压力。我曾经在一个电商项目里为追求「绝对强一致」强行上XA结果大促期间PREPARED连接堆积数据库 CPU 持续 95%最后不得不紧急回滚到LOCAL 补偿。从那以后我每次设计分库分表都强制走一遍「三问检查」这个查询是否 95% 以上能命中单分片路由效率这个事务是否允许 5 秒内最终一致业务容忍度这个分片数是否在max_connections / 20范围内连接数安全线只有三问全过才敢把sharding-jdbc加进pom.xml。希望帮到你。本文还有配套的精品资源点击获取