SpringBoot实战-多数据源方案一些思考

2,031 阅读8分钟

多数据源方案一些思考

目前微服务大行其道,大部分的架构都已经转为单服务单库来最大程度的解耦数据源的业务关联性。但是依然存在少数场景会遇到需要使用多数据源的场景。再则,撇开微服务来说,单体的SpringBoot服务在我们开发中,多数据源的情况就更加普遍了。本文主要聊聊多数据源的一些方案即对应的实现。

主流的多数据源方案

目前主流的多数据源方案宏观上来讲,主要就分为2种:

  1. 基于分包的方式
    • 分包的方式很好理解,就是讲多个数据源的XXMapper文件分在不同的包中,然后针对每一个数据源都会向Spring容器注入一个DataSource以及对应的SqlSessionFactory实例。这样如果有4个数据源,那么Spring最后容器中就会有4个独立的DataSource,以及4个独立的SqlSessionFactory实例。这样设计保证了数据源在dao层是完全隔离的,这是最简单直接且不容易出错的方法。
  2. 基于AOP切面拦截的方式
    • AOP的实现方案就很多了,但是他们大部分的结构是只有一个SqlSessionFactory实例,但是有多个DataSource,在需要切换的时候,切换当前线程下SqlSessionFactory对应的DataSource。
    • 这里列举一些方法:
      • 基于约定:定义mapper的规范,比如统一以什么开头,然后让AOP去拦截这些类或者方法,从而从类名方法名中获取数据源的信息。
      • 基于注解:在需要使用地方加上自定义的数据源注解,通过AOP拦截这些带注解的类和方法,从而进行数据源的切换。

优缺点

分包
  • 优点
    • 实现简单,直接,而且一定不会出错。
    • 定位sql等很清晰,一眼就能知道这个sql的方法是在哪个库上执行的。
  • 缺点
    • 不够灵活,即我需要做对Mapper分文件夹的额外工作。
    • Mapper类文件会很臃肿,我们可能会需要管理大量的Mapper类。
    • 在某些场景下,这种方式是完全不可取的(或者说,单纯只使用分包方法是不可取的,下面会给出这样的场景)。
AOP
  • 优点
    • 灵活,基本满足所有的场景(只要你需要什么场景,通过AOP都能整出适合它的方案)
  • 缺点
    • 每个使用的地方都需要加上注解,或者一个标识,还是比较麻烦的,而且不敢保证不会出现忘记或者写错的情况。
    • 太灵活了,用的好还行,使用不当的话,代码会很恶心。(来自实际工作经验,有了这个东西,很多写代码不注重质量的人,SQL乱放,数据源乱切,代码极乱)
    • 对代码的侵入性很高。这也是我们不是很喜欢他的一点。有些注解的方法,需要在代码里面到处加上数据源的注解,这些东西对业务开发本身毫无意义,更有甚者,在代码逻辑里面进行数据源切换的。(这其实对代码的侵入性太高了,你总不会喜欢看着项目的业务代码逻辑呢,突然给你来一段代码只是用来做数据源切换的吧,而且Spring为我们提供一系列的机制其实都是希望对我们自己写代码能够最小侵入性)。

如何选型

首先,考察你所使用的数据源场景。我个人建议是能不用AOP方式就不要使用AOP方式。如果你多个数据源之间彼此都相互独立,完全没有什么关联性,那么我推荐你采用分包方式。(这里的没有关联性,比如你场景需要订单库,用户库,商品库,这些业务属性相互区分的库)这种方式,虽然需要安装包分类,但是可以让代码结构十分清晰,sql的管理也变得容易。

如果你的数据源很多,比如一个项目里面有二三十个,你可能觉得分包很麻烦,可以考虑使用AOP来使用的时候切换。但是慎重考虑,到时候sql和mapper文件以及数据源在后期不断使用中的混乱程度。分包虽然多,但是很清晰,并且100%不会出问题(我遇到的实际开发中,经常有人切错数据源)。

针对AOP方式,我觉得唯一让我必须使用的理由就是:项目依赖的多个库,里面的表结构大致相同,或者完全就是主从关系(这是分包方式最大的缺点)。这时候,对于这种库,分多个包,可能造成有大量的sql都是重复的。代码冗余就很高。 使用AOP切换就很方便。(举个例子,一个出租车公司,依据城市进行分库,具体使用的数据源是城市的id来区分的,每个城市都有一个库,那么如果分包方式,我们对每个城市都要分1个包?然后每个包里面的代码逻辑还都一样?这显然不可取的,这种场景通过AOP可以特别优雅的解决)

还有可能在集成某些框架时候,我们不得不去使用分包方式。比如我们在使用SpringBatch的时候,SpringBatch内部会为我们强制添加事务,导致数据源切换的时候Connection其实一直事务管理器缓存住的Connection,这样AOP方式就没法行得通(至少我目前还没有发现简单的解决方案)。

实现

分包

定义一个主的数据源: @Primary 注解。

@Configuration
@MapperScan(basePackages = MasterXXXDsConfig.MAPPER_PKG, sqlSessionFactoryRef = MasterXXXDsConfig.SOURCE_SQL_SESSION_FACTORY)
public class MasterXXXConfig {

  	//数据源DataSource Bean名称
    public static final String SOURCE_NAME = "MasterXXXDs";
    //该数据源的SqlSessionFactory
    public static final String SOURCE_SQL_SESSION_FACTORY = "MasterXXXSqlSessionFactory";
  	//该数据源的事务管理器
    public static final String SOURCE_TX_MANAGER = "MasterXXXTransactionManager";
    //该数据源分包对应的mapper的包全限定类名
    public static final String MAPPER_PKG = "xx.mapper.MasterXXX";
    //数据源mapper对应xml的路径
    public static final String MAPPER_LOCATION = "classpath:mapper/MasterXXX/*Mapper.xml";

  	// 注入DataSource
    @Bean(SOURCE_NAME)
    @Primary
    @ConfigurationProperties(prefix = "spring.datasource.MasterXXX")
    public DataSource dataSource() {
        return DataSourceBuilder.create().build();
    }

  	// 注入SqlSessionFactory
    @Bean(SOURCE_SQL_SESSION_FACTORY)
    @Primary
    public SqlSessionFactory sqlSessionFactory() throws Exception {
      	// 如果用Mybatis-Plus,这里注意下需要用MP自己定义的 MybatisSqlSessionFactoryBean,否则sql会无法找到对应的绑定
      	// 如果不是用MP的,就使用Mybatis自己的SqlSessionFactoryBean
        MybatisSqlSessionFactoryBean sqlSessionFactoryBean = new MybatisSqlSessionFactoryBean();
        GlobalConfig globalConfig = GlobalConfigUtils.defaults();
        globalConfig.setBanner(false);
        sqlSessionFactoryBean.setGlobalConfig(globalConfig);
        sqlSessionFactoryBean.setDataSource(dataSource());
      	// 指定mapper的xml路径
        sqlSessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(MAPPER_LOCATION));
        return sqlSessionFactoryBean.getObject();
    }
		
  	// 注入事务管理器
    @Bean(SOURCE_TX_MANAGER)
    @Primary
    public DataSourceTransactionManager dataSourceTransactionManager() {
        return new DataSourceTransactionManager(dataSource());
    }
}

然后其他的数据源定义和其类似,只是不需要再标注@Primary注解。如:

@Configuration
@MapperScan(basePackages = YYYYConfig.MAPPER_PKG, sqlSessionFactoryRef = YYYYConfig.SOURCE_SQL_SESSION_FACTORY)
public class YYYYConfig {
 		//...
    @Bean(SOURCE_NAME)
    @ConfigurationProperties(prefix = "spring.datasource.YYYY")
    public DataSource dataSource() {
      //...
    }

    @Bean(SOURCE_SQL_SESSION_FACTORY)
    public SqlSessionFactory sqlSessionFactory() throws Exception {
      //...
    }

    @Bean(SOURCE_TX_MANAGER)
    public DataSourceTransactionManager dataSourceTransactionManager() {
      //...
    }
}

另外配置中也要注意:

spring:
  datasource:
    XXXMaster:
      type: com.alibaba.druid.pool.DruidDataSource
      # 这里会提示你需要配置成jdbc-url 而不是url,原因:自行百度
      jdbc-url: jdbc:mysql://..........
      username: xxxx
      password: xxxx

这种方式我们可以称为多数据源方案,因为容器中存在多个DataSource,他们互相独立工作的。

AOP

AOP的方式大多被称为动态数据源。因为他们一般都是用一个自定义的DataSource,该自定义DataSource内部定义了一个Map,然后获取Connection的时候通过AOP拦截到当前需要的数据源从Map中取出对应的DataSource然后再getConnection给出连接来实现切换数据源操作的。

自定义

大致流程如下:

  1. 自定义一个DataSource,继承AbstractDataSource类,用来取代SpringBoot自动配置的DataSource,里面存储一个Map存放所有的DataSource对象。
  2. 我们如何去获取正确的DataSource实现方式就有很多了,可以基于DataSource
  3. 然后就是定义Aspect切面了,

AOP的实现方式有很多,我这里大概给出一个基本实现的例子:(只是实例)

  • 自定义的动态数据源,需要配置其代替springboot自动配置的数据源

    public class DynamicDataSource extends AbstractRoutingDataSource{
        private Map<String, DataSource> dataSources = new HashMap<>();
    	@Override
    	protected Object determineCurrentLookupKey() {
    		return DynamicDataSourceDsHolder.getDataSourceType();
    	}
        // 依据ThreadLocal里面的值拿取需要的DataSource
        public DataSource getDataSource(){
            return dataSources.get(determineCurrentLookupKey());
        }
    }
    
  • 定义一个ThreadLocalHolder用来保存当前线程使用的是哪个数据源。

    public class DynamicDataSourceDsHolder {
    	private static final ThreadLocal<String> dsHolder = new ThreadLocal<String>();
        public static void setDataSourceType(String dataSourceType) {
    		contextHolder.set(dataSourceType);
    	}
    	public static String getDataSourceType() {
    		return contextHolder.get();
    	}
    	public static void clearDataSourceType() {
    		contextHolder.remove();
    	}
    }
    
  • 定义一个注解,用来标记使用方法需要进行数据源切换

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    @Documented
    public @interface DS {
    	String value();
    }
    
  • 定义一个切面

    @Aspect
    @Order(-1)
    @Component
    public class DynamicDataSourceAspect {
    	private final Logger logger = LoggerFactory.getLogger(DynamicDataSourceAspect.class);
    	@Before("execution(xxxx)")
    	public void before(JoinPoint point) {
    		Object target = point.getTarget();
    		String method = point.getSignature().getName();
    		Class<?>[] classz = target.getClass().getInterfaces();
    		Class<?>[] parameterTypes = ((MethodSignature) point.getSignature()).getMethod().getParameterTypes();
    		try {
    			Method m = classz[0].getMethod(method, parameterTypes);
    			if (m != null && m.isAnnotationPresent(TargetDataSource.class)) {
    				TargetDataSource data = m.getAnnotation(TargetDataSource.class);
    				DynamicDataSourceDsHolder.setDataSourceType(data.value());
    			}
    		} catch (Exception e) {
    			e.printStackTrace();
    		}
    	}
    	@After("execution(xxxx)")
    	public void after(JoinPoint point) {
    		DynamicDataSourceDsHolder.clearDataSourceType();
    	}
    }
    
  • 使用

    @DS("master")
    public void service(){
        //xxxxx
    }
    

这个简陋版本的多数据源方法,实际使用中存在很多的问题,但是大致的思路都有了,我们生产上可以使用开源的解决工具:苞米豆动态数据源方案。

苞米豆

我们可以使用苞米豆提供的动态数据源方案。他是一个基于AOP+ThreadLocal方式,通过注解标记方法或者类来完成数据源切换,还支持了很多其他的特性,详细可以阅读官方文档。

思考

其实分包和AOP是可以结合起来使用的,分包的方式更快一点是肯定的(因为不需要很多切换数据源的操作),但是这点性能影响不计,我可以通过AOP拦截不同的包,然后做不同的数据源切换,也能实现等效于分包的方式的数据源切面。其实我们可以对自己的场景通过AOP方式可以定制出很多最适合自己的方案。