1 動態資料來源的必要性
我們知道,物理服務機的CPU、記憶體、儲存空間、連線數等資源都是有限的,某個時段大量連線同時執行操作,會導致資料庫在處理上遇到效能瓶頸。而在複雜的網際網路業務場景下,系統流量日益膨脹。為了解決這個問題,行業先驅門充分發揚了分而治之的思想,對大庫表進行分割,然後實施更好的控制和管理,同時使用多臺機器的CPU、記憶體、儲存,提供更好的效能。參考我這篇《分庫分表》。
資料庫有水平拆分(Scale Out) 和垂直拆分(Scale Up)的區別,但是無論怎麼變化,當你對同一業務庫進行分庫的時候。必然要考慮到,在你的同一個業務服務(Service),會有同時訪問多個資料來源的情況。如下圖
另外一種場景是ABTesting業務場景,可能不同的使用者看到的業務資料是不一樣的,這就需要根據業務特性動態的獲取資料。
按照Spring boot的常規做法,maven新增依賴,在Yaml中配置對應的datasource、jpa等屬性即可使用了。但是多資料來源的情況下無論是配置 還是資料上下文的切換都變得無比繁瑣。如果能使用註解宣告的方式,粒度細化到方法級別的,那用起來就簡單多了。那我們來寫一個這樣的實現。
2 實現過程
2.1 Maven依賴
pom檔案中增加一些依賴,這邊我們以Jpa為案例說明:
<!-- 增加了4.3.8版本jdbc的支援-->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>4.3.8.RELEASE</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.20</version>
</dependency>
<!-- Jpa與Hibernate相關:開始-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<exclusions>
<exclusion>
<artifactId>byte-buddy</artifactId>
<groupId>net.bytebuddy</groupId>
</exclusion>
<exclusion>
<artifactId>hibernate-entitymanager</artifactId>
<groupId>org.hibernate</groupId>
</exclusion>
<exclusion>
<artifactId>hibernate-core</artifactId>
<groupId>org.hibernate</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-apt</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core</artifactId>
<version>5.3.7.Final</version>
</dependency>
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>hibernate-types-52</artifactId>
<version>2.9.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Jpa與Hibernate相關:結束-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
2.2 yaml配置
可以看到我們配置了一個預設的資料來源basic,然後再擴充套件了一個跟basic同級的節點mutil-data-core,包含三個資料來源,basic、cloudoffice、attend。
spring:
mutildata:
basic:
driver-class-name: com.mysql.jdbc.Driver
filters: stat
initial-size: 20
logAbandoned: true
maxActive: 300
maxPoolPreparedStatementPerConnectionSize: 20
maxWait: 60000
min-idle: 5
minEvictableIdleTimeMillis: 300000
poolPreparedStatements: true
removeAbandoned: true
removeAbandonedTimeout: 1800
testOnBorrow: false
testOnReturn: false
testWhileIdle: true
timeBetweenEvictionRunsMillis: 60000
validationQuery: SELECT 1
password: 123456
type: com.alibaba.druid.pool.DruidDataSource
url: jdbc:mysql://127.0.0.1:3306/basic?autoReconnect=true&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true
username: root
mutil-data-core:
basic:
password: 123456
url: jdbc:mysql://127.0.0.1:3306/basic?autoReconnect=true&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true
username: root
cloud:
password: 123456
url: jdbc:mysql://127.0.0.1:3307/cloudoffice?autoReconnect=true&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true
username: root
attend:
password: 123456
url: jdbc:mysql://127.0.0.1:3308/attend?autoReconnect=true&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true
username: root
2.3 編寫配置類Configuration
掃描我們上面的配置,spring.mutildata.basic下面的預設資料來源,以及 mutil-data-core下面的多個動態資料來源,有多少個掃描多少個出來,並進行組裝,放到一個資料來源map集合中:dataSourceMap。
@Bean(name = "basicDataSource")
@ConfigurationProperties(prefix = "spring.mutildata.basic") // 這是我們動態資料來源的配置位置
public DruidDataSource basicDataSource() {
return new DruidDataSource();
}
@Autowired
private DataSourceCoreConfig dataSourceCoreConfig;
/**
* 動態整合可選的資料庫路由,改掉之前硬編碼的方式
* @param basicDataSource
* @return
*/
@Bean(name = "routingDataSource")
@Primary
public RoutingDataSource routingDataSource(DruidDataSource basicDataSource) {
RoutingDataSource routingDataSource = new RoutingDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>(16);
HashMap<String, DataSourceCore> mutildatacore = dataSourceCoreConfig.getMutilDataCore();
routingDataSource.setDefaultTargetDataSource(basicDataSource);
try {
Iterator iter = mutildatacore.entrySet().iterator();
while (iter.hasNext()) { // 輪詢出所有的動態資料來源
Map.Entry entry = (Map.Entry) iter.next();
String key = entry.getKey().toString();
DataSourceCore dsc = (DataSourceCore) entry.getValue();
DruidDataSource ds = (DruidDataSource) basicDataSource.clone();
// 3個核心關鍵資料來源頭重新賦值
ds.setUrl(dsc.getUrl());
ds.setUsername(dsc.getUserName());
ds.setPassword(dsc.getPassWord());
dataSourceMap.put(key, ds);
}
}
catch (Exception ex) {
// Todo
}
routingDataSource.setTargetDataSources(dataSourceMap);
return routingDataSource;
}
2.4 資料來源集合
資料來源的管理:包含組織資料來源、讀值、賦值、清空資料來源等。
/**
* @author brand
* @Description: 動態資料來源
* @Copyright: Copyright (c) 2021
* @Company: Helenlyn, Inc. All Rights Reserved.
* @date 2021/12/16 10:33 上午
* @Update Time:
* @Updater:
* @Update Comments:
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
/**
* 用來儲存資料來源與獲取資料來源
*/
private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<String>();
/**
* 構造,包含一個預設資料來源,和一個資料來源集合
* @param defaultTargetDataSource
* @param targetDataSources
*/
public DynamicDataSource(DataSource defaultTargetDataSource, Map<String, DataSource> targetDataSources) {
super.setDefaultTargetDataSource(defaultTargetDataSource);
super.setTargetDataSources(new HashMap<Object, Object>(targetDataSources));
super.afterPropertiesSet();
}
@Override
protected Object determineCurrentLookupKey() {
return getDataSource();
}
public static void setDataSource(String dataSource) {
CONTEXT_HOLDER.set(dataSource);
}
public static String getDataSource() {
return CONTEXT_HOLDER.get();
}
public static void clearDataSource() {
CONTEXT_HOLDER.remove();
}
}
2.5 按鍵查詢
無註解的情況下,lookupKey是空的,這邊直接提供預設資料來源。
有註解的時候,按照註解中的資訊進行查詢。
/**
* 根據 lookupkey 獲取到真正的目標資料來源
* @return
*/
@Override
protected DataSource determineTargetDataSource() {
Assert.notNull(this.targetDataSources, "DataSource router not initialized");
Object lookupKey = this.determineCurrentLookupKey();
DataSource dataSource = (DataSource) this.targetDataSources.get(lookupKey);
if (dataSource == null && (this.lenientFallback || lookupKey == null)) { // 無註解的情況下,lookupKey是空的,會走到這邊,這時候給預設值
dataSource = this.resolvedDefaultDataSource;
}
if (dataSource == null) {
throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
} else {
return dataSource;
}
}
2.6 初始化後的資料來源結構
注意它的key,跟我們配置中的一模一樣,basic、cloudoffice、attend。這個很重要,註解用這個來匹配。
2.7 編寫Annotation
寫一個註解,對映的目標範圍為 型別和方法。
/**
* @author brand
* @Description: 資料來源切換註解
* @Copyright: Copyright (c) 2021
* @Company: Helenlyn, Inc. All Rights Reserved.
* @date 2021/12/15 7:36 下午
*/
@Target({ ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataSource {
String name() default "";
}
2.8 編寫AOP實現
編寫切面程式碼,以實現對註解的PointCut。
/**
* @author brand
* @Description:
* @Copyright: Copyright (c) 2021
* @Company: Helenlyn, Inc. All Rights Reserved.
* @date 2021/12/15 7:49 下午
*/
@Aspect
@Component
public class DataSourceAspect implements Ordered {
/**
* 定義一個切入點,匹配到上面的註解DataSource
*/
@Pointcut("@annotation(com.helenlyn.dataassist.annotation.DataSource)")
public void dataSourcePointCut() {
}
/**
* Around 環繞方式做切面注入
* @param point
* @return
* @throws Throwable
*/
@Around("dataSourcePointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
DataSource ds = method.getAnnotation(DataSource.class);
String routeKey = ds.name(); // 從頭部中取出註解的name(basic 或 cloudoffice 或 attend),用這個name進行資料來源查詢。
String dataSourceRouteKey = DynamicDataSourceRouteHolder.getDataSourceRouteKey();
if (StringUtils.isNotEmpty(dataSourceRouteKey)) {
// StringBuilder currentRouteKey = new StringBuilder(dataSourceRouteKey);
routeKey = ds.name();
}
DynamicDataSourceRouteHolder.setDataSourceRouteKey(routeKey);
try {
return point.proceed();
} finally { // 最後做清理,這個步驟很重要,因為我們的配置中有一個預設的資料來源,執行完要回到預設的資料來源。
DynamicDataSource.clearDataSource();
DynamicDataSourceRouteHolder.clearDataSourceRouteKey();
}
}
@Override
public int getOrder() {
return 1;
}
}
2.9 測試與效果
2.9.1 資料來源key資訊
資料來源key 資訊,有多少個資料來源,這邊就配置多少個,注意值須與yaml配置中的值保持一致。
/**
* 資料來源key 資訊,有多少個資料來源,這邊就配置多少個,
* 值須與yaml配置中的保持一致
*/
public static final String DATA_SOURCE_BASIC_NAME = "basic";
public static final String DATA_SOURCE_ATTEND_NAME = "attend";
public static final String DATA_SOURCE_CLOUD_NAME = "cloud";
2.9.2 測試方法
在Control中寫三個測試方法
/**
* 無註解預設情況:資料來源指向basic
* @return
*/
@RequestMapping(value = "/default/{user_code}", method = RequestMethod.GET)
public UserInfoDto getUserInfo(@PathVariable("user_code") String userCode) {
return userInfoService.getUserInfo(userCode);
}
/**
* 資料來源指向attend
* @return
*/
@DataSource(name= Constant.DATA_SOURCE_ATTEND_NAME)
@RequestMapping(value = "/attend/{user_code}", method = RequestMethod.GET)
public UserInfoDto getUserInfoAttend(@PathVariable("user_code") String userCode) {
return userInfoService.getUserInfo(userCode);
}
/**
* 資料來源指向cloud
* @return
*/
@DataSource(name= Constant.DATA_SOURCE_CLOUD_NAME)
@RequestMapping(value = "/cloud/{user_code}", method = RequestMethod.GET)
public UserInfoDto getUserInfoCloud(@PathVariable("user_code") String userCode) {
return userInfoService.getUserInfo(userCode);
}
2.9.3 效果
3 總結和程式碼參考
如果需要擴充套件資料來源,在yaml的節點mutil-data-core下加配置資料就行了,簡單方便。後面再寫個MySQL的實現方式。
github程式碼:https://github.com/WengZhiHua/Helenlyn.Grocery/tree/master/parent/DynamicDataSource