在实际开发中,你是否遇到过需要在运行时切换不同数据库的场景?例如,从多个外部数据源同步数据到本地库。我最近就遇到了类似需求,最初考虑使用MyBatis-Plus提供的 dynamic-datasource-spring-boot-starter 动态数据源组件,但由于项目环境限制无法直接引入。
既然现成的方案行不通,不如自己动手实现一个。通过阅读源码,我发现其核心原理并不复杂,主要依赖 ThreadLocal 和 AbstractRoutingDataSource。本文将一步步带你用这两种技术,在 Spring Boot 中实现一个功能完整且“优雅”的动态数据源方案,并涵盖注解切换、动态添加数据源等高级功能。
核心组件简介
在开始编码前,先简要了解两个核心角色:
-
ThreadLocal:全称 thread local variable。它通过为每个线程提供独立的变量副本,解决了多线程并发访问时的数据竞争问题,实现了线程间的数据隔离。其原理是将值以当前线程实例为Key,存储在线程自身的Map中。
- 作用:线程内共享,线程间隔离。
- 场景:完美适用于保存每个线程当前要使用的数据源标识。
-
AbstractRoutingDataSource:Spring框架提供的一个抽象类,用于根据用户定义的规则动态选择数据源。它在每次数据库操作前,会调用其 determineCurrentLookupKey() 方法,我们需要做的就是返回当前线程对应的数据源标识。
代码实现:构建动态数据源
程序环境:
- SpringBoot 2.4.8
- Mybatis-plus 3.2.0
- Druid 1.2.6
- lombok 1.18.20
- commons-lang3 3.10
第一步:实现数据源上下文持有者 (ThreadLocal)
创建一个工具类,用于管理当前线程绑定的数据源键名。
/**
* @author: jiangjs
**/
public class DataSourceContextHolder {
// 线程局部变量,每个线程独立拥有一份数据源标识的副本
private static final ThreadLocal<String> DATASOURCE_HOLDER = new ThreadLocal<>();
/**
* 设置数据源
* @param dataSourceName 数据源名称
*/
public static void setDataSource(String dataSourceName){
DATASOURCE_HOLDER.set(dataSourceName);
}
/**
* 获取当前线程的数据源
* @return 数据源名称
*/
public static String getDataSource(){
return DATASOURCE_HOLDER.get();
}
/**
* 删除当前数据源
*/
public static void removeDataSource(){
DATASOURCE_HOLDER.remove();
}
}
第二步:实现动态路由数据源 (AbstractRoutingDataSource)
定义一个动态数据源类,继承 AbstractRoutingDataSource,并将其与上文的 ThreadLocal 关联。
/**
* @author: jiangjs
* @description: 实现动态数据源,根据AbstractRoutingDataSource路由到不同数据源中
**/
public class DynamicDataSource extends AbstractRoutingDataSource {
public DynamicDataSource(DataSource defaultDataSource, Map<Object, Object> targetDataSources){
super.setDefaultTargetDataSource(defaultDataSource);
super.setTargetDataSources(targetDataSources);
}
@Override
protected Object determineCurrentLookupKey() {
// 关键:返回当前线程持有的数据源标识
return DataSourceContextHolder.getDataSource();
}
}
构造方法用于设置默认数据源以及一个Map结构的目标数据源集合。Map的Key是数据源名称(如“master”),Value是对应的 DataSource 对象。这个设计是后续所有 后端架构 动态切换的基础。
第三步:配置数据源
在 application.yml 中配置主从(示例)数据源信息。
#设置数据源
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
druid:
master:
url: jdbc:mysql://xxxxxx:3306/test1?characterEncoding=utf-8&allowMultiQueries=true&zeroDateTimeBehavior=convertToNull&useSSL=false
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
slave:
url: jdbc:mysql://xxxxx:3306/test2?characterEncoding=utf-8&allowMultiQueries=true&zeroDateTimeBehavior=convertToNull&useSSL=false
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
initial-size: 15
min-idle: 15
max-active: 200
max-wait: 60000
time-between-eviction-runs-millis: 60000
min-evictable-idle-time-millis: 300000
validation-query: ""
test-while-idle: true
test-on-borrow: false
test-on-return: false
pool-prepared-statements: false
connection-properties: false
创建配置类,将YAML配置转换为Bean并初始化动态数据源。
/**
* @author: jiangjs
* @description: 设置数据源
**/
@Configuration
public class DateSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.druid.master")
public DataSource masterDataSource(){
return DruidDataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.druid.slave")
public DataSource slaveDataSource(){
return DruidDataSourceBuilder.create().build();
}
@Bean(name = "dynamicDataSource")
@Primary // 标记为首选数据源
public DynamicDataSource createDynamicDataSource(){
Map<Object,Object> dataSourceMap = new HashMap<>();
DataSource defaultDataSource = masterDataSource();
dataSourceMap.put("master",defaultDataSource);
dataSourceMap.put("slave",slaveDataSource());
return new DynamicDataSource(defaultDataSource,dataSourceMap);
}
}
这里将 DynamicDataSource 声明为 @Primary 并注入Spring容器,后续所有数据库操作都会通过它路由。
重要提示:启动类需排除SpringBoot的自动数据源配置,避免循环依赖。
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
第四步:基础功能测试
首先,在主库(test1)和从库(test2)中分别创建测试表并插入数据。
-- 建表语句(主从库分别执行)
create table test_user(
user_name varchar(255) not null comment '用户名'
);
-- 主库(test1)插入数据
insert into test_user (user_name) value ('master');
-- 从库(test2)插入数据
insert into test_user (user_name) value ('slave');
编写一个Controller方法,通过路径参数动态指定数据源。
@GetMapping("/getData.do/{datasourceName}")
public String getMasterData(@PathVariable("datasourceName") String datasourceName){
// 1. 设置当前线程要使用的数据源
DataSourceContextHolder.setDataSource(datasourceName);
// 2. 执行查询(此时会自动路由到对应数据源)
TestUser testUser = testUserMapper.selectOne(null);
// 3. 清理当前线程的数据源标识,防止内存泄漏
DataSourceContextHolder.removeDataSource();
return testUser.getUserName();
}
执行结果:
-
访问 localhost:6001/dynamic/test/getData.do/master:

-
访问 localhost:6001/dynamic/test/getData.do/slave:

可以看到,通过手动调用 setDataSource 和 removeDataSource,我们成功实现了数据源切换。但这种方式需要在每个方法里重复编写设置和清理代码,不够优雅。MyBatis-Plus的动态数据源组件内部使用了栈结构来维护标识,支持嵌套切换,有兴趣可以阅读其源码。
优化进阶:让切换更优雅
优化一:使用注解切换数据源
为方法或类添加一个注解,通过AOP自动完成数据源的设置与清理。
1. 定义 @DS 注解
/**
* @author: jiangjs
**/
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface DS {
String value() default "master";
}
2. 实现切面 (AOP)
@Aspect
@Component
@Slf4j
public class DSAspect {
@Pointcut("@annotation(com.jiashn.dynamic_datasource.dynamic.aop.DS)")
public void dynamicDataSource(){}
@Around("dynamicDataSource()")
public Object datasourceAround(ProceedingJoinPoint point) throws Throwable {
MethodSignature signature = (MethodSignature)point.getSignature();
Method method = signature.getMethod();
DS ds = method.getAnnotation(DS.class);
if (Objects.nonNull(ds)){
// 从注解获取值,并设置到当前线程
DataSourceContextHolder.setDataSource(ds.value());
}
try {
return point.proceed(); // 执行原方法
} finally {
DataSourceContextHolder.removeDataSource(); // 最终清理
}
}
}
3. 使用注解进行测试
@GetMapping("/getMasterData.do")
public String getMasterData(){
// 默认使用master数据源
TestUser testUser = testUserMapper.selectOne(null);
return testUser.getUserName();
}
@GetMapping("/getSlaveData.do")
@DS("slave") // 指定使用slave数据源
public String getSlaveData(){
TestUser testUser = testUserMapper.selectOne(null);
return testUser.getUserName();
}
执行结果:
- 调用
getMasterData.do (无注解,使用默认master):

- 调用
getSlaveData.do (使用@DS(“slave”)注解):

通过注解,我们完全消除了业务代码中的模板化切换逻辑,与主流框架的使用体验保持一致。
优化二:实现运行时动态添加数据源
某些业务场景下,数据源信息可能存储在 数据库 中,需要在应用启动或运行期间动态加载。接下来我们就实现这个功能。
1. 定义数据源配置实体
/**
* @author: jiangjs
* @description: 数据源实体
**/
@Data
@Accessors(chain = true)
public class DataSourceEntity {
/**
* 数据库地址
*/
private String url;
/**
* 数据库用户名
*/
private String userName;
/**
* 密码
*/
private String passWord;
/**
* 数据库驱动
*/
private String driverClassName;
/**
* 数据源Key,即保存在DynamicDataSource Map中的key
*/
private String key;
}
2. 增强 DynamicDataSource 类
/**
* @author: jiangjs
* @description: 实现动态数据源,根据AbstractRoutingDataSource路由到不同数据源中
**/
@Slf4j
public class DynamicDataSource extends AbstractRoutingDataSource {
private final Map<Object,Object> targetDataSourceMap;
public DynamicDataSource(DataSource defaultDataSource,Map<Object, Object> targetDataSources){
super.setDefaultTargetDataSource(defaultDataSource);
super.setTargetDataSources(targetDataSources);
this.targetDataSourceMap = targetDataSources; // 持有Map引用
}
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSource();
}
/**
* 动态添加数据源
* @param dataSources 数据源实体集合
* @return 添加结果
*/
public Boolean createDataSource(List<DataSourceEntity> dataSources){
try {
if (CollectionUtils.isNotEmpty(dataSources)){
for (DataSourceEntity ds : dataSources) {
// 1. 校验数据库连接是否有效
Class.forName(ds.getDriverClassName());
DriverManager.getConnection(ds.getUrl(),ds.getUserName(),ds.getPassWord());
// 2. 创建Druid数据源并配置
DruidDataSource dataSource = new DruidDataSource();
BeanUtils.copyProperties(ds,dataSource);
dataSource.setTestOnBorrow(true);
dataSource.setTestWhileIdle(true);
dataSource.setValidationQuery("select 1 ");
dataSource.init(); // 初始化连接池
// 3. 加入数据源Map
this.targetDataSourceMap.put(ds.getKey(),dataSource);
}
// 4. 重要:重置目标数据源集合
super.setTargetDataSources(this.targetDataSourceMap);
// 5. 重要:刷新resolvedDataSources
super.afterPropertiesSet();
return Boolean.TRUE;
}
} catch (ClassNotFoundException | SQLException e) {
log.error("---动态添加数据源失败---:{}", e.getMessage());
}
return Boolean.FALSE;
}
/**
* 校验数据源是否存在
* @param key 数据源保存的key
* @return 返回结果,true:存在,false:不存在
*/
public boolean existsDataSource(String key){
return Objects.nonNull(this.targetDataSourceMap.get(key));
}
}
关键点在于添加数据源后,必须调用 setTargetDataSources 和 afterPropertiesSet() 来刷新Spring内部管理的数据源映射。
3. 模拟从数据库加载数据源
在主库创建一张表,用于存放其他数据源的配置信息。
create table test_db_info(
id int auto_increment primary key not null comment '主键Id',
url varchar(255) not null comment '数据库URL',
username varchar(255) not null comment '用户名',
password varchar(255) not null comment '密码',
driver_class_name varchar(255) not null comment '数据库驱动',
name varchar(255) not null comment '数据源名称'
);
-- 插入一条从库配置,注意name(作为key)与之前不同
insert into test_db_info(url, username, password,driver_class_name, name)
value ('jdbc:mysql://xxxxx:3306/test2?characterEncoding=utf-8&allowMultiQueries=true&zeroDateTimeBehavior=convertToNull&useSSL=false',
'root','123456','com.mysql.cj.jdbc.Driver','add_slave');
创建一个启动执行器,在应用启动时加载这些配置并添加到动态数据源中。
@Component
public class LoadDataSourceRunner implements CommandLineRunner {
@Resource
private DynamicDataSource dynamicDataSource;
@Resource
private TestDbInfoMapper testDbInfoMapper;
@Override
public void run(String... args) throws Exception {
List<TestDbInfo> testDbInfos = testDbInfoMapper.selectList(null);
if (CollectionUtils.isNotEmpty(testDbInfos)) {
List<DataSourceEntity> ds = new ArrayList<>();
for (TestDbInfo testDbInfo : testDbInfos) {
DataSourceEntity sourceEntity = new DataSourceEntity();
BeanUtils.copyProperties(testDbInfo,sourceEntity);
sourceEntity.setKey(testDbInfo.getName()); // 使用name字段作为key
ds.add(sourceEntity);
}
// 动态添加数据源到系统
dynamicDataSource.createDataSource(ds);
}
}
}
4. 测试动态添加的数据源
应用启动后,add_slave 这个数据源已被加载。我们使用最开始的那个测试接口进行验证:
访问 localhost:6001/dynamic/test/getData.do/add_slave

成功返回了从库的数据,证明我们动态添加的数据源已经生效。
总结
通过本文的实践,我们完成了一个从零搭建的SpringBoot动态数据源方案。它不仅实现了基于 ThreadLocal 和 AbstractRoutingDataSource 的核心路由机制,还通过AOP注解优化了使用体验,并扩展了运行时动态加载数据源的能力。
这个方案的优势在于理解底层原理、轻量且可控,当无法使用第三方Starter时,它提供了一种可靠的备选方案。希望这篇内容详实的指南,能帮助你在处理多数据源场景时更加得心应手。如果在实践中遇到问题,欢迎在技术社区交流探讨,共同进步。