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

3982

积分

0

好友

556

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

Mall电商项目学习教程截图

在实际开发中,我们有时会遇到需要从多个数据库中获取或写入数据的场景,比如跨库查询、数据同步等,这就涉及到数据源的动态切换。MyBatis-plus 提供的 dynamic-datasource-spring-boot-starter 组件是常见的解决方案,但笔者近期在项目中引入时,却因为项目环境问题导致无法使用。

既然现成的轮子跑不起来,不妨研究一下它的核心原理,自己动手造一个。本文将带你基于 ThreadLocalAbstractRoutingDataSource,一步步实现一个支持注解切换和运行时动态添加数据源的轻量级方案。

核心概念简介

在开始编码之前,我们需要先理解两个核心组件:

  • ThreadLocal:线程局部变量。它为每个访问变量的线程提供了独立的副本,从而实现了线程间的数据隔离。在数据源切换场景下,我们可以用它来保存当前线程应该使用的数据源标识。
  • AbstractRoutingDataSource:Spring 提供的抽象路由数据源。它维护了一个目标数据源映射表,并通过实现其 determineCurrentLookupKey() 方法来决定每次数据库操作时具体使用哪个数据源。这正是实现动态切换的关键。

环境与依赖

本次实现的程序环境如下,你可以根据实际情况调整版本:

  • Spring Boot 2.4.8
  • Mybatis-plus 3.2.0
  • Druid 1.2.6
  • lombok 1.18.20
  • commons-lang3 3.10

代码实现详解

1. 实现数据源上下文持有器 (ThreadLocal)

首先,我们创建一个工具类,利用 ThreadLocal 来保存和获取当前线程的数据源键值。

/**
 * 数据源上下文持有器
 */
public class DataSourceContextHolder {
    // 使用ThreadLocal为每个线程提供独立的数据源标识副本
    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();
    }
}

2. 实现动态路由数据源 (AbstractRoutingDataSource)

接着,我们创建动态数据源类,继承 AbstractRoutingDataSource,并将其与上面的上下文持有器关联。

import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.util.Map;

/**
 * 动态数据源,根据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);
        // 保存一份数据源Map的引用,用于后续动态添加
        this.targetDataSourceMap = targetDataSources;
    }

    @Override
    protected Object determineCurrentLookupKey() {
        // 路由逻辑:返回当前线程持有的数据源键
        return DataSourceContextHolder.getDataSource();
    }
}

构造方法接收一个默认数据源和一个目标数据源Map,Map的key是数据源名称,value是具体的 DataSource 对象。

3. 配置多数据源

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

然后,通过一个配置类,将这些配置转换为Bean并注入Spring容器。

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DruidDataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@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);
    }
}

注意:需要在启动类上排除Spring Boot的自动数据源配置,避免循环依赖。

@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)

4. 基础功能测试

为了验证,我们在主库(test1)和从库(test2)中分别创建一张表并插入不同数据。

-- 在两个库中执行
CREATE TABLE test_user(
  user_name VARCHAR(255) NOT NULL COMMENT '用户名'
);

-- 在主库执行
INSERT INTO test_user (user_name) VALUE ('master');

-- 在从库执行
INSERT INTO test_user (user_name) VALUE ('slave');

编写一个测试接口,通过路径参数来动态指定数据源。

@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,返回 master
    访问主数据源结果
  • 访问 localhost:6001/dynamic/test/getData.do/slave,返回 slave
    访问从数据源结果

5. 优化一:使用注解优雅切换

手动在代码中调用 setDataSourceremoveDataSource 不仅繁琐,还容易遗漏清理导致内存泄漏。我们可以利用 Spring AOP 结合自定义注解来优化。

5.1 定义注解
模仿 Mybatis-plus@DS 注解。

import java.lang.annotation.*;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface DS {
    String value() default "master";
}

5.2 实现切面
创建一个切面,在方法执行前后自动处理数据源切换。

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Objects;

@Aspect
@Component
@Slf4j
public class DSAspect {

    @Pointcut("@annotation(com.yourpackage.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);
        // 如果方法上有@DS注解,则设置对应的数据源
        if (Objects.nonNull(ds)) {
            DataSourceContextHolder.setDataSource(ds.value());
        }
        try {
            // 执行原方法
            return point.proceed();
        } finally {
            // 无论成功与否,最后都清除数据源设置
            DataSourceContextHolder.removeDataSource();
        }
    }
}

5.3 使用注解测试
现在,我们可以通过注解来切换数据源了,代码简洁了许多。

@GetMapping("/getMasterData.do")
// 默认使用 master 数据源,注解可省略
public String getMasterData() {
    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 返回 slave
    注解访问从数据源结果

6. 优化二:运行时动态添加数据源

某些场景下,数据源信息可能存储在数据库中,需要在应用启动时或运行期间动态加载。接下来我们增强 DynamicDataSource 类来实现这个功能。

6.1 定义数据源信息实体

import lombok.Data;
import lombok.experimental.Accessors;

@Data
@Accessors(chain = true)
public class DataSourceEntity {
    /**
     * 数据库地址
     */
    private String url;
    /**
     * 数据库用户名
     */
    private String userName;
    /**
     * 密码
     */
    private String passWord;
    /**
     * 数据库驱动
     */
    private String driverClassName;
    /**
     * 数据源标识key,即保存在Map中的key
     */
    private String key;
}

6.2 增强 DynamicDataSource 类
为其添加创建和校验数据源的方法。

// 在 DynamicDataSource 类中添加以下方法
import com.alibaba.druid.pool.DruidDataSource;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.BeanUtils;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.List;
import java.util.Objects;

/**
 * 批量创建并添加数据源
 * @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()).close();

                // 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. 更新AbstractRoutingDataSource内部管理的目标数据源
            super.setTargetDataSources(this.targetDataSourceMap);
            super.afterPropertiesSet(); // 此方法会重置resolvedDataSources
            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));
}

6.3 应用启动时加载数据源
假设我们在主库中有一张表 test_db_info 存储了其他数据源的配置信息。我们可以实现 CommandLineRunner,在应用启动后加载它们。

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 '数据源名称'
);

-- 插入一个示例数据源,指向之前的从库,但使用新名称
INSERT INTO test_db_info(url, username, password, driver_class_name, name)
VALUES ('jdbc:mysql://xxxxx:3306/test2?characterEncoding=utf-8&allowMultiQueries=true&zeroDateTimeBehavior=convertToNull&useSSL=false',
        'root',
        '123456',
        'com.mysql.cj.jdbc.Driver',
        'add_slave');
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;

@Component
public class LoadDataSourceRunner implements CommandLineRunner {

    @Resource
    private DynamicDataSource dynamicDataSource;
    @Resource
    private TestDbInfoMapper testDbInfoMapper; // 假设已定义

    @Override
    public void run(String... args) throws Exception {
        List<TestDbInfo> dbInfoList = testDbInfoMapper.selectList(null);
        if (CollectionUtils.isNotEmpty(dbInfoList)) {
            List<DataSourceEntity> dsList = new ArrayList<>();
            for (TestDbInfo dbInfo : dbInfoList) {
                DataSourceEntity entity = new DataSourceEntity();
                BeanUtils.copyProperties(dbInfo, entity);
                entity.setKey(dbInfo.getName()); // 用name字段作为数据源key
                dsList.add(entity);
            }
            // 动态添加到数据源中
            dynamicDataSource.createDataSource(dsList);
        }
    }
}

6.4 测试动态添加的数据源
应用启动后,新增的 add_slave 数据源已生效。我们可以通过之前的接口进行测试。

访问 localhost:6001/dynamic/test/getData.do/add_slave,成功返回 slave,证明动态添加的数据源工作正常。
访问动态添加的从数据源结果

总结与思考

通过以上步骤,我们实现了一个较为完整的 Spring Boot 动态数据源切换方案。它核心依赖于 ThreadLocal 进行线程隔离,以及 AbstractRoutingDataSource 进行路由。通过 AOP 注解优化了使用体验,并支持了运行时动态加载数据源的能力,覆盖了大部分常见场景。

相比成熟的第三方组件,这个自研方案更加轻量、透明,也便于根据自身业务进行定制(例如,你可以参考 Mybatis-plus 动态数据源的源码,为其增加数据源嵌套切换的“栈”能力)。

数据库 操作日益复杂的现代应用中,掌握数据源动态切换的原理与实现,是后端开发者的一项实用技能。希望本文的探索能为你带来启发。如果你在实践过程中有更多心得或问题,欢迎在 云栈社区 与大家交流讨论。

项目源码地址: https://github.com/lovejiashn/dynamic_datasource




上一篇:Java线程池避坑指南:10个常见问题与最佳实践详解
下一篇:我用Playwright异步+Firefox爬取2026春节档豆瓣短评,发现票房第一并非口碑最佳
您需要登录后才可以回帖 登录 | 立即注册

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

GMT+8, 2026-3-3 20:17 , Processed in 1.506377 second(s), 46 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

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