动态数据源实现多版本内容库切换

序言

最近有一个基于C/S(客户/服务端)结构的多版本内容控制的需求。服务端为所有客户端提供知识库内容支持,客户端可以通过前台页面在线更新服务端的知识库内容,客户端需要使用知识库的内容进行一系列的操作,具体做什么操作不是重点。

现在问题是产品需要支持在旧版本的知识库创建的作业更新后任然使用旧版本知识库内容,而创建新作业时使用更新的知识库内容

经过需求分析之后决定使用动态数据源来处理不同作业访问不同知识库内容的方式从而解决这个问题。

数据源连接

Spring 框架中,我们通常使用 DataSource 对象来管理数据库连接。javax.sql.DataSource 是一个 Java 接口,它提供了与数据库连接相关的方法和功能。它是连接数据库的一种方式,可以通过这个接口来获取数据库连接,执行 SQL 查询和更新等操作。

传统的 JDBC 访问数据库技术,每次访问数据库都需要通过数据库驱动器 Driver 和数据库名称以及密码等等资源建立数据库连接。频繁的建立数据库连接与断开数据库,这样会消耗大量的系统资源和时间,降低性能,需要一定的内存和 CPU 开销。

通过建立数据库连接池,将这些数据库连接保存在数据连接池中,访问数据库时,只需要从数据库连接池中获取空闲的数据库连接,当程序员访问数据库结束时,数据连接会放回数据库连接池中,从而大大提高系统性能。

SpringBoot官方提供的动态数据源

AbstractRoutingDataSourcespringboot 官方提供给我们的多数据源切换策略,下面介绍一下该类中的核心属性和方法。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {

// 目标数据源,也就是所有需要进行切换的所有数据源保存在这个map中
@Nullable
private Map<Object, Object> targetDataSources;

// 默认的数据源,无任何切换操作时使用默认数据库及事物上下文
@Nullable
private Object defaultTargetDataSource;

// 当切换的数据库不存在时是否回退到默认数据库
private boolean lenientFallback = true;

// 通过JNDI寻找数据源的默认实现
private DataSourceLookup dataSourceLookup = new JndiDataSourceLookup();

// 将targetDataSources转化为DataSource
@Nullable
private Map<Object, DataSource> resolvedDataSources;

@Nullable
private DataSource resolvedDefaultDataSource;

/*
* 实现InitializingBean类的方法,初始化bean后会调用,将targetDataSources转化为resolvedDataSources
*/
@Override
public void afterPropertiesSet() {
if (this.targetDataSources == null) {
throw new IllegalArgumentException("Property 'targetDataSources' is required");
}
this.resolvedDataSources = CollectionUtils.newHashMap(this.targetDataSources.size());
this.targetDataSources.forEach((key, value) -> {
Object lookupKey = resolveSpecifiedLookupKey(key);
DataSource dataSource = resolveSpecifiedDataSource(value);
this.resolvedDataSources.put(lookupKey, dataSource);
});
if (this.defaultTargetDataSource != null) {
this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
}
}

// 根据key获取数据源,key一般存放在当前线程的ThreadLoacl中
protected DataSource determineTargetDataSource() {
Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
// 获取当前线程对应数据源的标识key
Object lookupKey = determineCurrentLookupKey();
// 从数据源集合中获取数据源对象
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 + "]");
}
return dataSource;
}

// 这个方法是让我们实现返回当前要切换的数据源的key
@Nullable
protected abstract Object determineCurrentLookupKey();

所以使用 spring boot 官方提供的多数据源切换策略只需要实现 AbstractRoutingDataSource 类然后重写 determineCurrentLookupKey() 方法即可。

官方提供的多数据源切换策略相对较简单,还有一种第三方提供的动态数据源也是下面重点要介绍的。

苞米豆动态数据源

一般的实现方式是通过 ThreadLocal 和注解,通过 AOP 切面在需要切换数据源的地方设置需要切换的数据源的key即可,注意 ThreadLocal 设置使用完后需要清理,否则可能造成内存溢出。

这里主要介绍通过 baomidoudynamic-datasource 来配置多数据源切换操作,自定义配置数据来源以及动态增减数据源。

由于项目中使用了 Druid 做数据库连接池,所以会一并介绍 Druiddynamic-datasource 的整合方式。

依赖

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.14</version>
</dependency>

<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot-starter</artifactId>
<version>4.2.0</version>
</dependency>

配置

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
spring:
# 数据源配置
datasource:
# druid的全局配置
druid:
filter:
stat:
enabled: true
# 慢SQL记录
log-slow-sql: true
slow-sql-millis: 1000
merge-sql: false
wall:
config:
multi-statement-allow: true
webStatFilter:
enabled: true
statViewServlet:
enabled: true
# 设置白名单,不填则允许所有访问
allow:
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username: admin
login-password: 123456
dynamic:
primary: master
strict: false
datasource:
master:
type: com.alibaba.druid.pool.DruidDataSource
url: jdbc:postgresql://ip:port/database_1?useUnicode=true&characterEncoding=UTF-8&allowMultiQueries=true&serverTimezone=Asia/Shanghai&reWriteBatchedInserts=true
username: user_1
password: password
driverClassName: org.postgresql.Driver
# slave:
# type: com.alibaba.druid.pool.DruidDataSource
# url: jdbc:postgresql://ip:port/database_2?useUnicode=true&characterEncoding=UTF-8&allowMultiQueries=true&serverTimezone=Asia/Shanghai&reWriteBatchedInserts=true
# username: user_2
# password: password
druid:
# 初始连接数
initialSize: 5
# 最小连接池数量
minIdle: 10
# 最大连接池数量
maxActive: 20
# 配置获取连接等待超时的时间
maxWait: 60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
timeBetweenEvictionRunsMillis: 60000
# 配置一个连接在池中最小生存的时间,单位是毫秒
minEvictableIdleTimeMillis: 300000
# 配置一个连接在池中最大生存的时间,单位是毫秒
maxEvictableIdleTimeMillis: 900000
# 配置检测连接是否有效
validationQuery: SELECT version()
testWhileIdle: true
testOnBorrow: false
testOnReturn: false

到这一步其实我们就可以通过 @DS ("配置的数据库名称") 注解来切换数据源了。

注解可以配置在类和方法上面,配置在方法上面的注解优先级大于配置在类上面的注解。

自定义数据源配置来源

有时候我们并不固定数据源的加载位置。例如在上述场景中,每次更新一次知识库都需要创建一个数据源连接,然后将这个连接存入 master 库,我们不可能每次新增一个连接就将数据源连接配置到yml 文件中,所以就需要系统启动时主动从别的地方获取数据源连接,这里我从 master 库获取。

我们先来看它的核心类 DynamicRoutingDataSource 的源码 ,除了 springboot 官方提供的多数据源切换操作之外,它还扩展提供了数据源分组、动态新增或删除数据源连接等方法。

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
29
30
31
32
33
34
public class DynamicRoutingDataSource extends AbstractRoutingDataSource implements InitializingBean, DisposableBean {
// 其他属性和方法删除,只保留核心方法属性
// 所有数据库
private final Map<String, DataSource> dataSourceMap = new ConcurrentHashMap<>();
// 分组数据库
private final Map<String, GroupDataSource> groupDataSources = new ConcurrentHashMap<>();
// 数据源加载接口
private final List<DynamicDataSourceProvider> providers;

@Override
public void afterPropertiesSet() {
// 检查开启了配置但没有相关依赖
checkEnv();
// 添加并分组数据源
Map<String, DataSource> dataSources = new HashMap<>(16);
for (DynamicDataSourceProvider provider : providers) {
Map<String, DataSource> dsMap = provider.loadDataSources();
if (dsMap != null) {
dataSources.putAll(dsMap);
}
}
for (Map.Entry<String, DataSource> dsItem : dataSources.entrySet()) {
addDataSource(dsItem.getKey(), dsItem.getValue());
}
// 检测默认数据源是否设置
if (groupDataSources.containsKey(primary)) {
log.info("dynamic-datasource initial loaded [{}] datasource,primary group datasource named [{}]", dataSources.size(), primary);
} else if (dataSourceMap.containsKey(primary)) {
log.info("dynamic-datasource initial loaded [{}] datasource,primary datasource named [{}]", dataSources.size(), primary);
} else {
log.warn("dynamic-datasource initial loaded [{}] datasource,Please add your primary datasource or check your configuration", dataSources.size());
}
}
}

注意 afterPropertiesSet() 方法中循环遍历了 providers 这个实例变量,并将loadDataSources() 方法返回的数据源添加到 dataSourceMap 中。

所以我们可以猜测 DynamicDataSourceProvider 是用来加载数据源连接的接口。

image-20231113102320148

果然,不出我们所料。它有一个默认的实现类从 yml 配置文件中读取数据源连接。

image-20231113102557770

这个默认的实现类中具体是如何创建数据源的不是我们关注的重点,我们主要看它是何时加载的,弄明白这一点那我们就可以自定义一个数据源加载类,加载我们所需要的数据源连接。

我们从引入的 starter 出发,在 META_INF/spring.factories 文件中可以看到自动配置类。

1
2
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.baomidou.dynamic.datasource.spring.boot.autoconfigure.DynamicDataSourceAutoConfiguration

点进去这个配置类,可以看到

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Slf4j
@Configuration
@EnableConfigurationProperties(DynamicDataSourceProperties.class)
@AutoConfigureBefore(value = DataSourceAutoConfiguration.class, name = "com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure")
@Import({DruidDynamicDataSourceConfiguration.class, DynamicDataSourceCreatorAutoConfiguration.class, DynamicDataSourceAopConfiguration.class, DynamicDataSourceAssistConfiguration.class})
@ConditionalOnProperty(prefix = DynamicDataSourceProperties.PREFIX, name = "enabled", havingValue = "true", matchIfMissing = true)
public class DynamicDataSourceAutoConfiguration implements InitializingBean {

private final DynamicDataSourceProperties properties;

private final List<DynamicDataSourcePropertiesCustomizer> dataSourcePropertiesCustomizers;

public DynamicDataSourceAutoConfiguration(
DynamicDataSourceProperties properties,
ObjectProvider<List<DynamicDataSourcePropertiesCustomizer>> dataSourcePropertiesCustomizers) {
this.properties = properties;
this.dataSourcePropertiesCustomizers = dataSourcePropertiesCustomizers.getIfAvailable();
}

@Bean
@ConditionalOnMissingBean
public DataSource dataSource(List<DynamicDataSourceProvider> providers) {
DynamicRoutingDataSource dataSource = new DynamicRoutingDataSource(providers);
dataSource.setPrimary(properties.getPrimary());
dataSource.setStrict(properties.getStrict());
dataSource.setStrategy(properties.getStrategy());
dataSource.setP6spy(properties.getP6spy());
dataSource.setSeata(properties.getSeata());
dataSource.setGraceDestroy(properties.getGraceDestroy());
return dataSource;
}

@Override
public void afterPropertiesSet() {
if (!CollectionUtils.isEmpty(dataSourcePropertiesCustomizers)) {
for (DynamicDataSourcePropertiesCustomizer customizer : dataSourcePropertiesCustomizers) {
customizer.customize(properties);
}
}
}

}

可以看到初始化 dataSource 中传入了入参 providers ,那这个 providers 是从哪来的呢?我们可以发现配置类上面有一个 @Import 注解,引入了其他配置,进入最后一个 DynamicDataSourceAssistConfiguration 配置类,可以发现在这里注入了默认的 yml provider

image-20231113104316481

所以我们也定义一个自动配置类,然后将我们自己的 provider 交给 spring 容器管理。

同时这里默认实现了一个抽象类 provider 供我们使用,我们不必去实现 DynamicDataSourceProvider 接口,只需要实现这个抽象类然后重写 executeStmt() 方法通过默认数据源执行 SQL 获取数据库中的连接即可。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Slf4j
public class CustomDynamicDataSourceProvider extends AbstractJdbcDataSourceProvider {

private static final Pattern PATTERN = Pattern.compile("/([^/]+)\\?");

private final DataSourceProperty dataSourceProperty;

public CustomDynamicDataSourceProvider(DefaultDataSourceCreator defaultDataSourceCreator, DataSourceProperty dataSourceProperty) {
super(defaultDataSourceCreator, dataSourceProperty.getDriverClassName(),
dataSourceProperty.getUrl(), dataSourceProperty.getUsername(), dataSourceProperty.getPassword());
this.dataSourceProperty = dataSourceProperty;
}

@Override
protected Map<String, DataSourceProperty> executeStmt(Statement statement) throws SQLException {
Map<String, DataSourceProperty> dataSourcePropertiesMap = new HashMap<>();
ResultSet resultSet = statement.executeQuery("select * from library_sync_config where is_used = true");
if (Objects.nonNull(resultSet)) {
while (resultSet.next()) {
DataSourceProperty newProperty = BeanUtil.copyProperties(dataSourceProperty, DataSourceProperty.class);
String version = resultSet.getString("version");
String configName = resultSet.getString("config_name");
// 替换url中的数据库名称,其他属性都和master一致
String url = replaceDatabaseName(dataSourceProperty.getUrl(), configName);
newProperty.setPoolName(version);
dataSourcePropertiesMap.put(version, newProperty);
}
}
return dataSourcePropertiesMap;
}

private String replaceDatabaseName(String url, String newDatabaseName) {
String oldDatabaseName = parseDatabaseName(url);
return url.replaceFirst("(?i)" + oldDatabaseName, newDatabaseName);
}

private static String parseDatabaseName(String url) {
// 使用正则表达式提取数据库名
Matcher matcher = PATTERN.matcher(url);

if (matcher.find()) {
return matcher.group(1);
} else {
return null;
}
}
}

这里因为我是通过更新知识库操作再创建一个同级别的数据库,所以除了库名其他信息都是一致的,新连接只需要替换库名即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Configuration
@EnableConfigurationProperties({DynamicDataSourceProperties.class})
public class DynamicDataSourceConfig {
private final DynamicDataSourceProperties properties;

public DynamicDataSourceConfig(DynamicDataSourceProperties properties) {
this.properties = properties;
}

@Bean
@Primary
public DynamicDataSourceProvider dynamicDataSourceProvider(DefaultDataSourceCreator defaultDataSourceCreator) {
Map<String, DataSourceProperty> datasource = this.properties.getDatasource();
// 必须配置master数据源,通过master数据源创建其他连接添加至数据库连接池
DataSourceProperty master = datasource.get("master");
return new CustomDynamicDataSourceProvider(defaultDataSourceCreator, master);
}
}

通过控制台打印的日志我们也可以看到,虽然配置文件只配置了一个数据连接,但系统启动时通过其它地方新增了一个配置连接。

image-20231113110534993

自定义处理逻辑

动态数据源通过拦截添加了 @DS(xxx) 注解的类或方法,根据注解设置的值选取对应数据源连接。

通过配置类 DynamicDataSourceAopConfiguration 发现有两个主要的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 构造数据源处理器,默认会创建三个processor,并以执行链的方式设置好执行顺序
@Bean
@ConditionalOnMissingBean
public DsProcessor dsProcessor(BeanFactory beanFactory) {
DsProcessor headerProcessor = new DsHeaderProcessor();
DsProcessor sessionProcessor = new DsSessionProcessor();
DsSpelExpressionProcessor spelExpressionProcessor = new DsSpelExpressionProcessor();
spelExpressionProcessor.setBeanResolver(new BeanFactoryResolver(beanFactory));
headerProcessor.setNextProcessor(sessionProcessor);
sessionProcessor.setNextProcessor(spelExpressionProcessor);
return headerProcessor;
}

//创建Advisor对象,指定拦截器和需要拦截的注解
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
@Bean
@ConditionalOnProperty(prefix = DynamicDataSourceProperties.PREFIX + ".aop", name = "enabled", havingValue = "true", matchIfMissing = true)
public Advisor dynamicDatasourceAnnotationAdvisor(DsProcessor dsProcessor) {
DynamicDatasourceAopProperties aopProperties = properties.getAop();
DynamicDataSourceAnnotationInterceptor interceptor = new DynamicDataSourceAnnotationInterceptor(aopProperties.getAllowedPublicOnly(), dsProcessor);
DynamicDataSourceAnnotationAdvisor advisor = new DynamicDataSourceAnnotationAdvisor(interceptor, DS.class);
advisor.setOrder(aopProperties.getOrder());
return advisor;
}

DsProcessor 是一个处理器的抽象类,我们只需要实现何时匹配这个处理器,以及根据传入的key决定要匹配哪个数据源连接。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public abstract class DsProcessor {

/**
* 下一个执行器
*/
private DsProcessor nextProcessor;

/**
* 设置下一个执行器
*
* @param dsProcessor 执行器
*/
public void setNextProcessor(DsProcessor dsProcessor) {
this.nextProcessor = dsProcessor;
}

/**
* 抽象匹配条件 匹配才会走当前执行器否则走下一级执行器
*
* @param key DS注解里的内容
* @return 是否匹配
*/
public abstract boolean matches(String key);

/**
* 决定数据源
* <pre>
* 调用底层doDetermineDatasource,
* 如果返回的是null则继续执行下一个,否则直接返回
* </pre>
*
* @param invocation 方法执行信息
* @param key DS注解里的内容
* @return 数据源名称
*/
public String determineDatasource(MethodInvocation invocation, String key) {
if (matches(key)) {
String datasource = doDetermineDatasource(invocation, key);
if (datasource == null && nextProcessor != null) {
return nextProcessor.determineDatasource(invocation, key);
}
return datasource;
}
if (nextProcessor != null) {
return nextProcessor.determineDatasource(invocation, key);
}
return null;
}

/**
* 抽象最终决定数据源
*
* @param invocation 方法执行信息
* @param key DS注解里的内容
* @return 数据源名称
*/
public abstract String doDetermineDatasource(MethodInvocation invocation, String key);
}

所以我们只需要在配置文件中创建自己的 Processor 即可自定义数据源的处理逻辑,当然,我们也可以定义多个 Processor 形成一条执行链。

我们定义好自己的 Processor 之后因为 @ConditionalOnMissingBean 注解默认的处理器就不会生效了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Bean
public DsProcessor dsProcessor() {
return new MyProcessor();
}

public class MyProcessor extends DsProcessor {
@Override
public boolean matches(String key) {
// 定义何时匹配这个处理器
return true;
}

@Override
public String doDetermineDatasource(MethodInvocation invocation, String key) {
// 自己的处理逻辑
return null;
}
}

动态增减数据源

DynamicRoutingDataSource 组件中提供了 addDataSource()removeDataSource() 两个方法,使用时只需要注入该组件调用指定方法即可,十分方便。


动态数据源实现多版本内容库切换
https://seeyourface.cn/2023/11/10/动态数据源实现多版本内容库切换/
作者
Yang Lei
发布于
2023年11月10日
许可协议