spring boot 动态数据源配置 & 运行时新增数据源

spring boot 动态数据源配置 & 运行时新增数据源

场景:

  1. 同一系统支持不同业务场景,需要用到不同的数据库。
  2. 各个用户/应用之间数据完全隔离(不同的用户/应用,不同的数据库),而一个程序需要支持不同的用户/应用。例如 一些paas服务需要支持不同的业务场景,但是不同项目之间数据、账号、权限、token等业务数据都是完全隔离的,仅共享机器资源。

一、spring boot 动态数据源配置

方案:在一个确切的地方存储 数据源的配置信息(我是将这些信息存储在一个 配置数据库表中,而这个配置数据库是确切的,作为主数据源配置)。启动spring时,会初始化这个配置数据源,然后将其他动态数据源信息取出来初始化好datasource 注册到spring 容器。

原理:主要是实现AbstractRoutingDataSource的抽象类,然后将该类注册到spring容器,其中关键点是:

  1. 配置AbstractRoutingDataSource类的默认数据源Object defaultTargetDataSource 和其他数据源Map<Object, Object> targetDataSources。targetDataSources就是我们动态配置的数据源,key-value 接口,后面根据key 查找 datasource
  2. 实现determineCurrentLookupKey()方法,该方法决定了当前操作选择哪个数据源
  3. 注册到spring 容器

如何确定数据源的选择:业务层面通过API request中附带的参数(header、session、cookie、url_param等)来判断此次请求对应的数据源是哪个?例如 url?appid=1 ,那么就判断此次请求是appid=1 的应用库。第二步,确定后,将appid=1 对应的数据源key 存入本地线程ThreadLocal中。后面在determineCurrentLookupKey方法中 从本地线程ThreadLocal中取出对应的key。spring 会根据该key 选择对应的datasource 作为接下来操作的数据源。

原理大致这样,可以根据业务场景调整数据源选择的设计逻辑

数据源的初始化

配置AbstractRoutingDataSource类的默认数据源Object defaultTargetDataSource 和其他数据源Map<Object, Object> targetDataSources。targetDataSources就是我们动态配置的数据源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void initDataSource(Environment env) throws Exception {

//构建默认数据源
propertyResolver = new RelaxedPropertyResolver(env, "spring.datasource.");
dataSource = DataSourceBuilder.create()
.driverClassName(propertyResolver.getProperty("driver-class-name"))
.url(propertyResolver.getProperty("url"))
.username(propertyResolver.getProperty("username"))
.password(propertyResolver.getProperty("password"))
.type((Class<? extends DataSource>) Class.forName(propertyResolver.getProperty("type")))
.build();

dataBinder(dataSource, env);

logger.info("init primaryDataSource");

//构建其他应用数据源(其他数据源db_name 从 主数据源的project表里面查询)
//以下省略,初始化同主数据源
//.....

其中 数据源初始化时,需要配置相关信息比如说连接池、超时时间等,需要调用 dataBinder方法对数据源参数进行bind操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
spring.datasource.name=datasource
spring.datasource.url=jdbc:mysql://${dbHost}:3306/${dbName}?useUnicode=true&characterEncoding=utf8&useSSL=true
spring.datasource.username=${db_user}
spring.datasource.password=${db_pwd}
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.maximum-pool-size=20
spring.datasource.filters=stat
spring.datasource.maxActive=20
spring.datasource.initialSize=1
spring.datasource.maxWait=10000
spring.datasource.minIdle=1
spring.datasource.timeBetweenEvictionRunsMillis=60000
spring.datasource.minEvictableIdleTimeMillis= 300000
spring.datasource.validationQuery=select 1
spring.datasource.testWhileIdle=true
spring.datasource.testOnBorrow=true
spring.datasource.testOnReturn=true
spring.datasource.poolPreparedStatements=true
spring.datasource.maxOpenPreparedStatements=20

自定义的databind方法,传入 需绑定的datasource 和 获取参数用的Environment

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

private void dataBinder(DataSource dataSource, Environment env) {
RelaxedDataBinder dataBinder = new RelaxedDataBinder(dataSource);
dataBinder.setConversionService(conversionService);
dataBinder.setIgnoreNestedProperties(false);//false
dataBinder.setIgnoreInvalidFields(false);//false
dataBinder.setIgnoreUnknownFields(true);//true
if (dataSourcePropertyValues == null) {
Map<String, Object> rpr = new RelaxedPropertyResolver(env, "spring.datasource").getSubProperties(".");
Map<String, Object> values = new HashMap<>(rpr);
// 排除已经设置的属性
values.remove("type");
values.remove("driver-class-name");
values.remove("url");
values.remove("username");
values.remove("password");
values.remove("name");
dataSourcePropertyValues = new MutablePropertyValues(values);
}
dataBinder.bind(dataSourcePropertyValues);
}

动态数据源处理

上面提到AbstractRoutingDataSource,以下是其部分源码。 关注determineTargetDataSource()方法、determineCurrentLookupKey()(需重写)、afterPropertiesSet方法。其中代码中的 resolvedDataSources是通过targetDataSource(key-datasource的Map)赋值的,通过setTargetDataSources设置进去后会调用afterPropertiesSet()方法设置为resolvedDataSources,按照上述逻辑实现determineCurrentLookupKey方法,找到数据源即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//以下为AbstractRoutingDataSource类中的源码
public void setTargetDataSources(Map<Object, Object> targetDataSources) {
this.targetDataSources = targetDataSources;
}

public void setDefaultTargetDataSource(Object defaultTargetDataSource) {
this.defaultTargetDataSource = defaultTargetDataSource;
}

protected DataSource determineTargetDataSource() {
Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
Object lookupKey = this.determineCurrentLookupKey();
DataSource dataSource = (DataSource)this.resolvedDataSources.get(lookupKey);
if(dataSource == null && (this.lenientFallback || lookupKey == null)) {
dataSource = this.resolvedDefaultDataSource;
}

if(dataSource == null) {
throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
} else {
return dataSource;
}
}

protected abstract Object determineCurrentLookupKey();

重写的determineCurrentLookupKey方法:(其中Constant.{XXXX}只是定义好的的字符串常量)。自己实现DynamicDataSource类

1
2
3
4
5
6
7
8
9
public class DynamicDataSource extends AbstractRoutingDataSource{

@Override
protected Object determineCurrentLookupKey() {
String appointDataSource = ThreadLocalUtil.get(Constant.APPOINT_DATA_SOURCE);//预留支持注解切换数据源
return (StringUtil.isBlank(appointDataSource) ? "dataSource" : appointDataSource);
}
//......
}

Request拦截器拦截处理,确定选择哪个数据源,将数据源key 存储到本地线程中的Map中,determineCurrentLookupKey则是从线程的Map中取出key。(本例中:数据库名称 = db_{数据源key} = db_{appkey}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//preHandle方法
//举例:url附带appKey参数;也可以从session、header中获取
String appKey = request.getParameter(Constant.WEB_DEFAULT_APPKEY);
//根据appkey 设置数据源key 到ThreadLocal
ThreadLocalUtil.set(Constant.APPOINT_DATA_SOURCE, appKey);

//ThreadLocalUtil 定义了一个内部Map。set方法只是将 Constant.APPOINT_DATA_SOURCE = appKey
private static final ThreadLocal<Map<String, Object>> threadLocal = new ThreadLocal() {
protected Map<String, Object> initialValue() {
return new HashMap(4);
}
};
public static void set(String key, Object value) {
Map map = (Map)threadLocal.get();
map.put(key, value);
}

注册到Spring 容器

核心:自定义类 DynamicDataSourceRegister implements ImportBeanDefinitionRegistrar。 实现registerBeanDefinitions方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public void registerBeanDefinitions(AnnotationMetadata annotaion,BeanDefinitionRegistry registry) {
logger.info("registerBeanDefinitions");

GenericBeanDefinition beanDefinition = new GenericBeanDefinition();
beanDefinition.setBeanClass(DynamicDataSource.class);
beanDefinition.setSynthetic(true);
MutablePropertyValues mpv = beanDefinition.getPropertyValues();
//相当于调用 set方法,为BeanClass 的属性赋值
mpv.addPropertyValue("defaultTargetDataSource", dataSource);
mpv.addPropertyValue("targetDataSources",targetDataSources);
//注册dataSource bean(DynamicDataSource)到spring 容器
registry.registerBeanDefinition("dataSource", beanDefinition);
}

在spring boot 应用启动时

1
@Import(DynamicDataSourceRegister.class)

关于事务 和 子线程

注意:AbstractRoutingDataSource 只支持单库事务,也就是说切换数据源要在开启事务之前执行。
spring DataSourceTransactionManager进行事务管理,开启事务,会将数据源缓存到DataSourceTransactionObject对象中进行后续的commit rollback等事务操作。

二、运行时动态添加数据源

运行时,动态添加数据源,按照上述逻辑,只需要将新的datasource添加到targetDataSource的Map中,然后通知更新

举例方案:开放一个接口,通过接口形式调用;主要做两件事1.往数据库里面插入新数据源配置信息;2.将该数据源初始化好放到targetDataSource的Map中,然后通知已经更新数据源

我在配置信息表中只存储了appkey 也就是dbname。动态数据源我都用统一的用户、密码和连接池,这些信息都存在配置文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@GetMapping("/api/project/register")
@ResponseBody
public String register(@RequestParam String appKey) {

// 在配置数据库中新增数据源信息.我是通过脚本insert的,这里省略
// insertDataSource()
// sysProperties 是系统统一取properties 的类

// 获取context.
ApplicationContext ctx = SpringUtil.getApplicationContext();

// 获取 DynamicDataSource。之前注册给spring 容器,这里可以通过ctx直接拿
DynamicDataSource dynamicDataSource = ctx.getBean(DynamicDataSource.class);

// 构建新数据源
String url = sysProperties.getDbAppUrl().replace("{dbName}", "db_" + appKey);
DataSource ds = DataSourceBuilder.create()
.driverClassName(sysProperties.getDbAppDriverClass())
.url(url)
.username(sysProperties.getDbAppUserName())
.password(sysProperties.getDbAppPassword())
.type(com.zaxxer.hikari.HikariDataSource.class)
.build();

// 重设并通知,维护的TargetDataSource
dynamicDataSource.addDataSourceToTargetDataSource(appKey, ds);
return "success";
}

class DynamicDataSource新增以下内容。

DynamicDataSource中多加了一个ConcurrentHashMap<String, DataSource> backupTargetDataSources,用于后续的动态添加数据源时做targetDataSources的备份,因为无法获取targetDataSources,只能设置targetDataSources。


//预备一份用于存储targetDataSource
private ConcurrentHashMap<String, DataSource> backupTargetDataSources = new ConcurrentHashMap<>();

public void addDataSourceToTargetDataSource(String key ,DataSource ds){
    this.backupTargetDataSources.put(key, ds);
    this.setTargetDataSource(this.backupTargetDataSources);
}


public void setTargetDataSource(Map targetDataSource){
    super.setTargetDataSources(targetDataSource);
    this.afterPropertiesSet();
}

这样当程序需要支持一个新应用/新场景时,只需要通过脚本完成下面两步:1.建数据库,初始化好数据。2. 调用接口:curl ${baseurl}/api/project/register?appKey=xxxx

以上,只按照思路记录关键code,并不是完整的代码


Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×