多数据源应用场景
多数据源的应用很大程度上是为了满足多租户业务场景,多租户是一种软件架构技术,是实现如何在多用户环境下共用相同的系统或程序组件,并且可确保各用户间数据的隔离性,多租户的重点就是同一套程序下实现多用户数据的隔离。因此,多数据源就在这种场景下应运而生。
目前是实现多数据源的技术方案大致可以分为三类
1)每个用户都对应一个独立的数据库
2)每个用户共享数据库,同时每个用户对应一个独立的schema
3)通过在表中增加一个字段标识来区别数据所属的用户
上面三种数据隔离的实现方式都会有自己的业务场景价值,隔离级别高,安全性好,就会增加数据库的部署数量和维护成本,相对,隔离级别低,安全性低,就会增加代码的开发复杂度。
spring集成多数据源
1)数据源集成
上面说明了三种多租户的实现方式,这里仅介绍前两种在spring中一个通用的实现方式--AbstractRoutingDataSource
AbstractRoutingDataSource的内部维护了一个名为targetDataSources的Map和一个默认数据源defaultTargetDataSource,这两个参数可以在服务启动成功后进行设置,根据系统涉及到的数据源种类数量,对targetDataSources进行初始设置,其中key对应数据源的名称,value对应一个DataSource的实现;用户在切换数据源时根据数据源名称在 targetDataSources找DataSource时,如果没有相应的数据源,则会使用默认数据源defaultTargetDataSource。
例子说明:
本人工作中因业务需求,需要在PG数据库中基于多个schema做数据隔离实现多租户,数据库结构如下:
上面截图中有四个不同的schema,在服务启动后将这四个schema加载到targetDataSources,并将public设置为defaultTargetDataSource,本人对项目代码做了删减,具体参考如下:
@Autowired
private AbstractRoutingDataSource multiRouteDataSource;
public Map<String, DruidDataSource> DATA_SOURCE_MAP = new HashMap<>();
@EventListener
public void refreshDataSources(ApplicationReadyEvent event) {
Runnable loadResource = new Runnable() {
@Override
public void run() {
loadDataSources();
}
};
Thread thread = new Thread(loadResource);
thread.start();
log.info(LogFormatter.toMsg("load resource"));
}
public void loadDataSources(){
DataSourceContext.setDataSource("public");
//获取所有的schema,本项目是将所有schema放在public的特定表中。可以根据实际情况决定去存储schema的信息
List<String> schemaList = bussinessDao.getAllSchema();
DataSourceContext.clearDataSource();
//过滤掉一些PG自带的schema
Iterator<String> iterator = schemaList.iterator();
while(iterator.hasNext()){
String schema = iterator.next();
if(schema.startsWith("pg_")){
iterator.remove();
}
}
if (schemaList != null && schemaList.size() > 0) {
Map<Object, Object> targetDataSources = new HashMap<>();
for (String schema : schemaList) {
log.info(LogFormatter.toMsg("multiDataSource init","schema"), schema);
if(ObjectUtils.isNotEmpty(DATA_SOURCE_MAP.get(schema))){
targetDataSources.put(schema, DATA_SOURCE_MAP.get(schema));
}else{
//根据schema初始化数据源,具体实现省略
DruidDataSource dataSource = initDataSource(schema)
DATA_SOURCE_MAP.put(schema, dataSource);
targetDataSources.put(schema, dataSource);
}
}
if(ObjectUtils.isEmpty(DATA_SOURCE_MAP.get("public"))){
DruidDataSource dataSource = initDataSource("public");
DATA_SOURCE_MAP.put("public", dataSource);
}
//加入默认dataSource
targetDataSources.put("public", DATA_SOURCE_MAP.get("public"));
//设置targetDataSources
multiRouteDataSource.setTargetDataSources(targetDataSources);
//设置默认数据源
Object dataSource = DATA_SOURCE_MAP.get("public")
multiRouteDataSource.setDefaultTargetDataSource(dataSource);
}
multiRouteDataSource.afterPropertiesSet();
}
}
2)数据源切换
多数据源的目的是实现数据隔离,所以针对不同用户就要切换对应的数据源,这样才能避免数据污染,实现业务数据的正确。AbstractRoutingDataSource中利用模板模式,通过自身的抽象方法determineCurrentLookupKey()决定当前的数据源,AbstractRoutingDataSource获取数据源的源码如下:
protected DataSource determineTargetDataSource() {
Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
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;
}
用户数据源的切换逻辑可以通过实现determineCurrentLookupKey()方法进行满足,一般情况就是获取当前线程上下文中的schema名称,具体实现思路:用户可以通过ThreadLocal设置当前线程的schema名称来决定使用哪个数据源,然后在具体实现的determineCurrentLookupKey()方法中通过ThreadLocal的get()方法获取schema名称,这样determineTargetDataSource方法就会根据这个schema从targetDataSources获取对应的数据源。
事务中切换数据源问题
很多人在使用多数据源时,都会遇到事务中不能切换数据源的情况,这个时候我们就需要对源码有所了解,接下来从源码的角度来说明为什么在事务中不能切换数据源:我们知道spring是通过DataSourceTransactionManager对事务进行管理的,当我们在执行事务方法时,会通过AOP机制先执行DataSourceTransactionManager的doBegin()方法:
其中doBegin方法中的Connection newCon = obtainDataSource().getConnection()是用来获取具体的数据库connection,随着断点对obtainDataSource().getConnection()进行跟进,发现它最终会调用AbstractRoutingDataSource的getConnection()方法:
看到这个方法,我们应该就会很熟悉了,这个方法中的determineTargetDataSource()上面已经说过,它是根据determineCurrentLookupKey()决定当前线程使用哪个数据源。
通过上面的分析我们可以知道事务方法开始前就会根据当前数据源获取一个数据库连接connection,这个connection在当前线程的上下文中进行缓存,事务方法结束前都是可复用的,不然如何保证数据的事务特性,所以我们在事务的过程中是不需要更新connection的,不更新connection也就不会执行AbstractRoutingDataSource的getConnection()方法,从而不会更新数据源。这里需要说明的是:spring事务不仅仅针对加了@Transactional注解的方法,对数据库进行增删改的时候,spring也是通过DataSourceTransactionManager进行管理的。
上图是mybatis执行器,比如SimpleExecutor,创建Statement获取connection时的一段源码分析,通过分析可以发现,执行器在获取connection时,会根据当前线程上下文是否有connection,来决定进行connection复用,还是通过fetchConnection()获取connection,其中fetchConnection()是根据AbstractRoutingDataSource的getConnection()方法获取connection。因此,我们要想切换数据源只能在事务开启前进行切换,我们知道事务也是基于AOP实现的,这样我们可以把切换数据源的操作也放在AOP中,然后设置该AOP的order高于事务即可,比如,在该AOP中添加@Order(-1)注解