Spring boot 从数据库读取配置信息动态切换多数据源

现在项目中配置多数据源的情况很多,但是大多数情况都是在yml中配置,或者用配置中心例如spring config或者nacos中的配置文件中写死的。这样做的坏处有两点:

  1. 如果十个数据源,那么配置文件就太繁琐了
  2. 无法在项目不重启的情况下添加数据源(比如某个表原本在A数据源,但是现在因为表中数据太大,想要在B数据源也建个表来查询,这里不要杠分库分表)

所以还有一种很流行的写法,就是把所有的数据库连接信息放在一张表里。然后在项目中读取这张表的数据,动态创建数据库连接。当然了这种写法肯定比事先写死要繁琐一点。但是其优点也很明显。下面我们详细说一下如何实现这种做法。

思路整理

做一件事之前一个清晰的思路很重要,下面我们先理解我们要做什么:

  1. 首先项目中要有一个默认的数据库。这个数据库里要有一张数据库信息表。包括不限于 url,username,password,driverClassName,数据库名称等(因为我们数据库会涉及到不同的数据库,所以增加了驱动字段。后面我会把我自己建的表结构贴出来)。
  2. 大体思路应该是每次执行数据库操作的时候选择要使用的DataSource,所以每个数据源要有一个唯一标识来指定。
  3. 我们要把所有的数据源保存到一个map中,key是唯一标识,value是DataSource,这样在请求的时候选择唯一标识以后可以直接查找到对应的DataSource来使用。
  4. 如果指定的标识在map中不存在,则应该走创建步骤。甚至这里都可以用懒加载的模式,用到哪个数据源再去创建,不需要初始化。
  5. 为了多线程之间不冲突,所以我们应该用ThreadLocal保存当前指定的DataSource。

主要的几个步骤就是这样,其中还有一些细节就在代码里细说。

实现

数据库连接表:

CREATE TABLE `t_db_config` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `url` varchar(255) DEFAULT NULL,
  `username` varchar(255) DEFAULT NULL,
  `password` varchar(255) DEFAULT NULL,
  `driver_class_name` varchar(255) DEFAULT NULL,
  `create_person` varchar(255) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_person` varchar(255) DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `del_status` int(1) DEFAULT NULL,
  `db_name` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

这个表的实体类dao层service层的生成我就不说了,感兴趣的可以看我另一个mybatis plus代码生成器的文章:
MyBatisPlus中代码生成器的简单使用 - 简书 (jianshu.com)

创建数据源集合:这个就是自己建个类继承AbstractRoutingDataSource方法就行。代码如下:

import com.alibaba.druid.pool.DruidDataSource;
import com.alibaba.druid.stat.DruidDataSourceStatManager;
import com.lenovo.entity.DbConfigEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.util.StringUtils;
import java.sql.Connection;
import java.sql.DriverManager;
import java.util.Map;
import java.util.Set;


public class DynamicDataSource extends AbstractRoutingDataSource {
    private boolean debug = true;
    private final Logger log = LoggerFactory.getLogger(getClass());
    private Map<Object, Object> dynamicTargetDataSources;
    private Object dynamicDefaultTargetDataSource;

    @Override
    protected Object determineCurrentLookupKey() {
        String datasource = DBContextHolder.getDataSource();
        if (!StringUtils.isEmpty(datasource)) {
            Map<Object, Object> dynamicTargetDataSources2 = this.dynamicTargetDataSources;
            if (dynamicTargetDataSources2.containsKey(datasource)) {
                log.info("---当前数据源:" + datasource + "---");
            } else {
                log.info("不存在的数据源:");
                return null;
            }
        } else {
            log.info("---当前数据源:默认数据源---");
        }
        return datasource;
    }

    /**
     * 检查当前数据源是否存在,如果存在测试是否可用。不存在或者不可用创建新的数据源
     * @param dbConfig
     * @throws Exception
     */
    public void createDataSourceWithCheck(DbConfigEntity dbConfig) throws Exception {
        String dbName = dbConfig.getDbName();
        log.info("正在检查数据源:"+dbName);
        Map<Object, Object> dynamicTargetDataSources2 = this.dynamicTargetDataSources;
        if (dynamicTargetDataSources2.containsKey(dbName)) {
            log.info("数据源"+dbName+"之前已经创建,准备测试数据源是否正常...");
            DruidDataSource druidDataSource = (DruidDataSource) dynamicTargetDataSources2.get(dbName);
            boolean rightFlag = true;
            Connection connection = null;
            try {
                log.info(dbName+"数据源的概况->当前闲置连接数:"+druidDataSource.getPoolingCount());
                long activeCount = druidDataSource.getActiveCount();
                log.info(dbName+"数据源的概况->当前活动连接数:"+activeCount);
                if(activeCount > 0) {
                    log.info(dbName+"数据源的概况->活跃连接堆栈信息:"+druidDataSource.getActiveConnectionStackTrace());
                }
                log.info("准备获取数据库连接...");
                connection = druidDataSource.getConnection();
                log.info("数据源"+dbName+"正常");
            } catch (Exception e) {
                log.error(e.getMessage(),e); //把异常信息打印到日志文件
                rightFlag = false;
                log.info("缓存数据源"+dbName+"已失效,准备删除...");
                if(delDatasources(dbName)) {
                    log.info("缓存数据源删除成功");
                } else {
                    log.info("缓存数据源删除失败");
                }
            } finally {
                if(null != connection) {
                    connection.close();
                }
            }
            if(rightFlag) {
                log.info("不需要重新创建数据源");
                return;
            } else {
                log.info("准备重新创建数据源...");
                createDataSource(dbConfig);
                log.info("重新创建数据源完成");
            }
        } else {
            createDataSource(dbConfig);
        }
    }


    /**
     * 真正的创建数据源的方法
     * @param dbConfig
     * @return
     */
    public boolean createDataSource(DbConfigEntity dbConfig) {
        try {
            try { // 排除连接不上的错误
                Class.forName(dbConfig.getDriverClassName());
                DriverManager.getConnection(dbConfig.getUrl(), dbConfig.getUsername(), dbConfig.getPassword());// 相当于连接数据库
            } catch (Exception e) {
                log.info("数据库链接错误,url:{},username:{},password:{},错误原因:{}",
                        dbConfig.getUrl(), dbConfig.getUsername(), dbConfig.getPassword(),e.getMessage());
                return false;
            }
            @SuppressWarnings("resource")
            DruidDataSource druidDataSource = new DruidDataSource();
            druidDataSource.setName(dbConfig.getDbName());
            druidDataSource.setDriverClassName(dbConfig.getDriverClassName());
            druidDataSource.setUrl(dbConfig.getUrl());
            druidDataSource.setUsername(dbConfig.getUsername());
            druidDataSource.setPassword(dbConfig.getPassword());
            druidDataSource.setInitialSize(1); //初始化时建立物理连接的个数。初始化发生在显示调用init方法,或者第一次getConnection时
            druidDataSource.setMaxActive(20); //最大连接池数量
            druidDataSource.setMaxWait(60000); //获取连接时最大等待时间,单位毫秒。当链接数已经达到了最大链接数的时候,应用如果还要获取链接就会出现等待的现象,等待链接释放并回到链接池,如果等待的时间过长就应该踢掉这个等待,不然应用很可能出现雪崩现象
            druidDataSource.setMinIdle(5); //最小连接池数量
            String validationQuery = "select 1";
            druidDataSource.setValidationQuery(validationQuery); //用来检测连接是否有效的sql,要求是一个查询语句。如果validationQuery为null,testOnBorrow、testOnReturn、testWhileIdle都不会起作用。
            druidDataSource.setTestOnBorrow(true); //申请连接时执行validationQuery检测连接是否有效,这里建议配置为TRUE,防止取到的连接不可用
            druidDataSource.setTestWhileIdle(true);//建议配置为true,不影响性能,并且保证安全性。申请连接的时候检测,如果空闲时间大于timeBetweenEvictionRunsMillis,执行validationQuery检测连接是否有效。
            druidDataSource.setFilters("stat");//属性类型是字符串,通过别名的方式配置扩展插件,常用的插件有:监控统计用的filter:stat日志用的filter:log4j防御sql注入的filter:wall
            druidDataSource.setTimeBetweenEvictionRunsMillis(60000); //配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
            druidDataSource.setMinEvictableIdleTimeMillis(180000); //配置一个连接在池中最小生存的时间,单位是毫秒,这里配置为3分钟180000
            druidDataSource.setKeepAlive(true); //打开druid.keepAlive之后,当连接池空闲时,池中的minIdle数量以内的连接,空闲时间超过minEvictableIdleTimeMillis,则会执行keepAlive操作,即执行druid.validationQuery指定的查询SQL,一般为select * from dual,只要minEvictableIdleTimeMillis设置的小于防火墙切断连接时间,就可以保证当连接空闲时自动做保活检测,不会被防火墙切断
            druidDataSource.setRemoveAbandoned(true); //是否移除泄露的连接/超过时间限制是否回收。
            druidDataSource.setRemoveAbandonedTimeout(3600); //泄露连接的定义时间(要超过最大事务的处理时间);单位为秒。这里配置为1小时
            druidDataSource.setLogAbandoned(true);
            druidDataSource.init();
            this.dynamicTargetDataSources.put(dbConfig.getDbName(), druidDataSource);
            setTargetDataSources(this.dynamicTargetDataSources);// 将map赋值给父类的TargetDataSources
            super.afterPropertiesSet();// 将TargetDataSources中的连接信息放入resolvedDataSources管理
            log.info(dbConfig.getDbName()+"数据源初始化成功");
            return true;
        } catch (Exception e) {
            log.error(e + "");
            return false;
        }

    }

    /**
     * 删除数据源
     */
    public boolean delDatasources(String dbName) {
        Map<Object, Object> dynamicTargetDataSources2 = this.dynamicTargetDataSources;
        if (dynamicTargetDataSources2.containsKey(dbName)) {
            Set<DruidDataSource> druidDataSourceInstances = DruidDataSourceStatManager.getDruidDataSourceInstances();
            for (DruidDataSource l : druidDataSourceInstances) {
                if (dbName.equals(l.getName())) {
                    dynamicTargetDataSources2.remove(dbName);
                    DruidDataSourceStatManager.removeDataSource(l);
                    setTargetDataSources(dynamicTargetDataSources2);// 将map赋值给父类的TargetDataSources
                    super.afterPropertiesSet();// 将TargetDataSources中的连接信息放入resolvedDataSources管理
                    return true;
                }
            }
            return false;
        } else {
            return false;
        }
    }

    /**
     * 测试数据源连接是否有效
     * @param dbConfig
     * @return
     */
    public boolean testDatasource(DbConfigEntity dbConfig) {
        try {
            Class.forName(dbConfig.getDriverClassName());
            DriverManager.getConnection(dbConfig.getUrl(), dbConfig.getUsername(), dbConfig.getPassword());
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    @Override
    public void setDefaultTargetDataSource(Object defaultTargetDataSource) {
        super.setDefaultTargetDataSource(defaultTargetDataSource);
        this.dynamicDefaultTargetDataSource = defaultTargetDataSource;
    }

    @Override
    public void setTargetDataSources(Map<Object, Object> targetDataSources) {
        super.setTargetDataSources(targetDataSources);
        this.dynamicTargetDataSources = targetDataSources;
    }

    public Map<Object, Object> getDynamicTargetDataSources() {
        return dynamicTargetDataSources;
    }

    public void setDynamicTargetDataSources(Map<Object, Object> dynamicTargetDataSources) {
        this.dynamicTargetDataSources = dynamicTargetDataSources;
    }

    public Object getDynamicDefaultTargetDataSource() {
        return dynamicDefaultTargetDataSource;
    }

    public void setDynamicDefaultTargetDataSource(Object dynamicDefaultTargetDataSource) {
        this.dynamicDefaultTargetDataSource = dynamicDefaultTargetDataSource;
    }

}

上面有很多精细的判断,反正是我抄的,主要方法有以下几个:

  • 检查是否存在,不存在则调用创建的方法
  • 创建数据源的方法
  • 检查是否可用
  • 删除数据源

这里有一个细节:检验语句是select 1 之前炒的是select * from dual.但是后来我发现pg数据库会报错。别的就没啥了,可以针对性配置。

创建线程安全的切换工具类:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class DBContextHolder {
    private final static Logger log = LoggerFactory.getLogger(DBContextHolder.class);
    // 对当前线程的操作-线程安全的
    private static final ThreadLocal<String> contextHolder = new ThreadLocal<String>();

    // 调用此方法,切换数据源
    public static void setDataSource(String dataSource) {
        contextHolder.set(dataSource);
        log.info("已切换到数据源:{}",dataSource);
    }

    // 获取数据源
    public static String getDataSource() {
        return contextHolder.get();
    }

    // 删除数据源
    public static void clearDataSource() {
        contextHolder.remove();
        log.info("已切换到主数据源");
    }
}

创建默认数据源Mybatis Plus(这里我把驼峰设置写到配置文件中了,所以还要带个配置文件):

import com.alibaba.druid.pool.DruidDataSource;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.apache.ibatis.session.SqlSessionFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.sql.DataSource;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;

/**
 * @Author : JCccc
 * @CreateTime : 2019/10/22
 * @Description :
 **/
@Configuration
@EnableTransactionManagement
public class DruidDBConfig {
    private final Logger log = LoggerFactory.getLogger(getClass());

    // adi数据库连接信息
    @Value("${spring.datasource.url}")
    private String dbUrl;
    @Value("${spring.datasource.username}")
    private String username;
    @Value("${spring.datasource.password}")
    private String password;
    @Value("${spring.datasource.driver-class-name}")
    private String driverClassName;


    @Bean // 声明其为Bean实例
    @Primary // 在同样的DataSource中,首先使用被标注的DataSource
    @Qualifier("mainDataSource")
    public DataSource dataSource() throws SQLException {
        DruidDataSource datasource = new DruidDataSource();
        // 基础连接信息
        datasource.setUrl(this.dbUrl);
        datasource.setUsername(username);
        datasource.setPassword(password);
        datasource.setDriverClassName(driverClassName);
        // 连接池连接信息
        datasource.setInitialSize(5);
        datasource.setMinIdle(5);
        datasource.setMaxActive(20);
        datasource.setMaxWait(60000);
        datasource.setPoolPreparedStatements(true); //是否缓存preparedStatement,也就是PSCache。PSCache对支持游标的数据库性能提升巨大,比如说oracle。在mysql下建议关闭。
        datasource.setMaxPoolPreparedStatementPerConnectionSize(20);
        datasource.setTestOnBorrow(true); //申请连接时执行validationQuery检测连接是否有效,这里建议配置为TRUE,防止取到的连接不可用
        datasource.setTestWhileIdle(true);//建议配置为true,不影响性能,并且保证安全性。申请连接的时候检测,如果空闲时间大于timeBetweenEvictionRunsMillis,执行validationQuery检测连接是否有效。
        String validationQuery = "select 1";
        datasource.setValidationQuery(validationQuery); //用来检测连接是否有效的sql,要求是一个查询语句。如果validationQuery为null,testOnBorrow、testOnReturn、testWhileIdle都不会起作用。
        datasource.setFilters("stat,wall");//属性类型是字符串,通过别名的方式配置扩展插件,常用的插件有:监控统计用的filter:stat日志用的filter:log4j防御sql注入的filter:wall
        datasource.setTimeBetweenEvictionRunsMillis(60000); //配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
        datasource.setMinEvictableIdleTimeMillis(180000); //配置一个连接在池中最小生存的时间,单位是毫秒,这里配置为3分钟180000
        datasource.setKeepAlive(true); //打开druid.keepAlive之后,当连接池空闲时,池中的minIdle数量以内的连接,空闲时间超过minEvictableIdleTimeMillis,则会执行keepAlive操作,即执行druid.validationQuery指定的查询SQL,一般为select * from dual,只要minEvictableIdleTimeMillis设置的小于防火墙切断连接时间,就可以保证当连接空闲时自动做保活检测,不会被防火墙切断
        datasource.setRemoveAbandoned(true); //是否移除泄露的连接/超过时间限制是否回收。
        datasource.setRemoveAbandonedTimeout(3600); //泄露连接的定义时间(要超过最大事务的处理时间);单位为秒。这里配置为1小时
        datasource.setLogAbandoned(true);
        return datasource;
    }

    @Bean(name = "dynamicDataSource")
    @Qualifier("dynamicDataSource")
    public DynamicDataSource dynamicDataSource() throws SQLException {
        DynamicDataSource dynamicDataSource = new DynamicDataSource();
        // 默认数据源配置 DefaultTargetDataSource
        dynamicDataSource.setDefaultTargetDataSource(dataSource());
        Map<Object, Object> targetDataSources = new HashMap<Object, Object>();
        //额外数据源配置 TargetDataSources
        targetDataSources.put("mainDataSource", dataSource());
        dynamicDataSource.setTargetDataSources(targetDataSources);
        return dynamicDataSource;
    }

    @Bean
    public SqlSessionFactory sqlSessionFactory() throws Exception {
        MybatisSqlSessionFactoryBean sqlSessionFactoryBean = new MybatisSqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dynamicDataSource());
        // 设置mybatis的主配置文件
        ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        Resource mybatisConfigXml = resolver.getResource("classpath:mybatis-config.xml");
        sqlSessionFactoryBean.setConfigLocation(mybatisConfigXml);
        return sqlSessionFactoryBean.getObject();
    }
}

注意文中配置文件的路径是根据配置来的。我的mybatis-config.xml位置就是这样:



然后配置文件内容如下(其实就是一个驼峰的设置):

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>

    <settings>
        <setting name="useGeneratedKeys" value="true"/>
        <setting name="useColumnLabel" value="true"/>
        <setting name="mapUnderscoreToCamelCase" value="true"/>
    </settings>
</configuration>

注意上面的配置是对于mybatis plus来说的。mybatis应该要改下sqlSessionFactory那块,因为一开始我按照mybatis配置会出问题所以修改了。不过mybatis我没使用过,不确保正确性。

    @Bean
    public SqlSessionFactory sqlSessionFactory() throws Exception {
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dynamicDataSource());
        //解决手动创建数据源后字段到bean属性名驼峰命名转换失效的问题
        sqlSessionFactoryBean.setConfiguration(configuration());

        // 设置mybatis的主配置文件
        ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();

        return sqlSessionFactoryBean.getObject();
    }

最后我们建立个切换数据源的工具类就行了,代码可以参考下面:

@Service("dbConfigService")
public class DbConfigServiceImpl extends ServiceImpl<DbConfigMapper, DbConfigEntity> implements DbConfigService {

    @Autowired
    private DbConfigMapper dbConfigMapper;

    @Autowired
    private DynamicDataSource dynamicDataSource;

    @Override
    public List<DbConfigEntity> get() {
        LambdaQueryWrapper<DbConfigEntity> queryWrapper = Wrappers.lambdaQuery();
        queryWrapper.eq(DbConfigEntity::getDelStatus, 0);
        List<DbConfigEntity> list = dbConfigMapper.selectList(queryWrapper);
        return list;
    }

    @Override
    public boolean changeDb(String dbName) throws Exception {
        //默认切换到主数据源,进行整体资源的查找
        DBContextHolder.clearDataSource();
        List<DbConfigEntity> dataSourcesList = dbConfigMapper.get();

        for (DbConfigEntity dbConfig : dataSourcesList) {
            if (dbConfig.getDbName().equals(dbName)) {
                System.out.println("需要使用的的数据源已经找到,dbName是:" + dbName);
                //创建数据源连接&检查 若存在则不需重新创建
                dynamicDataSource.createDataSourceWithCheck(dbConfig);
                //切换到该数据源
                DBContextHolder.setDataSource(dbName);
                return true;
            }
        }
        return false;
    }
}

最后我们在代码中测试一下数据源切换:

    @GetMapping("/test")
    public void test() throws Exception{
        //i = 1 查默认库  2 查数据库2
        dbConfigService.changeDb("test2");
        System.out.println(dbConfigService.get());
        DBContextHolder.clearDataSource();
        System.out.println(dbConfigService.get());
    }

我这里为了测试特意两个库都建了这个表并添加不同的数据打印了。事实证明是切换成功了的。至于这个dbName,计划是每个接口在请求的时候都必须要传这个参数用来指定要查询的库。确实会麻烦一点,但是也灵活很多。
本篇笔记就到这里,如果稍微帮到你了记得点个喜欢点个关注!也祝大家工作顺顺利利!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章