天天看点

【正确姿势】完全理解 Spring AbstractRoutingDataSource 实现动态(多)数据源切换(避免踩坑)

你可以获得哪些?

  • 完全理解spring动态切库原理
  • 踩坑经验,如果你的系统在使用AbstractRoutingDataSource后偶现切库异常,那么我相信这篇文章对你可能有所启发。
  • 快速接入动态数据源: multi-datasource-spring-boot-starter

使用场景简介

笔者公司的数据库使用多租户架构,这里稍微解释一下何为多租户,其实说白了就是每个客户单独使用一个数据库,每个客户的数据物理隔离。

那么我们需要解决的是,不同的客户登录后,其操作的是属于他的数据库。我们采用了spring的AbstractRoutingDataSource实现根据当前用户动态切库的功能。如何做的呢?我们需要理解AbstractRoutingDataSource的工作原理。

Spring DataSource的工作原理

在说明动态切换数据源之前,我们需要先了解一下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()方法获得数据库连接,然后操作数据库。

AbstractRoutingDataSource工作原理

我们搞明白了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()方法的返回值来控制我们使用哪个数据源。

原理到这已经说完了,只关心原理的同学可以打道回府了~~~。如果你还想参考一下我的实现思路,那么继续往下看吧。

动态切库简易实现

  1. 实现一个DataSourceKeyHolder存放key,ThreadLocal我简要介绍一下,它可以实现在当前线程的任何一个地方set(value),在线程执行后面代码的任何位置通过get()将set()的值取出,而不需要通过方法参数传递。我们可以通过这种方式达到上下文参数传递的目的。
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();
    }
}
           
  1. 以mybatis的mapper为例,做一个切面,在所有mapper方法的执行前set(key);
@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 , 可以直接使用或者做一个参照。

  1. 实现AbstractRoutingDataSource:
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;
	}
}
           

好,代码写完了,来梳理一下执行流程:

  • 首先当一个mapper方法将要被执行前(我们可以认为一个mapper方法是一条sql),会被我们所配置的切面拦截,然后获取到key, set到DataSourceKeyHolder里保存起来。
  • 执行mapper方法之前mybatis还会获取我们实现的DynamicDataSource对象,通过其determineTargetDataSource()方法获取数据源,那么自然的会调用我们实现的determineCurrentLookupKey()方法,然后从DataSourceKeyHolder中取出我们上一步set()的key.并返回key所对应的dataSource给mybatis使用
  • 最后别忘了调用DataSourceKeyHolder.clear()清理ThreadLocal哦

ok, 结束了?开头说的偶发性异常在哪?哈哈哈,小伙子很细心,居然还记得。

踩坑(偶发性异常)

从AbstractRoutingDataSource的源码中我们可以看到,resolvedDataSources (targetDataSources的转存) 是HashMap类型的,网上很多文章介绍的动态更新数据源很多都是通过修改resolvedDataSources的方式做到的,假设你需要动态的新增数据源,我们知道HashMap不是线程安全的,在rehash的过程中可能导致获取到值为null的情况。返回null会导致什么来着?回去看一下determineTargetDataSource()方法代码,发现会切到默认数据源。也就是说并发较大的情况下,在更新数据源的时候有可能造成切库异常。反过来讲,AbstractRoutingDataSource设计时只考虑了静态初始化的情况,并没有考虑动态新增数据源的情况。那这个问题怎么解决了,请看这里吧 : multi-datasource-spring-boot-starter

Over, 完结散花~~~