多資料來源與動態資料來源的權衡

EQ84發表於2019-03-04

其實在系統設計時,應當儘量避免一個專案接入多個資料來源。我們應該儘量收斂一個資料庫的使用者,這樣在後續進行一些資料遷移、資料庫重構等工作時能夠降低風險和難度。 當然,這並不是絕對的情況,所謂“存在即是合理”。多個資料來源的使用從另一方面來說能夠大大的降低編碼便捷性。我們不再需要通過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自帶事務管理的替換,否則將無法進行一致性控制!
寫到這裡本文也就結束,好久沒有撰寫文章很多東西考慮不是很詳盡,謝謝批評指正!

相關文章