多数据源与动态数据源的权衡

其实在系统设计时,应当尽量避免一个项目接入多个数据源。我们应该尽量收敛一个数据库的使用者,这样在后续进行一些数据迁移、数据库重构等工作时能够降低风险和难度。 当然,这并不是绝对的情况,所谓“存在即是合理”。多个数据源的使用从另一方面来说能够大大的降低编码便捷性。我们不再需要通过Dubbo、SpringCloud等方式去通过其他系统中获取相关的数据。正好最近工作中遇到了相应的使用场景,下面来分享下我所考量的两种解决方案:

多数据源方案

其实这种方案是第一时间都能够想到的方案,我们直接在项目中注入多个SqlSessionFactory(如果你使用的是Mybatis的话)并可以将多个数据源的domain、dao按包进行存放。这样通过配置不同的SqlSessionFactory的@MapperScan和setTypeAliasesPackage就可以访问不同数据库了。如下代码片段配置了其中一个数据源的参数:

        /**
	 * db_b2b数据源
	 * @return b2b库数据源
	 */
	@Bean(name = "b2b")
	public DataSource b2b () throws SQLException {
		MysqlXADataSource mysqlXADataSource=new MysqlXADataSource();
		mysqlXADataSource.setUrl((dbB2BProperties.getUrl()));
		mysqlXADataSource.setPinGlobalTxToPhysicalConnection(true);
		mysqlXADataSource.setPassword((dbB2BProperties.getPassword()));
		mysqlXADataSource.setUser((dbB2BProperties.getUserName()));
		mysqlXADataSource.setPinGlobalTxToPhysicalConnection(true);
		AtomikosDataSourceBean xaDataSource=new AtomikosDataSourceBean();
		xaDataSource.setXaDataSource(mysqlXADataSource);
		xaDataSource.setUniqueResourceName("b2b");
		return xaDataSource;
	}

	@Bean(name = "sqlSessionFactoryB2B")
	public SqlSessionFactory sqlSessionFactoryB2B(@Qualifier("b2b")DataSource dataSource) throws Exception {
		MybatisSqlSessionFactoryBean bean = new MybatisSqlSessionFactoryBean();
		bean.setDataSource(dataSource);
		bean.setTypeAliasesPackage("com.mhc.lite.dal.domain.b2b");
		MybatisConfiguration configuration = new MybatisConfiguration();
		configuration.setJdbcTypeForNull(JdbcType.NULL);
		configuration.setMapUnderscoreToCamelCase(true);
		configuration.setCacheEnabled(false);
		bean.setConfiguration(configuration);
		bean.setPlugins(new Interceptor[]{
				paginationInterceptor //添加分页功能
		});
		return bean.getObject();
	}

	@Bean(name = "sqlSessionTemplateB2B")
	public SqlSessionTemplate sqlSessionTemplateB2B(
			@Qualifier("sqlSessionFactoryB2B") SqlSessionFactory sqlSessionFactory) throws Exception {
		return new SqlSessionTemplate(sqlSessionFactory);
	}
复制代码

可能有人会对MysqlXADataSource和AtomikosDataSourceBean感到陌生,这其实是JTA分布式事务中用到两个类,在文末会对其展开阐述。如上配置完成了一个数据源的添加,其余数据源按此模板进行复制便可以,但是有一点值得注意,需要将各个DataSource和SqlSessionFactory的Bean名称进行区分并搭配@Qualifier进行选择,不然会导致各个数据源间调用错乱。

动态数据源方案

其实换一个角度想想,我们使用多个SqlSessionFactory来各自连接不同的数据源是很有局限性的。当我们数据源数量比较多的时候类似上文的模板式的代码将充斥整个项目,配置起来比较的繁琐。而且,试想一下,我们并不是每时每刻都对各个数据源都需要进行操作,每个数据源又会保有一个基本的闲置连接数。这样对本就宝贵的系统内存和CPU等资源产生了浪费,所以,第二种方案就应运而生了--动态数据源。我举一个生活中比较形象的例子:工人使用的钻头,其实钻机是只需要一个的,我们只需要根据不同的墙壁材质和孔的形状需要去替换掉钻机上不同的钻头就可以适应各个场景了呀。而上文我们所做的事情是买了两套甚至多套的钻机(真的有点奢侈了!)。来看看该怎么做:

        /**
	 * db_base数据源
	 * @return
	 */
	@Bean(name = "base")
	@ConfigurationProperties(prefix = "spring.datasource.druid.base" )
	public DataSource base () {
		return DruidDataSourceBuilder.create().build();
	}

	/**
	 * db_b2b数据源
	 * @return
	 */
	@Bean(name = "b2b")
	@ConfigurationProperties(prefix = "spring.datasource.druid.b2b" )
	public DataSource b2b () {
		return DruidDataSourceBuilder.create().build();
	}

	/**
	 * 动态数据源配置
	 * @return
	 */
	@Bean
	@Primary
	public DataSource multipleDataSource (@Qualifier("base") DataSource base,
					      @Qualifier("b2b") DataSource b2b ) {
		DynamicDataSource dynamicDataSource = new DynamicDataSource();
		Map< Object, Object > targetDataSources = new HashMap<>();
		targetDataSources.put(DBTypeEnum.DB_BASE.getValue(), base );
		targetDataSources.put(DBTypeEnum.DB_B2B.getValue(), b2b);
		dynamicDataSource.setTargetDataSources(targetDataSources);
		dynamicDataSource.setDefaultTargetDataSource(base);
		return dynamicDataSource;
	}

	@Bean("sqlSessionFactory")
	public SqlSessionFactory sqlSessionFactory() throws Exception {
		MybatisSqlSessionFactoryBean sqlSessionFactory = new MybatisSqlSessionFactoryBean();
		sqlSessionFactory.setDataSource(multipleDataSource(base(),b2b()));
		MybatisConfiguration configuration = new MybatisConfiguration();
		configuration.setJdbcTypeForNull(JdbcType.NULL);
		configuration.setMapUnderscoreToCamelCase(true);
		configuration.setCacheEnabled(false);
		sqlSessionFactory.setConfiguration(configuration);
		sqlSessionFactory.setPlugins(new Interceptor[]{ //PerformanceInterceptor(),OptimisticLockerInterceptor()
				paginationInterceptor() //添加分页功能
		});
		sqlSessionFactory.setGlobalConfig(globalConfiguration());
		return sqlSessionFactory.getObject();
	}
复制代码

我们现在只需要一个SqlSessionFactory(“钻机”)了,但是多了一个DynamicDataSource。它其实可以看做是一份数据源的清单,后面我们将可以按照清单上的数据源名称进行动态的切换。那么问题又来了,我们怎么知道什么时候该用哪个数据库呢?接着看:

public class DynamicDataSource extends AbstractRoutingDataSource {

	/**
	 * 核心方法,切换数据源上下文
	 */
	@Override
	protected Object determineCurrentLookupKey() {
		return DbContextHolder.getDbType();
	}

}

public class DbContextHolder {

	private static final ThreadLocal contextHolder = new ThreadLocal<>();

	/**
	 * 设置数据源
	 */
	public static void setDbType(DBTypeEnum dbTypeEnum) {
		contextHolder.set(dbTypeEnum.getValue());
	}

	/**
	 * 取得当前数据源
	 */
	private static String getDbType() {
		return (String) contextHolder.get();
	}

	/**
	 * 清除上下文数据
	 */
	private static void clearDbType() {
		contextHolder.remove();
	}

}

@Slf4j
@Aspect
@Order(-100)
@Component
public class DataSourceSwitchAspect {

	/**
	 * 自己编写的manager method
	 */
	@Pointcut("execution(* com.mhc.polestar.dal.manager.*.*(..))")
	private void ownMethod(){}

	/**
	 * MP生成的CRUD method
	 */
	@Pointcut("execution(* com.baomidou.mybatisplus.service.*.*(..))")
	private void mpMethod() {}

	@Before( "ownMethod() || mpMethod()" )
	public void base(JoinPoint joinPoint) {
		String name = joinPoint.getTarget().getClass().getName();
		if (name.contains("B2b")){
			log.debug("切换到b2b数据源...");
			DbContextHolder.setDbType(DBTypeEnum.DB_B2B);
		}else {
			log.debug("切换到base数据源...");
			DbContextHolder.setDbType(DBTypeEnum.DB_BASE);
		}
	}

}
复制代码

还记得面向切面编程的思想么,我们其实每次想去“换钻头”这个动作可以通过切面的方式进行抽象。我们根据切点处所使用的Manager的名称、路径(需要事先制定一些规则)等信息来选择切换到哪个数据源,这样才能正常的工作。同时要通过@Order来保证切面的执行次序优先。说到这里,大体的思想也表达了出来,但是我在实际的使用过程中遇到了一个问题。虽然像上文那样操作可以对代码实现零入侵,但是看看下面这种情况:

        @Override
	@ValidateDTO
	@Transactional(rollbackFor = Exception.class)
	public APIResult<Boolean> addPartner(PartnerParamDTO paramDTO) {...}
复制代码

我们往往为了保证业务一致性需要开启事务,但是如果我们在Manager的调用者Service上开启事务,那么savepoint将被设置在service层,即其下层的上下文环境必须要确定下来不能更改了,这样在发生异常时进行rollBack才能保证一致性。可是,我们的切面设置在了Manger层,这样不就不可以切换数据源,势必会发生错误!后来,我通过自定义注解的方式在Service层就实现把数据源切换好这个问题也就解决,但是这样的方式会对代码产生一定的入侵性(我们需要在所有切换数据源的地方加上注解,这本和原来的业务没有任何关联,而且我们在一个Service方法中需要使用多个数据源,那就需要把这种代码的坏味道进一步深入到Manager层,使用DbContextHolder.setDbType()进行手动切换)。讲到这里,这个我遇见的这个问题也差不多描述结束了。

分布式事务

上文在讲多数据源方案的时候提到了MysqlXADataSource和AtomikosDataSourceBean,这其实是JTA分布式事务中的两个关键。众所周知,传统的Spring事务只能对单个数据源进行一致性管理。现在,我们在项目中使用了多个数据源或者动态数据源,如果我们还想继续使用事务就不得不考虑JTA分布式事务了。它是为了保证多数据源间事务同步,这里我使用atomiko的方式来进行一个简单的演示:

@Configuration
@EnableTransactionManagement
public class TxManagerConfig {

	@Bean(name = "userTransaction")
	public UserTransaction userTransaction() throws Throwable {
		UserTransactionImp userTransactionImp = new UserTransactionImp();
		userTransactionImp.setTransactionTimeout(10000);
		return userTransactionImp;
	}

	@Bean(name = "atomikosTransactionManager", initMethod = "init" , destroyMethod = "close")
	public TransactionManager atomikosTransactionManager() {
		UserTransactionManager userTransactionManager = new UserTransactionManager();
		userTransactionManager.setForceShutdown(false);
		return userTransactionManager;
	}

	@Bean(name = "transactionManager")
	@DependsOn({ "userTransaction", "atomikosTransactionManager" })
	public PlatformTransactionManager transactionManager() throws Throwable {
		return new JtaTransactionManager(userTransaction(),atomikosTransactionManager());
	}

}

        /**
	 * db_b2b数据源
	 * @return b2b库数据源
	 */
	@Bean(name = "b2b")
	public DataSource b2b () throws SQLException {
		MysqlXADataSource mysqlXADataSource=new MysqlXADataSource();
		mysqlXADataSource.setUrl((dbB2BProperties.getUrl()));
		mysqlXADataSource.setPinGlobalTxToPhysicalConnection(true);
		mysqlXADataSource.setPassword((dbB2BProperties.getPassword()));
		mysqlXADataSource.setUser((dbB2BProperties.getUserName()));
		mysqlXADataSource.setPinGlobalTxToPhysicalConnection(true);
		AtomikosDataSourceBean xaDataSource=new AtomikosDataSourceBean();
		xaDataSource.setXaDataSource(mysqlXADataSource);
		xaDataSource.setUniqueResourceName("b2b");
		return xaDataSource;
	}
复制代码

你没看错,在SpringBoot项目里面就是如此简单的配置就可以实现分布式事务了。我们使用MysqlXADataSource和AtomikosDataSourceBean对需要管理的数据源进行标识就可以使用JTA提供的PlatformTransactionManager来进行事务的管理。

方案的权衡

1.多数据源方案优势在于配置简单并且对业务代码的入侵性极小,缺点也显而易见:我们需要在系统中占用一些资源,而这些资源并不是一直需要,一定程度上会造成资源的浪费。如果你需要在一段业务代码中同时使用多个数据源的数据又要去考虑操作的原子性(事务),那么这种方案无疑会适合你。
2.动态数据源方案配置上看起来配置会稍微复杂一些,但是很好的符合了“即拿即用,即用即还”的设计原则,我们把多个数据源看成了一个池子,然后进行消费。它的缺点正如上文所暴露的那样:我们往往需要在事务的需求下做出妥协。而且由于需要切换环境上下文,在高并发量的系统上进行资源竞争时容易发生死锁等活跃性问题。我们常用它来进行数据库的“读写分离”,不需要在一段业务中同时操作多个数据源。
3.如果需要使用事务,一定记得使用分布式事务进行Spring自带事务管理的替换,否则将无法进行一致性控制!
写到这里本文也就结束,好久没有撰写文章很多东西考虑不是很详尽,谢谢批评指正!

猜你喜欢

转载自juejin.im/post/5b790a866fb9a019ea01f38c