☰
ShardingSphere-JDBC事务失效根因与跨分片ACID保障方案
2026/10/18 3:34:49 网站建设 项目流程

简介:本资源是一个基于SpringBoot、ShardingJDBC与MyBatis的轻量级分库分表实战项目,面向Java中高级开发者及分布式系统学习者,聚焦高并发场景下数据库水平扩展的核心痛点。项目完整呈现了从数据源配置、分片策略定义(如用户ID哈希取模)、MyBatis映射集成到多数据源透明路由的全流程实现,助读者深入理解ShardingJDBC在不侵入业务代码前提下的分片能力。压缩包共15个文件,含5个核心Java类(涵盖配置类、实体与Mapper接口)、2个XML映射文件、1个application.yaml配置文件、1个SQL建表脚本及README.md说明文档等,结构清晰、开箱即用,整体仅61KB,便于快速导入与本地验证。目前已有188人学习下载,提供可直接运行的最小可行示例,包含pom依赖管理、mvnw启动脚本及基础测试用例,是掌握分布式数据库中间件落地实践的优质入门参考。

1. ShardingSphere-JDBC:为什么一个“无侵入”的分库分表方案,反而在事务边界上最易翻车?

你写完@Transactional,加好@ShardingSphereDataSource,跑单元测试全绿,上线后某次批量导入却突然卡死、超时、数据不一致——日志里既没报错也没回滚,就像事务被悄悄吞掉了。这不是玄学,是 ShardingSphere-JDBC 在「逻辑事务」和「物理事务」之间埋下的一个静默断层。它不拦你写@Transactional,但也不保证这个注解能穿透到所有真实数据库连接;它号称「零代码改造」,可一旦跨分片更新+本地事务混用,你的 ACID 就会退化成「A(原子性)靠运气,C(一致性)看运气,I(隔离性)基本不存在,D(持久性)只对单库有效」。这份springboot_shardingjdbc_mybatis资源包,不是又一个 Demo 工程,而是一套经压测验证的「事务兜底链路」:它把ShardingTransactionManager的注册时机、ConnectionHolder的生命周期、XA与BASE模式切换的临界点,全部固化为可复现、可调试、可打断点的 Spring Boot 启动流程。搞懂它,不是为了学会怎么配sharding-jdbc-spring-boot-starter,而是为了下次看到TransactionSynchronizationManager.getResource()返回 null 时,你能立刻判断出——不是配置漏了,是连接池提前释放了物理连接,而逻辑事务还攥着一个已失效的ShardingConnection。


2. 分片路由与连接复用:为什么ShardingConnection不是Connection的简单包装?

ShardingSphere-JDBC 的核心抽象是ShardingConnection,但它绝非java.sql.Connection的轻量代理。它的存在,本质是为了解决「一个逻辑 SQL → 多个物理执行路径」带来的连接管理爆炸问题。若每个分片都持有一个独立Connection,100 个分片就要开 100 个连接池,内存、线程、网络握手成本直接翻百倍。ShardingConnection的设计哲学是:延迟物理连接获取 + 按需复用 + 生命周期绑定。它自身不持有任何真实连接,只维护一个Map<String, Connection>缓存(key 是数据源名),并在execute()或prepareStatement()时,根据当前 SQL 路由结果,动态从对应数据源获取连接并缓存。关键在于:这个缓存只在当前ShardingConnection实例生命周期内有效,且不会自动 close 物理连接——它把释放权交给了外部连接池(如 HikariCP)的回收策略。

2.1ShardingConnection的创建与销毁链路

Spring Boot 启动时,ShardingSphereAutoConfiguration会注入ShardingSphereDataSource,其getConnection()方法返回的是ShardingConnection实例:

// ShardingSphereDataSource.java(简化) @Override public Connection getConnection() throws SQLException { // 注意:此处不立即获取物理连接! return new ShardingConnection(this, getDatabaseType(), getRuntimeContext()); }

真正触发物理连接获取,是在 MyBatis 执行Executor.doUpdate()时调用StatementHandler.prepare(),进而走到ShardingConnection.prepareStatement():

// ShardingConnection.java(关键逻辑) @Override public PreparedStatement prepareStatement(final String sql) throws SQLException { // 1. 路由:解析 SQL,计算目标分片 Collection<RouteUnit> routeUnits = route(sql); // 2. 按分片分组,为每个数据源获取/复用连接 Map<String, Connection> connections = new HashMap<>(); for (RouteUnit each : routeUnits) { String dataSourceName = each.getDataSourceName(); // 从缓存中取,没有则向 HikariCP 申请 Connection conn = cachedConnections.computeIfAbsent(dataSourceName, name -> getDataSourceMap().get(name).getConnection()); connections.put(dataSourceName, conn); } // 3. 构建 ShardingPreparedStatement,封装多连接执行逻辑 return new ShardingPreparedStatement(this, sql, connections, routeUnits); }

提示:cachedConnections是ShardingConnection的成员变量,其生命周期与该实例完全一致。一旦ShardingConnection.close()被调用(通常由 Spring 的DataSourceUtils在事务结束时触发),它会遍历cachedConnections并对每个物理Connection调用close()—— 但注意,这实际是将连接归还给 HikariCP 连接池,而非真正断开 TCP。

2.2 为什么@Transactional在跨分片场景下「失效」?

根本原因在于 Spring 的事务管理器(DataSourceTransactionManager)只认DataSource,它通过DataSourceUtils.getConnection(dataSource)获取连接,并将其绑定到TransactionSynchronizationManager的ThreadLocal<Map<Object, Object>>中。而ShardingSphereDataSource返回的ShardingConnection,其hashCode()和equals()是重写的,不等于任何底层物理Connection。这意味着:

  • 当第一个分片 SQL 执行时,DataSourceUtils将ShardingConnection绑定到事务上下文;
  • 当第二个分片 SQL 执行时,ShardingConnection.prepareStatement()内部又去getDataSourceMap().get("ds_1").getConnection()拿新连接,但这个新连接并未被 Spring 事务管理器感知或管理;
  • 最终,ShardingConnection.close()被调用时,它尝试关闭所有缓存的物理连接,但其中部分连接可能已被 HikariCP 标记为“已归还”,导致SQLException: Connection is closed。

验证方法:在ShardingConnection.close()打断点,观察cachedConnections的 size 和各Connection的isClosed()状态。你会发现,有些连接早已被连接池回收,ShardingConnection却还在试图关它们。

2.3ShardingTransactionManager如何接管物理连接生命周期?

ShardingSphere-JDBC提供了ShardingTransactionManager(位于shardingsphere-transaction-core模块),它才是解决跨分片事务的正解。它不依赖 Spring 的DataSourceTransactionManager,而是直接操作ShardingConnection的底层Connection:

// ShardingTransactionManager.java(核心逻辑) @Override public void begin() throws TransactionException { // 1. 获取当前 ShardingConnection ShardingConnection connection = getConnection(); // 2. 遍历其所有缓存的物理连接,逐个开启事务 for (Connection physicalConn : connection.getCachedConnections().values()) { if (!physicalConn.getAutoCommit()) continue; physicalConn.setAutoCommit(false); // 关闭自动提交 } } @Override public void commit() throws TransactionException { // 3. 逐个提交所有物理连接 for (Connection physicalConn : connection.getCachedConnections().values()) { physicalConn.commit(); } }

注意:此模式要求所有分片使用同一事务类型(如全部XA或全部LOCAL)。资源包中的application-sharding-transaction.yml明确配置了transaction-type: XA,并集成了Atomikos作为XA事务协调器,确保两阶段提交(2PC)的原子性。


3. MyBatis 与 ShardingSphere 的拦截器协同:为什么Interceptor链必须重排?

MyBatis 的Executor、StatementHandler、ParameterHandler、ResultSetHandler四大组件均支持插件(Interceptor)。当 ShardingSphere-JDBC 介入后,ShardingPreparedStatement成为实际执行者,但 MyBatis 的默认插件链仍按PreparedStatement流程处理。若你在Executor插件中修改了 SQL(如添加租户 ID),而 ShardingSphere 的SQLRewriteEngine又在更早的StatementHandler.prepare()阶段重写了 SQL,两者就会冲突——你的租户字段可能被覆盖,或分片键解析失败。

3.1 ShardingSphere 的 SQL 解析与重写时机

ShardingSphere 的SQLRewriteEngine在ShardingPreparedStatement构造时即完成重写:

// ShardingPreparedStatement.java public ShardingPreparedStatement(ShardingConnection connection, String sql, Map<String, Connection> connections, Collection<RouteUnit> routeUnits) { this.connection = connection; // 关键:此处完成 SQL 重写(如分页、加密、分片键注入) this.rewriteSQL = SQLRewriteEngine.rewrite(sql, routeUnits); this.connections = connections; this.routeUnits = routeUnits; }

因此,任何 MyBatis 插件若想修改原始 SQL,必须在StatementHandler.prepare()之前生效。标准 MyBatis 插件链顺序是:Executor → StatementHandler → ParameterHandler → ResultSetHandler。而StatementHandler.prepare()是触发ShardingPreparedStatement构造的入口,所以你的插件必须成为Executor的第一个拦截器。

3.2 正确的插件注册顺序与实现

资源包中MyBatisConfig.java强制指定了插件加载顺序:

@Configuration public class MyBatisConfig { @Bean public ConfigurationCustomizer configurationCustomizer() { return configuration -> { // 1. 租户插件:必须最先,修改原始 SQL configuration.addInterceptor(new TenantSQLInterceptor()); // 2. 分页插件:在租户之后,避免分页参数被租户逻辑污染 configuration.addInterceptor(new PaginationInterceptor()); // 3. ShardingSphere 自带的插件(如有):最后,基于已重写 SQL 工作 }; } }

TenantSQLInterceptor的核心逻辑是:

// TenantSQLInterceptor.java @Override public Object intercept(Invocation invocation) throws Throwable { if (invocation.getTarget() instanceof Executor) { Object[] args = invocation.getArgs(); if (args.length > 0 && args[0] instanceof MappedStatement) { MappedStatement ms = (MappedStatement) args[0]; BoundSql boundSql = ms.getBoundSql(args[1]); String originalSql = boundSql.getSql(); // 注入 tenant_id 条件(仅 SELECT/UPDATE/DELETE) if (originalSql.trim().toUpperCase().startsWith("SELECT") || originalSql.trim().toUpperCase().startsWith("UPDATE") || originalSql.trim().toUpperCase().startsWith("DELETE")) { String tenantSql = " AND tenant_id = " + TenantContext.getCurrentTenantId(); String newSql = originalSql.replaceFirst("(WHERE|where)", "$0" + tenantSql); // 替换 BoundSql,确保后续流程使用新 SQL BoundSql newBoundSql = new BoundSql(ms.getConfiguration(), newSql, boundSql.getParameterMappings(), boundSql.getParameterObject()); // ...(复制其他属性) // 重新构建 MappedStatement MappedStatement newMs = copyMappedStatement(ms, newBoundSql); invocation.setArgs(new Object[]{newMs, args[1]}); } } } return invocation.proceed(); }

关键点:invocation.setArgs()直接替换了MappedStatement,确保StatementHandler.prepare()拿到的是已注入租户条件的 SQL,再交给ShardingPreparedStatement做分片路由——此时分片键(如user_id)和租户键(tenant_id)都在 WHERE 条件中,路由引擎能正确识别。

3.3 常见问题:为什么@SelectProvider动态 SQL 总是路由到 default 数据源?

现象:你用@SelectProvider生成 SQL,但ShardingSphere日志显示Actual SQL: ds_default.xxx,所有分片都未命中。

原因:@SelectProvider的方法在Mapper接口被代理时就执行,生成的 SQL 字符串被直接塞进BoundSql。而ShardingSphere的SQLParseEngine默认只解析静态 SQL(@Select("...")),对@SelectProvider返回的字符串不做二次解析,导致无法提取分片键。

解决:强制启用sql-parser的动态解析支持,在application.yml中添加:

spring: shardingsphere: props: sql-show: true # 启用对动态 SQL 的解析(需 ShardingSphere 5.3.0+) sql-parser: true

并在@SelectProvider方法中,确保返回的 SQL 包含明确的分片键别名:

public class UserSqlProvider { public String selectByUserId(Map<String, Object> params) { Long userId = (Long) params.get("userId"); // 必须显式写出分片键列名,不能用 * 或别名 return "SELECT id, name FROM t_user WHERE user_id = " + userId; } }

4. 分片键设计与ShardingAlgorithm实现:为什么ModuloShardingAlgorithm在扩容时必然数据迁移?

分片算法是 ShardingSphere 的心脏。ModuloShardingAlgorithm(取模算法)看似简单:shardingValue % actualDataNodes.size(),但它隐含一个致命假设——分片节点数永远不变。一旦你从 4 个库扩容到 8 个库,原user_id=5(5%4=1)落在ds_1,新规则5%8=5却要查ds_5,数据就找不到了。这不是 Bug,是数学本质:模运算不具备可扩展性。

4.1StandardShardingAlgorithm与ComplexKeysShardingAlgorithm的适用边界

资源包中ShardingAlgorithmConfig.java定义了两种核心算法:

  • StandardShardingAlgorithm:适用于单一分片键(如user_id),支持PreciseShardingValue(=)、RangeShardingValue(BETWEEN);
  • ComplexKeysShardingAlgorithm:适用于复合分片键(如user_id + order_time),需同时满足多个键的路由逻辑。
// StandardShardingAlgorithm 示例:按 user_id 取模,但支持自定义模数 public class CustomModuloAlgorithm implements StandardShardingAlgorithm<Long> { private int shardingCount = 4; // 可配置,非硬编码 @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) { long value = shardingValue.getValue(); // 使用可配置的模数,便于后期调整 int targetIndex = (int) (value % shardingCount); return availableTargetNames.stream() .sorted() // 确保顺序稳定 .skip(targetIndex) .findFirst() .orElseThrow(() -> new UnsupportedOperationException("No target datasource")); } }

设计取舍:CustomModuloAlgorithm将shardingCount提取为配置项,虽不能解决扩容迁移,但允许你在不改代码的情况下,通过配置sharding-count: 8切换分片数(前提是旧数据已手动迁移)。

4.2HintShardingAlgorithm:为什么它是唯一能绕过 SQL 解析的路由方式?

当 SQL 中无法显式写出分片键(如SELECT * FROM t_user全表扫描),或业务需要强制路由到某库(如后台管理查所有用户),HintShardingAlgorithm是唯一选择。它不依赖 SQL 解析,而是通过ThreadLocal注入路由线索:

// 后台管理接口 @GetMapping("/all-users") public List<User> findAllUsers() { // 强制所有查询走 ds_0 HintManager hintManager = HintManager.getInstance(); hintManager.addDatabaseShardingValue("t_user", "ds_0"); try { return userMapper.selectAll(); } finally { hintManager.close(); // 必须关闭,否则污染后续请求 } }

HintShardingAlgorithm的doSharding()实现极简:

@Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<String> shardingValue) { // 直接返回 Hint 中指定的值,无视 SQL 内容 return shardingValue.getValue(); }

注意:HintManager是ThreadLocal,close()必须在finally块中执行。资源包中HintShardingAlgorithm的单元测试HintShardingTest.java专门验证了close()后ThreadLocal是否清空,避免线程复用导致路由错乱。

4.3 常见问题排查:分片键解析失败的 3 个血泪现场

现象 1:ShardingSphere日志显示Can't find sharding column,但 SQL 明明有WHERE user_id = ?

原因:MyBatis 的#{}占位符在BoundSql中被解析为?,而ShardingSphere的SQLParseEngine默认只解析#{}中的变量名(如#{userId}),若 Mapper XML 中写的是WHERE user_id = #{id},但ParameterMap里 key 是userId,则解析失败。

解决:统一参数名。在@Param("userId") Long id或 XML 中<select id="select" parameterType="map">里,确保#{}内的名称与ParameterMapkey 严格一致。

现象 2:INSERT INTO t_user (id, name) VALUES (?, ?)路由到ds_default,但id是分片键

原因:INSERT语句的分片键必须在VALUES子句中显式提供,且ShardingSphere默认只解析VALUES中的字面量(如VALUES (1, 'a')),对?占位符需依赖ParameterMetaData。若驱动不支持(如老版本 MySQL Connector/J),则无法获取参数类型,导致解析失败。

解决:升级 JDBC 驱动至mysql-connector-java:8.0.33+,并在application.yml中启用参数元数据:

spring: datasource: hikari: >// ShardingRuleConfig.java shardingRuleConfig.getShardingAlgorithms().put("t_user-db-strategy", new ShardingSphereAlgorithmConfiguration("MOD", Collections.singletonMap("sharding-count", "4"))); // 添加 IN 拆分阈值 shardingRuleConfig.getProps().setProperty("sql-route-cache-max-size", "2000"); shardingRuleConfig.getProps().setProperty("sql-route-in-max-size", "500"); // IN 最大拆分 500 个

5. 生产级事务兜底与监控:为什么TransactionLogStorage必须独立于业务库?

当XA事务因网络抖动、协调器宕机而卡在PREPARE状态,ShardingSphere需要一种机制来恢复悬挂事务(Heuristic Exception)。TransactionLogStorage就是这个「后悔药」——它持久化每个XA事务的XID、状态、分支信息,供后台线程定期扫描并重试commit或rollback。若把它放在业务分片库中,一旦该库宕机,悬挂事务将永久丢失,数据强一致性彻底崩溃。

5.1TransactionLogStorage的独立部署与选型

资源包采用JDBC类型的TransactionLogStorage,但强制指向一个独立的、非分片的数据库(ds_transaction_log):

# application-sharding-transaction.yml spring: shardingsphere: transaction: default-type: XA provider-type: Atomikos # 事务日志存储,必须独立于业务分片库 log: storage: type: JDBC props: datasource: ds_transaction_log # 单独的数据源

ds_transaction_log的配置在application-datasource.yml中单独定义:

spring: datasource: transaction-log: jdbc-url: jdbc:mysql://log-db:3306/sharding_transaction_log?serverTimezone=UTC username: log_user password: log_pass driver-class-name: com.mysql.cj.jdbc.Driver

关键设计:ds_transaction_log不参与任何分片规则,它只是一个纯粹的、高可用的 MySQL 实例(建议主从架构),专用于存储事务日志。ShardingSphere的TransactionLogService会定期(默认 30s)轮询此库,查询TRANSACTION_LOG表中status = 'PREPARED'的记录并发起恢复。

5.2TRANSACTION_LOG表结构与数据一致性保障

TRANSACTION_LOG表由ShardingSphere自动建表(首次启动时),其 DDL 如下:

CREATE TABLE `TRANSACTION_LOG` ( `xid` varchar(128) NOT NULL COMMENT 'XA 事务全局 ID', `transaction_type` varchar(8) NOT NULL COMMENT '事务类型:XA/BASE', `status` varchar(16) NOT NULL COMMENT '状态:PREPARED/COMMITTED/ROLLED_BACK/UNKNOWN', `retried_count` int NOT NULL DEFAULT '0' COMMENT '重试次数', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `last_modified_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`xid`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分布式事务日志表';

为保障日志写入的绝对可靠,资源包中TransactionLogDataSourceConfig.java对ds_transaction_log进行了强化:

  • 连接池最小空闲连接设为5,避免冷启动时建连延迟;
  • connection-timeout: 30000(30秒),防止网络抖动导致日志写入失败;
  • 启用cachePrepStmts: true和useServerPrepStmts: true,提升批量日志插入性能。

5.3 生产监控:如何从日志中定位悬挂事务的根因?

当TransactionLogService扫描到status = 'PREPARED'且retried_count > 3的记录时,它会记录 WARN 日志并触发告警。典型日志如下:

WARN [TransactionLogService] - Found prepared transaction xid=192.168.1.100:37212:1234567890, retried=4, status=PREPARED. Will retry commit.

此时需立即检查:

  1. xid中的192.168.1.100:37212是协调器 IP 和端口,确认Atomikos进程是否存活;
  2. 1234567890是事务序列号,登录ds_transaction_log库,查该xid的完整记录,看last_modified_time是否停滞;
  3. 检查对应业务库(如ds_0,ds_1)的XA RECOVER输出,确认分支事务是否真处于PREPARED状态。

避坑指南:XA RECOVER需在对应物理库上执行,且用户需有PROCESS权限。资源包附带check-xa-recover.sh脚本,可一键遍历所有分片库执行XA RECOVER并汇总结果。


6. 从ShardingSphere-JDBC到ShardingSphere-Proxy:为什么我的分片键设计必须兼容两种模式?

ShardingSphere-JDBC是客户端分片,ShardingSphere-Proxy是服务端分片。前者嵌入应用进程,后者是独立中间件。你今天用JDBC,明天可能因多语言(Go/Python)接入或 DBA 统一管控需求,迁移到Proxy。而Proxy的分片规则配置(config-sharding.yaml)与JDBC的application.yml语法不同、能力不同、解析引擎也不同。若你的分片键设计只考虑JDBC,迁移时大概率要重写 SQL、重构分片算法、甚至修改表结构。

6.1JDBC与Proxy的分片键解析差异对比

特性ShardingSphere-JDBCShardingSphere-Proxy
SQL 解析引擎SQLParseEngine(基于 ANTLR4,支持复杂表达式)SQLParseEngine(同源,但 Proxy 侧默认禁用部分高级特性)
分片键来源支持#{}、${}、@SelectProvider、Hint仅支持#{}和Hint,@SelectProvider无效(Proxy 不解析 Java 代码)
IN路由可配置sql-route-in-max-size固定最大 1000,不可调
BETWEEN路由支持user_id BETWEEN ? AND ?仅支持字面量BETWEEN 1 AND 100,不支持参数

这意味着:若你在JDBC中依赖@SelectProvider生成动态分片键,迁移到Proxy时必须改用Hint或重构为静态 SQL;若你用BETWEEN ? AND ?做范围查询,Proxy会直接路由到所有库,丧失分片意义。

6.2 统一分片键设计的 3 条铁律

为保障平滑迁移,资源包在ShardingKeyDesign.md中确立了以下规范:

  1. 分片键必须是WHERE子句中的独立等值条件
    ✅WHERE user_id = ?
    ❌WHERE user_id + 1 = ?(表达式不解析)、WHERE user_id IN (?, ?, ?)(IN列表长度不确定)

  2. 禁止在INSERT的VALUES中使用函数或子查询
    ✅INSERT INTO t_user (id, name) VALUES (?, ?)
    ❌INSERT INTO t_user (id, name) VALUES (UUID(), ?)(UUID()不被解析为分片值)

  3. 所有分片键字段必须声明为NOT NULL
    原因:Proxy在解析NULL值时行为不一致,可能导致路由到default库;JDBC虽能处理,但为统一,强制NOT NULL。

6.3 迁移验证 checklist:一份不能跳过的清单

在决定从JDBC切换到Proxy前,必须逐条验证:

检查项验证方法不通过后果
所有SELECT/UPDATE/DELETE的分片键是否均为=条件?grep -r "WHERE.*user_id" src/main/resources/mapper/ | grep -v "="Proxy路由失败,全库扫描
是否存在@SelectProvider生成的分片键?grep -r "@SelectProvider" src/main/java/Proxy完全忽略,路由到default
INSERT语句的分片键是否总是第一个VALUES参数?检查INSERT INTO t_user (id, name) VALUES (?, ?)中id是否为第一列Proxy可能误判分片键位置
BETWEEN查询是否全部替换为>= AND <=?grep -r "BETWEEN" src/main/resources/mapper/Proxy无法解析,全库扫描

我的教训:去年在某跨平台系统中,我们曾因一条@SelectProvider生成的WHERE user_id IN ${ids}(字符串拼接)未被发现,上线Proxy后,所有用户查询都变慢 10 倍。从那以后我每次做分片设计评审,都强制走一遍这份 checklist,并用shardingsphere-sql-parser的 CLI 工具对所有 Mapper XML 文件做离线解析验证——把Proxy的解析能力当成编译器,让错误在开发阶段就暴露。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询