分库分表与多数据源切换的本质区别与协同落地 简介本资源是一份基于Sharding-JDBC实现分库分表与多数据源动态切换的完整Java Web实战项目面向中高级Java开发者及分布式架构学习者聚焦高并发、大数据量场景下的数据库水平扩展痛点。项目以SSMDemo为载体涵盖分片规则配置、跨库路由逻辑、分布式事务集成等核心实践帮助读者快速掌握企业级分库分表落地能力。压缩包共73个文件含18个Java业务与配置类、21个编译后class文件、18个XML配置与映射文件如MyBatis映射、Spring上下文、3个properties配置项及SQL建表脚本等结构清晰体现典型SSMSharding-JDBC工程组织方式整体仅66KB轻量易导入。目前已有2068人学习下载资源附带可直接运行的pom依赖、sharding-config.yaml分片配置、mybatistest.sql初始化脚本及完整IDEA项目元信息.project/.classpath/.settings等开箱即用便于对照博文深入理解分片策略定制与多数据源切换机制。1. 分库分表不是加个注解就完事它本质是把单点数据库的“心脏”拆成多个可独立跳动的腔室而多数据源切换才是让应用在这些腔室间精准供血的神经反射弧你写完ShardingSphereDataSource或DS(slave1)服务一跑——查不到数据、事务报No transaction in progress、连表查询直接抛Table db2.user_order doesnt exist。这不是配置漏了是根本没搞清分库分表解决的是数据规模爆炸后的存储与路由问题而多数据源切换解决的是运行时动态访问不同物理库的能力问题。二者常被混为一谈但技术栈完全不重叠——ShardingSphere 做逻辑分片路由Spring Boot 多数据源做 DataSource 实例管理MyBatis Plus 的DS只是轻量级路由标记。本篇拆解的是一套真实落地过电商订单用户中心双写场景的完整方案用AbstractRoutingDataSource手动控制数据源选择配合ShardingSphere-JDBC做分片内路由再用TransactionSynchronizationManager拦截事务传播边界。不依赖任何黑盒中间件控制台所有逻辑可 debug、可断点、可压测。适合已踩过DS在事务中失效、sharding-jdbc无法跨库 join、读写分离延迟导致脏读这三类坑的中级以上后端工程师。2. 分库分表与多数据源两个目标、三层抽象、四类选型依据2.1 为什么不能只用 ShardingSphere 代理所有流量ShardingSphere-JDBC嵌入式和 Proxy代理式都默认将 SQL 路由到对应分片但它不负责数据源实例的生命周期管理。比如你有ds_master_01、ds_slave_01、ds_master_02三个物理数据源ShardingSphere 只知道“这条INSERT INTO t_order_01应该发给ds_master_01”但它不关心ds_master_01是从 HikariCP 还是 Druid 创建的、是否启用了读写分离、连接池参数是否合理。更关键的是当你要执行一条跨分片的UPDATE如修改用户主库 订单分库ShardingSphere 会拒绝执行并抛UnsupportedOperationException——它设计上就不支持分布式事务的强一致性。此时必须靠应用层自己控制先切到用户库更新t_user再切到订单库更新t_order_01再手动 commit 两个事务。这个“切”的动作就是多数据源切换的核心。2.2 三层抽象从物理库到逻辑表的映射链条抽象层级典型实现关键职责不可替代性物理数据源层HikariDataSource实例、JNDI 数据源管理 JDBC 连接、连接池、超时、健康检查Spring BootConfigurationProperties(prefixspring.datasource)直接绑定路由决策层AbstractRoutingDataSource子类、ShardingSphereDataSource根据上下文如线程变量、方法注解返回具体DataSource实例DS注解底层就是靠它实现determineCurrentLookupKey()SQL 重写层ShardingSphere-JDBC的SQLRouter、SQLRewriteEngine将SELECT * FROM t_order WHERE user_id123改写为SELECT * FROM t_order_01 WHERE user_id123绕过此层直接操作分表名会破坏分片规则导致数据错乱提示很多团队误以为引入 ShardingSphere 就自动拥有了多数据源能力结果发现DS(slave)在ShardingSphereDataSource下完全不生效——因为 ShardingSphere 自己封装了一套DataSource绕过了 Spring 的AbstractRoutingDataSource机制。必须显式配置ShardingSphereDataSource作为AbstractRoutingDataSource的 targetSource 之一才能共存。2.3 四类选型依据什么场景该用 ShardingSphere什么该手写路由必须用 ShardingSphere 的场景高频单表查询如订单详情页、范围查询WHERE create_time BETWEEN ? AND ?、分页LIMIT 100,20。它的 SQL 解析器能准确提取分片键避免全库扫描。必须手写AbstractRoutingDataSource的场景读写分离主库写 从库读、多租户隔离每个租户独立库、灰度发布新老库并行写。ShardingSphere 的读写分离插件对事务内读操作支持不稳容易从从库读到未提交数据。两者必须共存的场景分库分表 读写分离。例如t_order_01表在ds_master_01写和ds_slave_01读两个物理库中存在ShardingSphere 负责把t_order_01路由到ds_master_01或ds_slave_01而AbstractRoutingDataSource负责决定当前请求走 master 还是 slave。绝对不该用的场景简单水平拆分如按 ID 取模分 4 库且无复杂查询需求。此时用 MyBatis Plus 的TableName(t_order_${shardId})DS(ds_${shardId})手动拼表名数据源比引入 ShardingSphere 节省 300MB 内存、减少 12 个依赖包。2.4 为什么DS注解在事务中会失效根源在 Spring 的事务同步机制Spring 的DataSourceTransactionManager在开启事务时会调用doBegin()方法获取Connection并绑定到TransactionSynchronizationManager的resourcesThreadLocal 中。后续所有 DAO 操作都复用这个 Connection完全绕过AbstractRoutingDataSource.determineCurrentLookupKey()。所以即使你在Transactional方法里执行DynamicDataSourceContextHolder.set(slave)事务内的 SQL 仍走原数据源。验证方式在Transactional方法内打日志输出TransactionSynchronizationManager.getResource(dataSource)的 hashcode你会发现它和determineCurrentLookupKey()返回的 key 完全无关。3. 手写AbstractRoutingDataSource从线程变量到事务感知的七步落地3.1 第一步定义数据源上下文持有器ThreadLocal 可继承public class DynamicDataSourceContextHolder { private static final ThreadLocalString CONTEXT_HOLDER new InheritableThreadLocal(); public static void set(String key) { CONTEXT_HOLDER.set(key); } public static String get() { return CONTEXT_HOLDER.get(); } public static void clear() { CONTEXT_HOLDER.remove(); } }注意必须用InheritableThreadLocal否则Async异步方法或CompletableFuture子线程无法继承父线程的数据源 key。普通ThreadLocal在线程池中会被复用导致 key 泄露。3.2 第二步实现路由数据源支持 fallback 和空值保护public class DynamicRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { String key DynamicDataSourceContextHolder.get(); // 关键如果未设置key返回默认数据源避免NPE if (key null || key.trim().isEmpty()) { return default; } // 检查targetDataSources中是否存在该key防止运行时异常 if (!this.getResolvedDataSources().containsKey(key)) { log.warn(Dynamic data source key [{}] not found, use default, key); return default; } return key; } }逻辑说明determineCurrentLookupKey()是 Spring 调用的钩子方法每次getConnection()前触发。这里做了两层防护一是空值 fallback 到default二是校验 key 是否存在于targetDataSources中。否则一旦DS(nonexistent)会直接抛IllegalStateException: Cannot determine target DataSource for lookup key [nonexistent]。3.3 第三步配置多数据源 Bean明确区分 master/slave/tenantConfiguration public class DataSourceConfig { Bean ConfigurationProperties(spring.datasource.master) public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(spring.datasource.slave01) public DataSource slave01DataSource() { return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(spring.datasource.tenant_a) public DataSource tenantADataSource() { return DataSourceBuilder.create().build(); } Bean public DataSource dynamicDataSource() { DynamicRoutingDataSource dynamicRoutingDataSource new DynamicRoutingDataSource(); MapObject, Object targetDataSources new HashMap(); targetDataSources.put(master, masterDataSource()); targetDataSources.put(slave01, slave01DataSource()); targetDataSources.put(tenant_a, tenantADataSource()); targetDataSources.put(default, masterDataSource()); // 默认走主库 dynamicRoutingDataSource.setTargetDataSources(targetDataSources); dynamicRoutingDataSource.setDefaultTargetDataSource(masterDataSource()); return dynamicRoutingDataSource; } }参数说明targetDataSources的 key 必须与DynamicDataSourceContextHolder.set()传入的字符串完全一致大小写敏感。defaultTargetDataSource是兜底项当determineCurrentLookupKey()返回 null 或未匹配时使用。此处设为masterDataSource()确保写操作总有库可用。3.4 第四步编写 AOP 切面解析DS注解并注入上下文Aspect Component public class DataSourceAspect { Pointcut(annotation(com.example.annotation.DS)) public void dsAnnotationPointcut() {} Around(dsAnnotationPointcut()) public Object around(ProceedingJoinPoint joinPoint) throws Throwable { MethodSignature signature (MethodSignature) joinPoint.getSignature(); DS ds signature.getMethod().getAnnotation(DS.class); if (ds ! null StringUtils.hasText(ds.value())) { DynamicDataSourceContextHolder.set(ds.value()); } try { return joinPoint.proceed(); } finally { // 必须清除否则ThreadLocal泄露 DynamicDataSourceContextHolder.clear(); } } }关键细节finally块中强制clear()这是血泪经验。曾在线上环境因忘记清除导致 Tomcat 线程池复用时后续请求继承了前一个请求的slavekey大量写操作误发到从库引发主从不一致。3.5 第五步事务内数据源切换的终极解法——TransactionSynchronizationComponent public class TransactionAwareDataSourceSwitcher { EventListener public void handleContextRefresh(ContextRefreshedEvent event) { // 在Spring容器初始化完成后注册同步器 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronizationAdapter() { Override public void beforeCommit(boolean readOnly) { // 事务提交前强制切回主库避免从库提交失败 DynamicDataSourceContextHolder.set(master); } Override public void afterCompletion(int status) { // 事务结束后清理防止影响下一个请求 DynamicDataSourceContextHolder.clear(); } } ); } }原理TransactionSynchronization是 Spring 事务的回调接口在事务生命周期各阶段触发。beforeCommit确保最终写操作一定在主库执行afterCompletion清理 ThreadLocal比 AOP 的finally更可靠——因为 AOP 可能被异常中断而事务同步器由 Spring 保证执行。3.6 第六步YAML 配置文件按环境隔离数据源参数spring: datasource: # 主库写 master: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://10.0.1.10:3306/order_db?useSSLfalseserverTimezoneAsia/Shanghai username: root password: password hikari: maximum-pool-size: 20 connection-timeout: 3000 # 从库读 slave01: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://10.0.1.11:3306/order_db?useSSLfalseserverTimezoneAsia/Shanghai username: readonly password: readonly123 hikari: maximum-pool-size: 15 read-only: true # 租户库 tenant_a: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://10.0.1.12:3306/tenant_a_db?useSSLfalseserverTimezoneAsia/Shanghai username: tenant_a password: tenant_a_pass注意hikari.read-only: true是关键参数它会让 HikariCP 在创建连接时调用connection.setReadOnly(true)MySQL 会据此优化执行计划如禁用某些锁提升从库查询性能。3.7 第七步单元测试验证路由正确性与事务隔离SpringBootTest class DataSourceRoutingTest { Autowired private JdbcTemplate jdbcTemplate; Test void testMasterRoute() { DynamicDataSourceContextHolder.set(master); String db jdbcTemplate.queryForObject(SELECT DATABASE(), String.class); assertEquals(order_db, db); // 断言连接到了主库 } Test Transactional void testTransactionFallbackToMaster() { DynamicDataSourceContextHolder.set(slave01); // 在事务中执行插入应被beforeCommit切回master jdbcTemplate.update(INSERT INTO t_test (name) VALUES (?), test); // 验证数据在主库而非从库 Integer count jdbcTemplate.queryForObject( SELECT COUNT(*) FROM t_test WHERE name ?, Integer.class, test); assertEquals(1, count); } }验证逻辑testMasterRoute测试基础路由testTransactionFallbackToMaster测试事务内beforeCommit是否生效——即使初始设为slave01插入操作也必须成功且数据可查证明路由已切回主库。4. 避坑分库分表与多数据源切换的五个真实翻车现场4.1 现象DS(slave)在Async方法中始终走默认库原因Async创建新线程ThreadLocal不继承DynamicDataSourceContextHolder.get()返回 null触发 fallback 到default。解决改用InheritableThreadLocal已在 3.1 步骤实现并在Async方法开头手动set()Async public void asyncTask() { DynamicDataSourceContextHolder.set(slave01); // 显式设置 // ...业务逻辑 DynamicDataSourceContextHolder.clear(); // 清理 }4.2 现象ShardingSphere 与AbstractRoutingDataSource冲突DS完全不生效原因ShardingSphereDataSource是一个独立的DataSource实现它内部维护自己的连接池和路由逻辑不经过 Spring 的AbstractRoutingDataSource。解决将ShardingSphereDataSource作为DynamicRoutingDataSource的一个 targetBean public DataSource shardingDataSource() { // 构建 ShardingSphereDataSource略 return shardingSphereDataSource; } // 在 dynamicDataSource() 中添加 targetDataSources.put(sharding, shardingDataSource());然后在业务代码中DynamicDataSourceContextHolder.set(sharding)由 ShardingSphere 处理分片DynamicRoutingDataSource只负责选择sharding这个逻辑数据源。4.3 现象读写分离下刚写入的数据立即查询返回空原因MySQL 主从复制存在延迟seconds_behind_master 0从库尚未同步完成。解决对强一致性要求高的场景如支付结果页禁止走从库DS(master) // 强制走主库 public Order getOrderById(Long id) { return orderMapper.selectById(id); }或实现「主库读」策略在DynamicRoutingDataSource.determineCurrentLookupKey()中若当前线程存在forceMaster标记则忽略DS注解。4.4 现象TransactionalDS(slave)导致事务不生效Transactional注解被忽略原因Spring 的Transactional依赖DataSourceTransactionManager而该 Manager 绑定的是dynamicDataSource()但DS(slave)只在 DAO 层生效事务管理器仍用默认数据源。解决永远不要在事务方法上标注DS。正确做法是写操作Transactional 不加DS走默认 master读操作不加TransactionalDS(slave)混合操作拆分为两个方法写方法加Transactional读方法加DS(slave)并用Transactional(propagation Propagation.NOT_SUPPORTED)隔离。4.5 现象分库后COUNT(*)查询变慢执行计划显示ALL全表扫描原因ShardingSphere 对聚合函数的支持有限COUNT(*)会被路由到所有分片执行再合并结果网络 IO CPU 开销大。解决方案一用COUNT(1)替代COUNT(*)部分 MySQL 版本优化更好方案二对非精确场景用 Redis HyperLogLog 预估数量方案三在分片键上建索引并确保WHERE条件包含分片键使COUNT路由到单一分片-- ✅ 正确带分片键路由到 t_order_01 SELECT COUNT(*) FROM t_order WHERE user_id 123 AND status PAID; -- ❌ 错误无分片键广播到所有分片 SELECT COUNT(*) FROM t_order WHERE status PAID;5. 进阶技巧用ShardingSphere-JDBC的Hint机制绕过分片规则实现跨库关联查询5.1 为什么需要 Hint标准分片路由无法处理的三类场景后台管理查询运营人员要查“所有用户近 7 天订单数”SQL 无分片键user_id必须广播到所有分片。数据迁移校验对比新旧分库数据一致性需JOIN两个不同库的表。临时修复脚本紧急修复某类脏数据需跨分片UPDATE。ShardingSphere 提供HintManager允许在代码中强制指定分片值或跳过分片不依赖 SQL 解析100% 可控。5.2 实战用 Hint 实现跨分片JOIN用户表 订单表假设t_user在ds_master_01t_order_01在ds_master_01t_order_02在ds_master_02。标准 SQLSELECT u.name, o.total FROM t_user u JOIN t_order o ON u.id o.user_id会被 ShardingSphere 拒绝CrossShardingQueryException。用 Hint 绕过Service public class CrossShardService { Autowired private JdbcTemplate jdbcTemplate; public ListMapString, Object crossShardJoin() { // Step 1: 启用 Hint指定分片值强制路由到 t_order_01 HintManager hintManager HintManager.getInstance(); hintManager.setDatabaseShardingValue(ds_master_01); // 指定库 hintManager.setTableShardingValue(t_order_01); // 指定表 try { // Step 2: 执行 JOIN此时 ShardingSphere 不解析 SQL直接发往指定库表 return jdbcTemplate.queryForList( SELECT u.name, o.total FROM t_user u JOIN t_order_01 o ON u.id o.user_id ); } finally { // Step 3: 必须关闭 Hint否则污染后续请求 hintManager.close(); } } }关键参数说明setDatabaseShardingValue(ds_master_01)强制所有 SQL 发送到ds_master_01数据源setTableShardingValue(t_order_01)强制表名为t_order_01跳过分片算法hintManager.close()释放 ThreadLocal 资源否则下次请求可能继承错误的 Hint5.3 Hint 的三种模式对比与选型建议模式触发方式适用场景风险强制分片值hintManager.addDatabaseShardingValue(t_order, user_id, 123)传入分片键值精确查询单条记录如SELECT * FROM t_order WHERE user_id ?若值不存在分片查询返回空强制分片库hintManager.setDatabaseShardingValue(ds_master_01)指定物理库名后台统计、跨库 JOIN、数据迁移需确保目标库存在对应表否则Table not found禁用分片hintManager.setMasterRoute()无参数强制走主库读写分离场景无风险但丧失从库读能力5.4 生产环境必须加的 Hint 安全防护Hint 是一把双刃剑线上误用会导致全库扫描甚至数据错乱。必须加两层防护环境开关仅在dev和test环境启用 Hintprod环境HintManager.setEnabled(false)白名单校验在HintManager封装层拦截非法操作public class SafeHintManager { private static final SetString ALLOWED_TABLES Set.of(t_order, t_user); public static void setTableShardingValue(String tableName, String value) { if (!ALLOWED_TABLES.contains(tableName)) { throw new IllegalArgumentException(Hint not allowed on table: tableName); } HintManager.getInstance().setTableShardingValue(tableName); } }从那以后我每次写 Hint 逻辑都强制走一遍SafeHintManager封装再加单元测试覆盖白名单校验。曾经有同事在生产环境直接调HintManager.setTableShardingValue(t_order_999)导致 ShardingSphere 创建不存在的表名连接耗尽连接池。希望帮到你。本文还有配套的精品资源点击获取