笔者公司的数据库使用多租户架构,这里稍微解释一下何为多租户,其实说白了就是每个客户单独使用一个数据库,每个客户的数据物理隔离。
那么我们需要解决的是,不同的客户登录后,其操作的是属于他的数据库。我们采用了spring的AbstractRoutingDataSource实现根据当前用户动态切库的功能。如何做的呢?我们需要理解AbstractRoutingDataSource的工作原理。
在说明动态切换数据源之前,我们需要先了解一下spring在单数据源情况下是如何工作的。我们先说一下什么是DataSource?有什么用呢?
请看DataSource接口定义:
package javax.sql;
public interface DataSource extends CommonDataSource, Wrapper {
Connection getConnection() throws SQLException;
Connection getConnection(String username, String password)
throws SQLException;
}
聪明的你肯定一下就明白了,原来DataSource就是一个获取数据库connection的工厂类。然后我们还发现它的包名是javax.sql,也就是说它是一个标准。常见的C3P0、DBCP、Hikari、Druid等等数据库连接池都实现了这个接口。
ok, 数据源我们现在弄明白了,那数据源是如何被spring使用的呢?以我们现在用的最广泛的springboot为例,我们在application.properties中配置了数据库连接信息后,mybatis,spring-data-jpa等等orm框架就可以直接工作了,why?
其实原理很简单,我猜你也想到了。spring在初始化系统的过程中读取application.properties中的数据库配置信息,然后实例化一个DataSource bean对象,mybatis、spring-data-jpa等想要操作数据库时获取这个DataSource对象,然后调用其getConnection()方法获得数据库连接,然后操作数据库。
我们搞明白了DataSource工作原理,那么AbstractRoutingDataSource又是如何工作的呢?
我们先抛开spring的设计,一起思考一下。由上面的DataSource原理我们知道,一个DataSource代表一个数据库。那么我们要实现切换数据库,只要每次执行sql之前,从不同的数据源获得连接就可以了。换言之,我们需要实例化多个不同的数据源,然后每次使用的时候取不同的数据源来用。
ok,进入正题,看看spring是如何设计的。
AbstractRoutingDataSource是spring提供的一个抽象类,为了看的清楚,我们先看一下唯一一个需要被实现的方法:
public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {
@Nullable
protected abstract Object determineCurrentLookupKey();
}
这个方法没有参数,并且返回一个Object值,这个值使干嘛的呢?我们暂且放一放,继续往下看(真源码)。(为了大家看的清晰,我删掉了一些无关紧要的内容,保留了主要逻辑)
public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {
@Nullable
private Map<Object, Object> targetDataSources;
@Nullable
private Object defaultTargetDataSource;
@Nullable
private Map<Object, DataSource> resolvedDataSources;
@Nullable
private DataSource resolvedDefaultDataSource;
//设置需要切换的所有数据源(除了默认数据源)
public void setTargetDataSources(Map<Object, Object> targetDataSources) {
this.targetDataSources = targetDataSources;
}
//默认数据源
public void setDefaultTargetDataSource(Object defaultTargetDataSource) {
this.defaultTargetDataSource = defaultTargetDataSource;
}
public void afterPropertiesSet() {
this.resolvedDataSources = new HashMap(this.targetDataSources.size());
this.targetDataSources.forEach((lookupKey, dataSource) -> {
this.resolvedDataSources.put(lookupKey, dataSource);
});
if (this.defaultTargetDataSource != null) {
this.resolvedDefaultDataSource = defaultTargetDataSource;
}
}
protected DataSource determineTargetDataSource() {
Object lookupKey = this.determineCurrentLookupKey();
DataSource dataSource = (DataSource)this.resolvedDataSources.get(lookupKey);
if (dataSource == null) {
dataSource = this.resolvedDefaultDataSource;
}
return dataSource;
}
@Nullable
protected abstract Object determineCurrentLookupKey();
}
接下来我们来模拟一下AbstractRoutingDataSource的使用过程,并说明上面代码的工作原理。
class Demo {
public static void main(String args[]){
//初始化动态数据源
DemoDynamicDataSource dds = new DemoDynamicDataSource();
dds.setDefaultTargetDataSource(new DataSource());
HashMap<String,DataSource> targetDataSources = new HashMap<>();
targetDataSources.put("1",new DataSource());
targetDataSources.put("2",new DataSource());
dds.setTargetDataSources(targetDataSources);
//spring会在bean初始化最后调用实现了InitializingBean接口bean的afterPropertiesSet()
dds.afterPropertiesSet();
//使用动态数据源
DataSource datasource = dds.determineTargetDataSource();
datasource.getConnection().execute("select xx");
}
}
初始化过程的核心在:afterPropertiesSet(); 代码很简单,将defaultDataSource存起来,将TargetDataSources转存到一个map里。
而使用的核心在:dds.determineTargetDataSource(), 我们看到,首先调用了我们需要实现的determineCurrentLookupKey()方法,然后通过获取到的key到上一步初始化的targetDataSource中取对应的datasource(取不到就使用默认的defaultDataSource),然后返回datasource。
ok,我们现在明白了,原来我们可以通过determineCurrentLookupKey()方法的返回值来控制我们使用哪个数据源。
原理到这已经说完了,只关心原理的同学可以打道回府了~~~。如果你还想参考一下我的实现思路,那么继续往下看吧。
class DataSourceKeyHolder {
private final static ThreadLocal<String> dataSourceKeyHolder = new ThreadLocal<>();
static void set(String key) {
dataSourceKeyHolder.set(key);
}
static String get() {
return dataSourceKeyHolder.get();
}
static void clear() {
dataSourceKeyHolder.remove();
}
}
@Component
@Aspect
public class DataSourceAspect {
//在所有的mapper层做切面,要求满足:文件夹名称为mapper,接口名以Mapper结尾。
@Pointcut("execution(* com..*Mapper.*(..))")
public void aspect() {
}
@Before("aspect()")
public void before(JoinPoint joinPoint) throws InvocationTargetException, IllegalAccessException {
//通过一些逻辑从当前方法上获取当前要切库的key
DataSourceKeyHolder.set(key);
}
@After("aspect()")
public void after() {
DataSourceKeyHolder.clear();
}
具体如何去从method上取出key,设计不同,方法不同,比如根据方法的参数、注解、参数注解一类的信息,又或者从当前登录用户的session中获取到要使用的数据源所对应的key。这里就不实现了。笔者实现的了一个通用的 multi-datasource-spring-boot-starter , 可以直接使用或者做一个参照。
public class DynamicDataSource extends AbstractRoutingDataSource {
protected abstract Object determineCurrentLookupKey(){
return DataSourceKeyHolder.get();
}
}
配置DynamicDataSource:
@Configuration
public class AppConfig{
@Bean
public DataSource datasource(){
DynamicDataSource dds = new DynamicDataSource();
dds.setDefaultTargetDataSource(new DataSource());
HashMap<String,DataSource> targetDataSources = new HashMap<>();
targetDataSources.put("1",new DataSource());
targetDataSources.put("2",new DataSource());
dds.setTargetDataSources(targetDataSources);
return dds;
}
}
好,代码写完了,来梳理一下执行流程:
ok, 结束了?开头说的偶发性异常在哪?哈哈哈,小伙子很细心,居然还记得。
从AbstractRoutingDataSource的源码中我们可以看到,resolvedDataSources (targetDataSources的转存) 是HashMap类型的,网上很多文章介绍的动态更新数据源很多都是通过修改resolvedDataSources的方式做到的,假设你需要动态的新增数据源,我们知道HashMap不是线程安全的,在rehash的过程中可能导致获取到值为null的情况。返回null会导致什么来着?回去看一下determineTargetDataSource()方法代码,发现会切到默认数据源。也就是说并发较大的情况下,在更新数据源的时候有可能造成切库异常。反过来讲,AbstractRoutingDataSource设计时只考虑了静态初始化的情况,并没有考虑动态新增数据源的情况。那这个问题怎么解决了,请看这里吧 : multi-datasource-spring-boot-starter
Over, 完结散花~~~