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,計劃是每個接口在請求的時候都必須要傳這個參數用來指定要查詢的庫。確實會麻煩一點,但是也靈活很多。
本篇筆記就到這裏,如果稍微幫到你了記得點個喜歡點個關注!也祝大家工作順順利利!

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