上篇介紹了數據源基礎,并實現了基于兩套DataSource,兩套mybatis配置的多數據源,從基礎知識層面闡述了多數據源的實現思路。不了解的同學請戳→同學,你的多數據源事務失效了!
正如文末回顧所講,這種方式的多數據源對代碼侵入性很強,每個組件都要寫兩套,不適合大規模線上實踐。
對于多數據源需求,Spring早在 2007 年就注意到并且給出了解決方案,原文見:
dynamic-datasource-routing
Spring提供了一個AbstractRoutingDataSource類,用來實現對多個DataSource的按需路由,本文介紹的就是基于此方式實現的多數據源實踐。
一、什么是AbstractRoutingDataSource
先看類上的注釋:
Abstract {@link JAVAx.sql.DataSource} implementation that routes {@link #getConnection()} calls to one of various target DataSources based on a lookup key. The latter is usually (but not necessarily) determined through some thread-bound transaction context.
課代表翻譯:這是一個抽象類,可以通過一個lookup key,把對getConnection()方法的調用,路由到目標DataSource。后者(指lookup key)通常是由和線程綁定的上下文決定的。
這段注釋可謂字字珠璣,沒有一句廢話。下文結合主要代碼解釋其含義。
public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {
//目標 DataSource Map,可以裝很多個 DataSource
@Nullable
private Map<Object, Object> targetDataSources;
@Nullable
private Map<Object, DataSource> resolvedDataSources;
//Bean初始化時,將 targetDataSources 遍歷并解析后放入 resolvedDataSources
@Override
public void afterPropertiesSet() {
if (this.targetDataSources == null) {
throw new IllegalArgumentException("Property 'targetDataSources' is required");
}
this.resolvedDataSources = CollectionUtils.newHashMap(this.targetDataSources.size());
this.targetDataSources.forEach((key, value) -> {
Object lookupKey = resolveSpecifiedLookupKey(key);
DataSource dataSource = resolveSpecifiedDataSource(value);
this.resolvedDataSources.put(lookupKey, dataSource);
});
if (this.defaultTargetDataSource != null) {
this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
}
}
@Override
public Connection getConnection() throws SQLException {
return determineTargetDataSource().getConnection();
}
/**
* Retrieve the current target DataSource. Determines the
* {@link #determineCurrentLookupKey() current lookup key}, performs
* a lookup in the {@link #setTargetDataSources targetDataSources} map,
* falls back to the specified
* {@link #setDefaultTargetDataSource default target DataSource} if necessary.
* @see #determineCurrentLookupKey()
*/
//根據 #determineCurrentLookupKey()返回的lookup key 去解析好的數據源 Map 里取相應的數據源
protected DataSource determineTargetDataSource() {
Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
// 當前 lookupKey 的值由用戶自己實現↓
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;
}
/**
* Determine the current lookup key. This will typically be
* implemented to check a thread-bound transaction context.
* <p>Allows for arbitrary keys. The returned key needs
* to match the stored lookup key type, as resolved by the
* {@link #resolveSpecifiedLookupKey} method.
*/
// 該方法用來決定lookup key,通常用線程綁定的上下文來實現
@Nullable
protected abstract Object determineCurrentLookupKey();
// 省略其余代碼...
}
首先看類圖
AbstractRoutingDataSource-uml
是個DataSource,并且實現了InitializingBean,說明有Bean的初始化操作。
其次看實例變量
private Map<Object, Object> targetDataSources;和private Map<Object, DataSource> resolvedDataSources;其實是一回事,后者是經過對前者的解析得來的,本質就是用來存儲多個 DataSource實例的 Map。
最后看核心方法
使用DataSource,本質就是調用其getConnection()方法獲得連接,從而進行數據庫操作。
AbstractRoutingDataSource#getConnection()方法首先調用determineTargetDataSource(),決定使用哪個目標數據源,并使用該數據源的getConnection()連接數據庫:
@Override
public Connection getConnection() throws SQLException {
return determineTargetDataSource().getConnection();
}
protected DataSource determineTargetDataSource() {
Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
// 這里使用的 lookupKey 就能決定返回的數據源是哪個
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()方法,該方法是抽象方法,由用戶自己實現,通過改變其返回值,控制返回不同的數據源。用表格表示如下:
LOOKUPKEY |
DATASOURCE |
first |
firstDataSource |
second |
secondDataSource |
如何實現這個方法呢?結合Spring在注釋里給的提示:
后者(指lookup key)通常是由和線程綁定的上下文決定的。
應該能聯想到ThreadLocal了吧!ThreadLocal可以維護一個與當前線程綁定的變量,充當這個線程的上下文。
二、實現
設計yaml文件外部化配置多個數據源
spring:
datasource:
first:
driver-class-name: org.h2.Driver
jdbc-url: jdbc:h2:mem:db1
username: sa
password:
second:
driver-class-name: org.h2.Driver
jdbc-url: jdbc:h2:mem:db2
username: sa
password:
創建lookupKey的上下文持有類:
/**
* 數據源 key 上下文
* 通過控制 ThreadLocal變量 LOOKUP_KEY_HOLDER 的值用于控制數據源切換
* @see RoutingDataSource
* @author :Java課代表
*/
public class RoutingDataSourceContext {
private static final ThreadLocal<String> LOOKUP_KEY_HOLDER = new ThreadLocal<>();
public static void setRoutingKey(String routingKey) {
LOOKUP_KEY_HOLDER.set(routingKey);
}
public static String getRoutingKey() {
String key = LOOKUP_KEY_HOLDER.get();
// 默認返回 key 為 first 的數據源
return key == null ? "first" : key;
}
public static void reset() {
LOOKUP_KEY_HOLDER.remove();
}
}
實現AbstractRoutingDataSource:
/**
* 支持動態切換的數據源
* 通過重寫 determineCurrentLookupKey 實現數據源切換
* @author :Java課代表
*/
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return RoutingDataSourceContext.getRoutingKey();
}
}
給我們的RoutingDataSource初始化上多個數據源:
/**
* 數據源配置
* 把多個數據源,裝配到一個 RoutingDataSource 里
* @author :Java課代表
*/
@Configuration
public class RoutingDataSourcesConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource.first")
public DataSource firstDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties(prefix = "spring.datasource.second")
public DataSource secondDataSource() {
return DataSourceBuilder.create().build();
}
@Primary
@Bean
public RoutingDataSource routingDataSource() {
RoutingDataSource routingDataSource = new RoutingDataSource();
routingDataSource.setDefaultTargetDataSource(firstDataSource());
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put("first", firstDataSource());
dataSourceMap.put("second", secondDataSource());
routingDataSource.setTargetDataSources(dataSourceMap);
return routingDataSource;
}
}
演示一下手工切換的代碼:
public void init() {
// 手工切換為數據源 first,初始化表
RoutingDataSourceContext.setRoutingKey("first");
createTableUser();
RoutingDataSourceContext.reset();
// 手工切換為數據源 second,初始化表
RoutingDataSourceContext.setRoutingKey("second");
createTableUser();
RoutingDataSourceContext.reset();
}
這樣就實現了最基本的多數據源切換了。
不難發現,切換工作很明顯可以抽成一個切面,我們可以優化一下,利用注解標明切點,哪里需要切哪里。
三、引入AOP
自定義注解
/**
* @author :Java課代表
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface WithDataSource {
String value() default "";
}
創建切面
@Aspect
@Component
// 指定優先級高于@Transactional的默認優先級
// 從而保證先切換數據源再進行事務操作
@Order(Ordered.LOWEST_PRECEDENCE - 1)
public class DataSourceAspect {
@Around("@annotation(withDataSource)")
public Object switchDataSource(ProceedingJoinPoint pjp, WithDataSource withDataSource) throws Throwable {
// 1.獲取 @WithDataSource 注解中指定的數據源
String routingKey = withDataSource.value();
// 2.設置數據源上下文
RoutingDataSourceContext.setRoutingKey(routingKey);
// 3.使用設定好的數據源處理業務
try {
return pjp.proceed();
} finally {
// 4.清空數據源上下文
RoutingDataSourceContext.reset();
}
}
}
有了注解和切面,使用起來就方便多了:
// 注解標明使用"second"數據源
@WithDataSource("second")
public List<User> getAllUsersFromSecond() {
List<User> users = userService.selectAll();
return users;
}
關于切面有兩個細節需要注意:
- 需要指定優先級高于聲明式事務
- 原因:聲明式事務事務的本質也是 AOP,其只對開啟時使用的數據源生效,所以一定要在切換到指定數據源之后再開啟,聲明式事務默認的優先級是最低級,這里只需要設定自定義的數據源切面的優先級比它高即可。
- 業務執行完之后一定要清空上下文
- 原因:假設方法 A 使用@WithDataSource("second")指定走"second"數據源,緊跟著方法 B 不寫注解,期望走默認的first數據源。但由于方法A放入上下文的lookupKey此時還是"second"并未刪除,所以導致方法 B 執行的數據源與期望不符。
四、回顧
至此,基于AbstractRoutingDataSource+AOP的多數據源就實現好了。
在配置DataSource 這個Bean的時候,用的是自定義的RoutingDataSource,并且標記為 @Primary。這樣就可以讓
mybatis-spring-boot-starter使用RoutingDataSource幫我們自動配置好mybatis,比搞兩套DataSource+兩套Mybatis配置的方案簡單多了。
文中相關代碼已上傳課代表的github
特別說明:
樣例中為了減少代碼層級,讓展示更直觀,在 controller 層寫了事務注解,實際開發中可別這么干,controller 層的任務是綁定、校驗參數,封裝返回結果,盡量不要在里面寫業務!
五、優化
對于一般的多數據源使用場景,本文方案已足夠覆蓋,可以實現靈活切換。
但還是存在如下不足:
- 每個應用使用時都要新增相關類,大量重復代碼
- 修改或新增功能時,所有相關應用都得改
- 功能不夠強悍,沒有高級功能,比如讀寫分離場景下的讀多個從庫負載均衡
其實把這些代碼封裝到一個starter里面,高級功能慢慢擴展就可以。
好在開源世界早就有現成工具可用了,開發mybatis-plus的"baomidou"團隊在其生態中開源了一個多數據源框架 Dynamic-Datasource,底層原理就是AbstractRoutingDataSource,增加了更多強悍的擴展功能,下篇介紹其使用。