找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2829

积分

0

好友

376

主题
发表于 5 小时前 | 查看: 1| 回复: 0

在实际开发中,你是否遇到过需要在运行时切换不同数据库的场景?例如,从多个外部数据源同步数据到本地库。我最近就遇到了类似需求,最初考虑使用MyBatis-Plus提供的 dynamic-datasource-spring-boot-starter 动态数据源组件,但由于项目环境限制无法直接引入。

既然现成的方案行不通,不如自己动手实现一个。通过阅读源码,我发现其核心原理并不复杂,主要依赖 ThreadLocalAbstractRoutingDataSource。本文将一步步带你用这两种技术,在 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();
}

执行结果

  1. 访问 localhost:6001/dynamic/test/getData.do/master
    传递master数据源名称的查询结果

  2. 访问 localhost:6001/dynamic/test/getData.do/slave
    传递slave数据源名称的查询结果

可以看到,通过手动调用 setDataSourceremoveDataSource,我们成功实现了数据源切换。但这种方式需要在每个方法里重复编写设置和清理代码,不够优雅。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();
}

执行结果

  1. 调用 getMasterData.do (无注解,使用默认master):
    使用默认master数据源注解查询结果
  2. 调用 getSlaveData.do (使用@DS(“slave”)注解):
    使用@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));
    }
}

关键点在于添加数据源后,必须调用 setTargetDataSourcesafterPropertiesSet() 来刷新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
访问动态添加的add_slave数据源结果

成功返回了从库的数据,证明我们动态添加的数据源已经生效。

总结

通过本文的实践,我们完成了一个从零搭建的SpringBoot动态数据源方案。它不仅实现了基于 ThreadLocalAbstractRoutingDataSource 的核心路由机制,还通过AOP注解优化了使用体验,并扩展了运行时动态加载数据源的能力。

这个方案的优势在于理解底层原理、轻量且可控,当无法使用第三方Starter时,它提供了一种可靠的备选方案。希望这篇内容详实的指南,能帮助你在处理多数据源场景时更加得心应手。如果在实践中遇到问题,欢迎在技术社区交流探讨,共同进步。




上一篇:分布式文件存储方案详解:HDFS、Ceph与GFS如何选型?
下一篇:Clawdbot 集成飞书完整教程:2026.2.2 版本国产化配置与避坑指南
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-7 19:26 , Processed in 0.392774 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表