Content Table

动态切换数据源

动态切换数据源的原理为使用 AbstractRoutingDataSource,根据数据源的名字查找数据源:

  1. 在 Spring IoC 容器中创建一个 AbstractRoutingDataSource 实现的对象 routingDataSource (Spring 默认没有使用此数据源)
  2. 创建一个数据源的 Map dataSourceMap,key 为 DataSource 的名字,value 为 DataSource 的对象
  3. 调用 routingDataSource.setTargetDataSources(dataSourceMap) 设置可供使用的数据源
  4. 在需要使用数据源的时候,Spring JDBC 会调用 routingDataSource.determineTargetDataSource() 获取数据源,而要返回哪个数据源,由 routingDataSource.determineCurrentLookupKey() 返回的数据源的名字决定

下面我们从最简单的方式实现数据源切换,然后一步一步的深入优化基于 AbstractRoutingDataSource 的数据源。

一、暴力实现

把下面的代码放到项目中,就实现了数据源的动态切换,会自动的轮流使用数据源一 master 和数据源二 slave。

配置数据源

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
package com.xtuer.config;

import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

/**
* 配置数据源
*/
@Configuration
public class DataSourceConfig {
// 数据源一
public DataSource masterDataSource() {
return DataSourceBuilder.create()
.username("root")
.password("root")
.url("jdbc:mysql://127.0.0.1:3306/test-1?useUnicode=true&characterEncoding=UTF-8&useSSL=false")
.driverClassName("com.mysql.jdbc.Driver")
.build();
}

// 数据源二
public DataSource slaveDataSource() {
return DataSourceBuilder.create()
.username("root")
.password("root")
.url("jdbc:mysql://127.0.0.1:3306/test-2?useUnicode=true&characterEncoding=UTF-8&useSSL=false")
.driverClassName("com.mysql.jdbc.Driver")
.build();
}

// 在 Spring IoC 容器中创建一个 AbstractRoutingDataSource 实现的对象 routingDataSource
@Bean
public DataSource routingDataSource() {
// [1] 实际的物理数据源
DataSource masterDataSource = this.masterDataSource();
DataSource slaveDataSource = this.slaveDataSource();

// [2] 给每个物理数据源绑定一个名字,把它们传递给 RoutingDataSource
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("master", masterDataSource);
targetDataSources.put("slave", slaveDataSource);

RoutingDataSource rds = new RoutingDataSource();
rds.setTargetDataSources(targetDataSources);

// [3] 如果 determineCurrentLookupKey 返回的 key 找不到对应的数据源,则使用这里设置的默认数据源
rds.setDefaultTargetDataSource(masterDataSource);

return rds;
}
}

动态数据源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.xtuer.config;

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

/**
* 动态数据源
*/
public class RoutingDataSource extends AbstractRoutingDataSource {
private int count = 0;

// 使用此 lookupKey (数据源的名字) 查找对应的数据源
@Override
protected Object determineCurrentLookupKey() {
// 轮流使用不同的数据源访问数据库
String dataSourceName = count % 2 == 0 ? "master" : "slave";
System.out.println("获取数据源: " + dataSourceName);
count++;

return dataSourceName;
}
}

验证多数据源

使用动态切换数据源的目的,大多数目标都是为了实现读写分离,不过配置 MySQL 的读写分离有点花时间,为了验证数据源的切换是否生效,可以采用下面的简单办法:

  1. 创建 2 个数据库: test-1, test-2
  2. 在它们里面分别创建结构完全一样的表 foo,只有 2 列: id 和 name
    • test-1 中: id 为 1,name 为 test-1-name
    • test-1 中: id 为 1,name 为 test-2-name
  3. 使用 MyBatis 的 Mapper 查询 id 为 1 的记录,如果看到轮流的输出 test-1-name 和 test-2-name,说明数据源切换生效了

数据库脚本:

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE foo (
id INT(11) NOT NULL,
name VARCHAR(64) NOT NULL,
PRIMARY KEY (id)
)

# 数据库 test-1 中执行
INSERT INTO foo (id, name) VALUES (1, 'test-1-name');

# 数据库 test-2 中执行
INSERT INTO foo (id, name) VALUES (1, 'test-2-name');

疑惑

在不少其他人的文章中,上面的 masterDataSourceslaveDataSource 会使用 @Bean 的方式自动创建,然后注入 routingDataSource() 但是我们没这么做,有以下几个原因:

  • 在 Spring IoC 容器中,如果同时存在多个 DataSource 对象,有时候有可能会出错,例如引入分布式唯一 ID 生成器 uid-generator-spring-boot-starter,可能是由于 Mapper 存在于不同的包中导致的
  • 不同环境中数据库的配置可能不同,读取数据源的配置动态生成数据源比较合理 (这里为了方便说明原理,忽略这种做法),而不是用函数固定写死,例如:
    • 开发环境: 只有一个
    • 测试环境: 一主一从
    • 线上环境: 一主多从,甚至使用 MySQL 的黑洞引擎实现多主多从
  • 方便创建数据库连接池: 在 Spring Boot 2.0 中,每个 DataSource 都会自动的为其创建一个连接池,但是使用的是默认配置,application.yml 中 HikariCP 的配置不会生效

到此我们已经知道了怎么实现动态的切换数据源,接下来的内容只是为了把事情做的更好,大家可以先看标题,然后思考一下如果是自己来做,应该怎么实现呢?

二、使用连接池

修改 masterDataSource() 和 slaveDataSource() 的代码,返回 HikariDataSource 即可使用数据库连接池 HikariCP,使用其他数据库连接池如 Druid 在此修改即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 数据源一
public DataSource masterDataSource() {
HikariDataSource ds = new HikariDataSource();
ds.setUsername("root");
ds.setPassword("root");
ds.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/test-1?useUnicode=true&characterEncoding=UTF-8&useSSL=false");
ds.setDriverClassName("com.mysql.jdbc.Driver");
ds.setMaximumPoolSize(3);

return ds;
}

// 数据源二
public DataSource slaveDataSource() {
HikariDataSource ds = new HikariDataSource();
ds.setUsername("root");
ds.setPassword("root");
ds.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/test-2?useUnicode=true&characterEncoding=UTF-8&useSSL=false");
ds.setDriverClassName("com.mysql.jdbc.Driver");
ds.setMaximumPoolSize(5);

return ds;
}

连接池的配置都写在代码里不太好,可以把它们放到 application.yml 中:

1
2
3
4
5
6
# Connection Pool: Hikari
spring.datasource.hikari.minimumIdle : 5
spring.datasource.hikari.maximumPoolSize : 5
spring.datasource.hikari.idleTimeout : 30000
spring.datasource.hikari.maxLifetime : 2000000
spring.datasource.hikari.connectionTimeout: 30000

在 DataSourceConfig 中读取这些配置到 HikariConfig,用于创建 HikariDataSource:

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
64
65
package com.xtuer.config;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

/**
* 创建数据源
*/
@Configuration
public class DataSourceConfig {
// 数据库连接池的配置
@Bean
@ConfigurationProperties("spring.datasource.hikari")
public HikariConfig hikariConfig() {
return new HikariConfig();
}

// 数据源一
public DataSource masterDataSource(HikariConfig hikariConfig) {
hikariConfig.setUsername("root");
hikariConfig.setPassword("root");
hikariConfig.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/test-1?useUnicode=true&characterEncoding=UTF-8&useSSL=false");
HikariDataSource ds = new HikariDataSource(hikariConfig);

return ds;
}

// 数据源二
public DataSource slaveDataSource(HikariConfig hikariConfig) {
hikariConfig.setUsername("root");
hikariConfig.setPassword("root");
hikariConfig.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/test-2?useUnicode=true&characterEncoding=UTF-8&useSSL=false");
HikariDataSource ds = new HikariDataSource(hikariConfig);

return ds;
}

// 在 Spring IoC 容器中创建一个 AbstractRoutingDataSource 实现的对象 routingDataSource
@Bean
public DataSource routingDataSource(HikariConfig hikariConfig) {
// [1] 实际的物理数据源
DataSource masterDataSource = this.masterDataSource(hikariConfig);
DataSource slaveDataSource = this.slaveDataSource(hikariConfig);

// [2] 给每个物理数据源绑定一个名字,把它们传递给 RoutingDataSource
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("master", masterDataSource);
targetDataSources.put("slave", slaveDataSource);

RoutingDataSource rds = new RoutingDataSource();
rds.setTargetDataSources(targetDataSources);

// [3] 如果 determineCurrentLookupKey 返回的 key 找不到对应的数据源,则使用这里设置的默认数据源
rds.setDefaultTargetDataSource(masterDataSource);

return rds;
}
}

由于 username, password, url 因数据库而异,应该使用其他配置,而不能放在连接池的统一配置中,如:

1
2
3
4
5
6
7
8
spring.datasource.master:
username: root
password: root
url: jdbc:mysql://127.0.0.1:3306/test-1?useUnicode=true&characterEncoding=UTF-8&useSSL=false

spring.datasource.slaves:
- { username: root, password: root, url: jdbc:mysql://192.168.0.90:3306/test-2?useUnicode=true&characterEncoding=UTF-8&useSSL=false }
- { username: root, password: root, url: jdbc:mysql://192.168.0.91:3306/test-2?useUnicode=true&characterEncoding=UTF-8&useSSL=false }

读取数据库的配置和连接池的配置,替换代码中 routingDataSource() 创建数据源的部分,就可以根据不同的环境生成不同的动态数据源。

三、MyBatis 数据源配置

在 Spring Boot 2.0 中不需要配置下面的代码也会自动的使用我们注入的数据源 routingDataSource,Spring MVC 4 等没有试过,如果有需要时就给加上吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
@EnableTransactionManagement
public class MyBatisConfig {
// 注入 routingDataSource
@Resource(name = "routingDataSource")
private DataSource dataSource;

@Bean
public SqlSessionFactory sqlSessionFactory() throws Exception {
SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();

sqlSessionFactoryBean.setDataSource(dataSource);
sqlSessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver()
.getResources("classpath:mapper/*.xml"));

return sqlSessionFactoryBean.getObject();
}

@Bean
public PlatformTransactionManager platformTransactionManager() {
return new DataSourceTransactionManager(dataSource);
}
}

四、读写分离

读写分离的本质也是动态切换数据源,使用 Spring AoP 的 Before Advice 进行数据源切换,一般实现方式有:

  • 在方法上使用注解明确的指定要使用写的数据源还是读的数据源
  • 按照一定的规则命名读或写的方法,AoP 中根据方法名确定要使用读还是写的数据源,自动切换。方法的命名规则如:
    • 读:
      • findUserByName
      • countUsers
      • selectUser
      • getUser
    • 写:
      • saveUser
      • insertUser
      • createUser
      • updateUser
      • upsertUser
      • deleteUser
    • 推荐参考 JPA 的命名规则
  • 为了解决线程安全问题,可以使用 ThreadLocal 存储当前线程使用的数据源名称: Before Advice 的函数中确定数据源的名字,接下来 Spring 在 determineCurrentLookUpKey() 中使用此数据源名字
  • 读库的负载均衡,可以使用简单的轮询方式使用读数据源

读写分离的实现主要就是 Spring AoP 的内容了,可以搜索其他人的文章进行学习,例如:

这里我们介绍通过切换数据源的方式实现读写分离,主要目的是介绍实现原理,此外还可以使用专业的分布式数据库中间件 MyCatApache ShardingSphere 等来实现。