☰
Spring Boot多数据源与分库分表协同控制流设计
2026/10/27 13:01:58 网站建设 项目流程

简介:本资源是一套基于Spring Boot的多数据源与分库分表实战项目,面向Java后端开发者及数据库架构进阶学习者,聚焦高并发场景下的数据层扩展难题。项目整合Dynamic-Datasource实现多数据源动态路由,采用Sharding-JDBC完成逻辑库表到物理库表的透明分片,并通过Druid连接池统一管理数据库连接;同时集成MyBatis-Plus提升DAO层开发效率,配合Lombok精简POJO代码,辅以JUnit完成核心业务测试。压缩包共162个文件,含115个XML映射文件(定义SQL与实体关系)、14个Java类(涵盖Controller、Service、Mapper及测试类)、6个YML配置(多数据源与分片规则)、以及日志、构建脚本等辅助文件,整体仅164KB,结构紧凑、开箱即用。已有624人学习下载,读者可直接获取完整可运行工程、清晰的分库分表配置范例、典型业务(如订单与用户)的分片实践代码,以及基于真实类名(OrderController、UserTestMapper等)组织的模块化目录结构,便于快速理解与二次开发。

1. 多数据源 + 分库分表:不是加了@DS就能扛住百万并发,而是先得让 Spring Boot 知道「哪条 SQL 该发给哪个库、哪个表」

你写完@DS("slave1"),接口跑通了;配好 ShardingSphere 的sharding.yaml,SELECT * FROM t_order也能自动路由到t_order_0001;但一上压测,QPS 刚过 3000,连接池就报HikariPool-1 - Connection is not available,日志里全是No route path for table t_order或Can't determine the actual tables。这不是配置漏了,是 Spring Boot 的数据源生命周期、MyBatis 的SqlSession绑定机制、分库分表中间件的路由上下文三者在底层「抢内存、争线程、互锁死」——它们根本没对齐时间窗口。这份资源不是一堆 YAML 和注解的拼凑包,而是一套经过某高校分布式系统实验室三年压测验证的Spring Boot 多数据源与分库分表协同控制流设计:它把DataSource的创建、TransactionSynchronization的注册、ShardingSphere的RouteContext注入、MyBatis的ExecutorType切换,全部收束进一个可插拔的RoutingDataSourceHolder中,用ThreadLocal<DataSourceKey>做唯一锚点,确保「事务开始那一刻,数据源、分片键、表路由路径」三者原子绑定。搞懂它,你才能真正把「多数据源」从负载均衡开关,变成分库分表的确定性执行引擎。


2. 数据源隔离的本质:不是配多个DataSourceBean,而是让 Spring 的AbstractRoutingDataSource在每次getConnection()时,精准命中「当前事务上下文所声明的物理库」

Spring Boot 默认只管理一个DataSourceBean,而多数据源的核心矛盾在于:同一个JdbcTemplate或MyBatis SqlSession实例,在一次 HTTP 请求中可能需要访问主库写订单、从库查用户、归档库读历史——但DataSource是单例,getConnection()调用却必须返回不同物理连接。AbstractRoutingDataSource就是 Spring 提供的“路由开关”,但它本身不存状态,也不管事务——它的determineCurrentLookupKey()方法,只是个钩子,返回值决定从targetDataSourcesMap 中取哪个DataSource。真正的难点在于:这个“当前 key”从哪来?谁负责设置?什么时候清除?

2.1RoutingDataSourceHolder:用ThreadLocal封装「数据源决策权」,而非「数据源实例」

这份资源没有直接继承AbstractRoutingDataSource,而是封装了一个RoutingDataSourceHolder工具类,其核心是:

public class RoutingDataSourceHolder { private static final ThreadLocal<DataSourceKey> CONTEXT_HOLDER = ThreadLocal.withInitial(() -> DataSourceKey.MASTER); public static void setDataSourceKey(DataSourceKey key) { CONTEXT_HOLDER.set(key); } public static DataSourceKey getDataSourceKey() { return CONTEXT_HOLDER.get(); } public static void clear() { CONTEXT_HOLDER.remove(); } }

注意:DataSourceKey是一个枚举(MASTER,SLAVE_1,SLAVE_2,ARCHIVE),不是字符串。这避免了拼写错误导致的路由失败,也便于后续扩展为带权重的动态 key。关键点在于:clear()必须在请求结束或事务提交后强制调用——否则线程复用(Tomcat 线程池)会导致下一个请求误用上一个请求的数据源 key。

2.2DynamicRoutingDataSource:把determineCurrentLookupKey()变成「可信决策出口」

public class DynamicRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { // 不再依赖 @DS 注解的 AOP 切面临时设置, // 而是信任 RoutingDataSourceHolder 的权威性 DataSourceKey key = RoutingDataSourceHolder.getDataSourceKey(); log.debug("Routing to datasource: {}", key); return key; } }

这里去掉了所有「尝试从方法签名、注解、参数里推断 key」的玄学逻辑。为什么?因为determineCurrentLookupKey()在getConnection()时被调用,而此时 Spring 的 AOP 代理可能尚未触发(比如@Transactional的TransactionInterceptor在invokeWithinTransaction中才执行),导致@DS注解还没生效,determineCurrentLookupKey()就已返回默认值。这份资源强制要求:所有数据源切换,必须显式调用RoutingDataSourceHolder.setDataSourceKey(...),且只能在事务边界内(如@Transactional方法开头)。

2.3 与@Transactional的生死绑定:为什么@DS注解必须配合Propagation.REQUIRED

很多项目踩坑:在非事务方法里用@DS("slave1"),结果还是走主库。原因在于@DS的 AOP 切面(如DataSourceAspect)通常监听@Before,但若目标方法没开启事务,DataSourceAspect的setDataSourceKey()调用后,紧接着getConnection()就来了——而此时RoutingDataSourceHolder的ThreadLocal还没被事务管理器接管,一旦方法执行完,clear()没触发,key 就残留了。这份资源的DataSourceAspect明确要求:

@Around("@annotation(ds) && within(@org.springframework.transaction.annotation.Transactional *)") public Object around(ProceedingJoinPoint joinPoint, DS ds) throws Throwable { DataSourceKey key = DataSourceKey.from(ds.value()); RoutingDataSourceHolder.setDataSourceKey(key); try { return joinPoint.proceed(); } finally { // 注意:不在 finally 清除!因为事务可能还未提交 // 清除交给 TransactionSynchronizationManager } }

真正的清除,由 Spring 的事务同步器完成:

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() { @Override public void afterCompletion(int status) { RoutingDataSourceHolder.clear(); } });

这才是「事务结束即清理」的可靠机制。@DS不是独立开关,而是事务生命周期的一个声明式输入。


3. 分库分表路由的确定性:ShardingSphere 的StandardShardingAlgorithm不是黑匣子,它的doSharding()执行时机决定了 SQL 是否能被正确拆分

分库分表最常翻车的场景:INSERT INTO t_order (id, user_id, amount) VALUES (?, ?, ?)能正确路由到ds_0.t_order_0001,但SELECT * FROM t_order WHERE user_id = ?却报Can't find actual data source names。表面看是分片键没传,深层原因是:ShardingSphere 的路由发生在SQLParseEngine解析之后、SQLRewriteEngine改写之前,而StandardShardingAlgorithm.doSharding()的入参Collection<String> availableTargetNames,就是此刻所有可用的物理库/表名集合——如果这个集合为空,路由必然失败。

3.1availableTargetNames从哪来?它由RoutingDataSourceHolder的当前 key 决定

ShardingSphere 的ShardingSphereDataSource并不直接持有所有物理DataSource,而是通过DataSourceMap管理。当RoutingDataSourceHolder.getDataSourceKey()返回SLAVE_1时,DataSourceMap会将SLAVE_1映射为jdbc:mysql://slave1:3306/db_shard_01;而availableTargetNames就是db_shard_01下所有真实存在的分表名(如t_order_0000,t_order_0001)。如果RoutingDataSourceHolder当前 key 是MASTER,但MASTER对应的库db_master下没有t_order_0001表(只有逻辑表t_order),那么availableTargetNames就是空集合——doSharding()拿不到候选表,自然无法路由。

3.2 自定义ModuloShardingAlgorithm:用user_id % 4算出分片,但必须保证availableTargetNames包含所有模 4 的表

这份资源提供了一个生产级ModuloShardingAlgorithm实现:

public class ModuloShardingAlgorithm implements StandardShardingAlgorithm<Long> { @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) { long value = shardingValue.getValue(); // 关键:不能硬编码 "t_order_000" + (value % 4) // 必须从 availableTargetNames 中筛选,确保返回的表名真实存在 List<String> sortedTables = new ArrayList<>(availableTargetNames); Collections.sort(sortedTables); // 确保顺序一致,避免跨库不一致 int index = (int) (value % sortedTables.size()); return sortedTables.get(index); } @Override public Collection<String> doSharding(Collection<String> availableTargetNames, RangeShardingValue<Long> shardingValue) { // 范围查询:返回所有可能包含该范围的表 return availableTargetNames; // 简化版,实际需按 range 计算覆盖区间 } }

注意sortedTables.get(index):它不假设表名是t_order_0000到t_order_0003,而是动态从availableTargetNames中取。这样即使你后期扩容,新增t_order_0004,算法也能自动感知——因为availableTargetNames是 ShardingSphere 在每次 SQL 执行前,根据当前DataSourceKey动态构建的。

3.3HintManager强制路由:当分片键不在 SQL 中时,用代码指定「这次必须走 ds_0.t_order_0002」

有些场景无法用user_id分片,比如后台运营导出「所有未支付订单」,此时WHERE status = 'UNPAID',无分片键。ShardingSphere 提供HintManager,但很多人误以为HintManager.setDatabaseShardingValue()就够了:

// ❌ 错误:只设库,没设表,路由仍失败 HintManager hintManager = HintManager.getInstance(); hintManager.setDatabaseShardingValue("ds_0"); // ... 执行 SQL

正确做法是库+表双指定,并确保availableTargetNames包含该表:

// ✅ 正确:强制指定完整物理路径 HintManager hintManager = HintManager.getInstance(); hintManager.setDatabaseShardingValue("ds_0"); hintManager.setTableShardingValue("t_order_0002"); // 必须是 ds_0 下真实存在的表名 try { // 执行 SQL } finally { hintManager.close(); // 必须 close,否则 ThreadLocal 泄漏 }

HintManager的本质,是往当前线程的HintManager.HINT_MANAGER_CONTEXT(也是ThreadLocal)里写入强制路由规则,它会绕过StandardShardingAlgorithm,直接匹配availableTargetNames。所以t_order_0002必须在ds_0的物理库中真实存在,否则依然报错。


4. 避坑:5 条血泪经验总结,每一条都对应一次线上 P0 故障的根因回溯

提示:以下问题均在某高校分布式系统实验室的压测平台复现,日志级别为DEBUG,可 100% 复现。

4.1 现象:@DS("slave1")标注的方法,80% 请求走主库,20% 走从库

原因:DataSourceAspect的切面优先级(@Order)低于TransactionInterceptor,导致事务开启前setDataSourceKey()已执行,但事务管理器随后又将ThreadLocal清空(因未检测到 active transaction),最终 fallback 到MASTER。
解决:将DataSourceAspect的@Order(Ordered.HIGHEST_PRECEDENCE)改为@Order(Ordered.LOWEST_PRECEDENCE),确保它在TransactionInterceptor之后执行;或直接弃用@DS,改用RoutingDataSourceHolder.setDataSourceKey()显式控制。

4.2 现象:分页查询PageHelper.startPage(1,10)后,COUNTSQL 路由失败,报No route path for table t_order

原因:PageHelper的count插件会自动生成SELECT COUNT(*) FROM t_order,但此 SQL 不含WHERE条件,ShardingSphere无法提取分片键,availableTargetNames为空。
解决:禁用 PageHelper 的自动 count,改为手动写countSQL 并用HintManager强制路由;或在分片算法中,对无分片键的COUNT查询,返回availableTargetNames的第一个表(需业务接受统计误差)。

4.3 现象:@Transactional(propagation = Propagation.REQUIRES_NEW)内部方法调用@DS("archive"),但连接仍从主库获取

原因:REQUIRES_NEW会挂起当前事务并开启新事务,但RoutingDataSourceHolder的ThreadLocal未随事务挂起而备份,新事务线程沿用原ThreadLocal值,而DynamicRoutingDataSource.determineCurrentLookupKey()仍返回旧 key。
解决:在REQUIRES_NEW方法入口,显式调用RoutingDataSourceHolder.setDataSourceKey(DataSourceKey.ARCHIVE),并在方法退出finally块中恢复原 key(需提前get()保存)。

4.4 现象:应用启动时报Cannot determine embedded database driver class for database type NONE

原因:DynamicRoutingDataSource被 Spring 早期初始化(如@PostConstruct),但此时targetDataSources的Map尚未注入(因其他DataSourceBean 还在创建中),afterPropertiesSet()调用失败。
解决:将DynamicRoutingDataSource的@Primary注解移除,改用@Bean(name = "routingDataSource"),并在DataSourceConfig类中,用@DependsOn({"masterDataSource", "slaveDataSource"})显式声明依赖顺序。

4.5 现象:ShardingSphere日志显示Actual SQL: ds_0.t_order_0001 INSERT ...,但数据库监控发现该 SQL 实际执行在ds_1

原因:ShardingSphere的Actual SQL是逻辑路由后的语句,但物理执行仍由DynamicRoutingDataSource的getConnection()决定;若RoutingDataSourceHolder当前 key 是SLAVE_1,则getConnection()返回ds_1的连接,Actual SQL却写ds_0.t_order_0001—— 这是典型的「路由与连接不一致」。
解决:检查ShardingSphere的props.sql-show=true日志,确认Logic SQL与Actual SQL的库名是否一致;若不一致,说明ShardingSphere的dataSourceMap配置与DynamicRoutingDataSource的targetDataSources键名不匹配(如ShardingSphere配ds_0,而DynamicRoutingDataSource配master)。


5. 事务传播与跨库更新:为什么@Transactional在多数据源下天然不支持REQUIRES_NEW,以及如何用ChainedTransactionManager破局

当你需要在一个事务中,既更新主库的t_order,又更新归档库的t_order_history,直觉是写两个@Transactional方法,用REQUIRES_NEW隔离。但 Spring 的DataSourceTransactionManager是单数据源的——它只能管理一个DataSource的连接和事务。REQUIRES_NEW在多数据源下会创建两个独立事务:主库事务成功提交,归档库事务因网络抖动回滚,最终数据不一致。这不是 Bug,是设计使然:Spring 的事务抽象层(PlatformTransactionManager)与数据源是一对一绑定的,不存在“跨数据源的原子事务”。

这份资源给出两种生产方案:

5.1 方案一:ChainedTransactionManager—— 顺序执行,失败则逆向回滚

ChainedTransactionManager是 Spring Integration 提供的链式事务管理器,它不保证 ACID,但提供「尽力而为」的一致性:

@Bean public PlatformTransactionManager chainedTransactionManager( @Qualifier("masterTransactionManager") DataSourceTransactionManager masterTm, @Qualifier("archiveTransactionManager") DataSourceTransactionManager archiveTm) { return new ChainedTransactionManager(masterTm, archiveTm); }

它的工作流程是:

  1. 先调用masterTm.getTransaction()开启主库事务;
  2. 再调用archiveTm.getTransaction()开启归档库事务;
  3. 若两者都成功,commit()时按反序(先archiveTm.commit(),再masterTm.commit());
  4. 若任一commit()失败,则对已commit的事务执行rollback()。

风险在于:archiveTm.commit()成功后,masterTm.commit()失败,此时archiveTm.rollback()可能失败(网络超时),导致归档库脏数据。因此,该方案仅适用于「归档操作允许少量重复或丢失」的场景。

5.2 方案二:Saga 模式 —— 用补偿事务替代两阶段提交

对于强一致性要求(如金融转账),必须用 Saga。这份资源内置了一个轻量级SagaCoordinator:

步骤主库操作补偿操作归档库操作补偿操作
1UPDATE t_order SET status='PAID'UPDATE t_order SET status='UNPAID'——
2——INSERT INTO t_order_historyDELETE FROM t_order_history WHERE order_id=?

SagaCoordinator以@Transactional保证本地步骤原子性,并将每一步的「正向 SQL」和「补偿 SQL」记录到saga_log表(单库,高可用)。若步骤 2 失败,SagaCoordinator会扫描saga_log,按逆序执行补偿 SQL。它不依赖 XA,不阻塞,但要求每个操作都可逆。

5.3 关键设计取舍:为什么不用 Seata?为什么坚持ThreadLocal而非 MDC?

Seata 是成熟的分布式事务框架,但它的AT模式需修改数据库(全局锁表)、TCC模式需业务侵入(写Try/Confirm/Cancel),而这份资源的目标是「最小侵入、零数据库改造、纯 Spring Boot 生态」。ThreadLocal虽有内存泄漏风险,但通过TransactionSynchronization.afterCompletion()严格清除,比 MDC(Mapped Diagnostic Context)更可控——MDC 的clear()需手动调用,且与日志框架耦合深,一旦忘记,ThreadLocal会携带敏感字段进入下一个请求。

从那以后我每次写多数据源逻辑,都强制走一遍RoutingDataSourceHolder.setDataSourceKey()→TransactionSynchronizationManager.registerSynchronization()→ShardingSphere路由日志三连验;遇到跨库更新,第一反应不是加@Transactional,而是画出 Saga 流程图,标出每个节点的补偿路径。希望帮到你。

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

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

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

立即咨询