ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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. 根据分片规则,计算出需要访问的物理数据源列表 Collection<String> actualDataSourceNames = shardingRule.getActualDataSources(logicDataSourceName); // 3. 从每个物理数据源获取连接,并包装为 ShardingSphereConnection List<Connection> 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 = " + orderId),ShardingSphere 无法识别该字段为分片键,导致路由失败或全库扫描。

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'); -- ✅ 有效:将子查询结果提前查出,拼成确定值列表 List<Long> orderIds = orderItemMapper.selectOrderIdsByStatus("SHIPPED"); orderMapper.selectByOrderIds(orderIds); // 使用 ShardingSphere 的 IN 路由优化

原因:IN (SELECT ...)的子查询需在数据库执行,ShardingSphere 在解析阶段只能看到IN (?)占位符,无法展开。

3.2 如何验证路由是否生效?三步定位法

  1. 开启 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。

  2. 检查分片规则配置:确认sharding-column与实体类字段名、数据库列名完全一致(包括大小写)。常见错误是 Java 字段orderId对应数据库列order_id,但配置写成sharding-column: order_id(正确)或sharding-column: orderId(错误,解析器找不到)。

  3. 验证参数传递路径:使用 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在无事务的连接上执行,autoCommit=true,但ShardingSphereConnection的setAutoCommit(true)只作用于主连接,其他物理连接状态未同步。

解决:异步方法内禁用事务,或改用消息队列解耦;若必须事务,用TransactionTemplate显式传播。

4.3 场景三:MyBatisforeach批量插入路由失效

现象:<insert id="batchInsert"> INSERT INTO t_order ... VALUES <foreach collection="list" item="item"> (#{item.orderId}, ...) </foreach> </insert>,日志显示所有INSERT语句都发往t_order_0,而非按orderId分片。

原因:foreach生成的批量 SQL 是单条语句,ShardingSphere 将其视为「单 SQL 多参数」,但路由算法默认对INSERT只取第一个参数的分片键值(list[0].orderId),忽略后续项。

解决:配置sharding-jdbc的props.sql-show=true,观察实际路由的分片值;或改用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) { Collection<Connection> 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-READ(MySQL 默认) rs = stmt.executeQuery("SELECT @@tx_isolation"); rs.next(); String isolation = rs.getString(1); Assert.assertEquals("隔离级别应为 REPEATABLE-READ", "REPEATABLE-READ", isolation); } } }

这段代码会在每次@Transactional方法执行前后自动运行。如果发现某个物理连接autocommit=true,说明该连接未被事务管理器纳入,极可能是@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%4=1,67890%4=2→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窗口函数解析器无法将窗口定义与分片键关联,无法确定数据分布改用应用层聚合,或迁移到支持原生分片的数据库(如 TiDB)
FULLTEXT全文索引查询分词结果无法跨库合并,MATCH AGAINST语义无法路由使用 Elasticsearch 独立承载全文检索
JSON_CONTAINS等 JSON 函数解析器无法提取 JSON 字段内的分片键值将 JSON 中的关键字段冗余为普通列,并设为分片键

这不是版本迭代问题,而是架构决定的:SQL 解析必须在应用内存中完成,而数据库原生分片(如 MySQL Cluster)是在存储引擎层解析,天然支持所有语法。

6.2 硬伤二:连接数爆炸式增长

ShardingSphereConnection为每个分片维护一个物理连接。当分库分表达到 8 库 × 16 表 = 128 个分片时,单个getConnection()调用会创建 128 个物理连接。而 MySQL 默认最大连接数max_connections=151,这意味着单个应用实例最多只能支撑 1 个活跃分片连接池。资源包中的connection-pool-tuning.md文档给出了实测数据:

分片数HikariCPmaximumPoolSize实际可用连接数线上建议
42080✅ 安全
1620320❌ 超 MySQL 限制,需调大max_connections
64201280❌ 必须启用连接复用(sharding-jdbc.props.check-table-metadata-enabled=false)

这个数字关系揭示了一个残酷事实:分片数不是越多越好,而是受制于数据库连接数上限和应用内存开销。很多团队盲目分 1024 片,结果发现 90% 的连接处于 idle 状态,白白消耗内存。

6.3 硬伤三:分布式事务的「伪最终一致性」

XA事务虽能保证 ACID,但存在「两阶段提交阻塞」风险:若prepare阶段成功,commit阶段因网络抖动失败,数据库会进入PREPARED状态,占用连接和锁长达数小时。而LOCAL事务的「最终一致性」,本质是靠应用层补偿任务(如定时扫描order_status='CREATING'的超时订单)。资源包中的compensation-job模块,就是一个典型的补偿框架:它用ShardingSphere的JobScheduler按分片路由扫描,避免全库扫描压力。

我曾经在一个电商项目里,为追求「绝对强一致」强行上XA,结果大促期间PREPARED连接堆积,数据库 CPU 持续 95%,最后不得不紧急回滚到LOCAL+ 补偿。从那以后我每次设计分库分表,都强制走一遍「三问检查」:

  1. 这个查询是否 95% 以上能命中单分片?(路由效率)
  2. 这个事务是否允许 5 秒内最终一致?(业务容忍度)
  3. 这个分片数是否在max_connections / 20范围内?(连接数安全线)

只有三问全过,才敢把sharding-jdbc加进pom.xml。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进