路由元件構建方案(分庫分表)V1

2022-10-20 12:01:04

路由元件構建方案V1

實現效果:通過註解實現資料分散到不同庫不同表的操作。
實現主要以下幾部分:

  1. 資料來源的設定和載入
  2. 資料來源的動態切換
  3. 切點設定以及資料攔截
  4. 資料的插入

涉及的知識點:

  1. 分庫分表相關概念
  2. 雜湊演演算法
  3. 資料來源的切換
  4. AOP切面
  5. Mybatis攔截器

資料來源的設定和載入

獲取多個資料來源我們肯定需要在yaml或者properties中進行設定。所以首先需要獲取到設定資訊;
定義組態檔中的庫和表:

server:
  port: 8080
# 多資料來源路由設定
router:
  jdbc:
    datasource:
      dbCount: 2
      tbCount: 4
      default: db00
      routerKey: uId
      list: db01,db02
      db00:
        driver-class-name: com.mysql.jdbc.Driver
        url: jdbc:mysql://xxxxx:3306/xxxx?useUnicode=true
        username: xxxx
        password: 111111
      db01:
        driver-class-name: com.mysql.jdbc.Driver
        url: jdbc:mysql://xxxxx:3306/xxxxx?useUnicode=true
        username: xxxxx
        password: 111111
      db02:
        driver-class-name: com.mysql.jdbc.Driver
        url: jdbc:mysql://xxxxx:3306/xxxx?useUnicode=true
        username: xxxxx
        password: 111111
mybatis:
  mapper-locations: classpath:/com/xbhog/mapper/*.xml
  config-location:  classpath:/config/mybatis-config.xml

為了實現並且使用自定義的資料來源設定資訊,啟動開始的時候讓SpringBoot定位位置。
首先類載入順序:指定自動設定;

org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.xbhog.db.router.config.DataSourceAutoConfig

針對讀取這種自定義較大的資訊設定,就需要使用到 org.springframework.context.EnvironmentAware 介面,來獲取組態檔並提取需要的設定資訊。

public class DataSourceAutoConfig implements EnvironmentAware {

    @Override
    public void setEnvironment(Environment environment){
        ......
    }
}

屬性設定中的字首需要跟路由元件中的屬性設定:
這裡設定成什麼,在組態檔中就要設定成對應名字

String prefix = "router.jdbc.datasource.";

根據其字首獲取對應的庫數量dbCount、表數量tbCount以及資料來源資訊dataSource;

//庫的數量
dbCount = Integer.valueOf(environment.getProperty(prefix + "dbCount"));
//表的數量
tbCount = Integer.valueOf(environment.getProperty(prefix + "tbCount"));
//分庫分表資料來源
String dataSources = environment.getProperty(prefix + "list");

針對多資料來源的存在,使用Map進行儲存:Map<String,Map<String,Object>> daraSources;

for(String dbInfo : dataSources.split(",")){
    Map<String,Object> dataSourceProps = PropertyUtil.handle(environment, prefix + dbInfo, Map.class);
    dataSourceMap.put(dbInfo,dataSourceProps);
}

通過dataSource方法實現資料來源的範例化:把基於從設定資訊中讀取到的資料來源資訊,進行範例化建立。
將獲得的資訊放到DynamicDataSource類(父類別:DataSource)中進行範例化(setTargetDataSources,setDefaultTargetDataSource);
將我們自定義的資料來源加入到Spring容器管理中。

//建立資料來源
Map<Object, Object> targetDataSource = new HashMap<>();
//遍歷資料來源的key和value
for(String dbInfo : dataSourceMap.keySet()){
    Map<String, Object> objectMap = dataSourceMap.get(dbInfo);
    targetDataSource.put(dbInfo,new DriverManagerDataSource(objectMap.get("url").toString(),
            objectMap.get("username").toString(),objectMap.get("password").toString()));
}
//這是資料來源
DynamicDataSource dynamicDataSource = new DynamicDataSource();
dynamicDataSource.setTargetDataSources(targetDataSource);
//defaultDataSourceConfig的輸入點
dynamicDataSource.setDefaultTargetDataSource(new DriverManagerDataSource(defaultDataSourceConfig.get("url").toString(),
        defaultDataSourceConfig.get("username").toString(),defaultDataSourceConfig.get("password").toString()));
return dynamicDataSource;

到這裡前置的設定都在spring中完成,後續是對資料的插入,也就是mybatis的操作:包含庫表的隨機計算和資料攔截器的實現。

動態切換資料來源

路由切換的實現通過AbstractRoutingDataSource抽象類,該類充當了DataSource的路由中介, 在執行的時候, 根據某種key值來動態切換到真正的DataSource上。繼承了AbstractDataSourceAbstractDataSource實現了DataSource;
AbstractRoutingDataSource根據方法determineTargetDataSource:

檢索當前目標資料來源。確定當前查詢鍵,在targetDataSources對映中執行查詢,必要時退回到指定的預設目標資料來源。

protected DataSource determineTargetDataSource() {
    Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
    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方法來確定當前查詢的鍵(資料來源key);

抽象方法determineCurrentLookupKey()返回DataSource的key值,然後根據這個key從resolvedDataSources這個map裡取出對應的DataSource,如果找不到,則用預設的resolvedDefaultDataSource

	/**
	 *確定當前查詢鍵。這通常用於檢查執行緒繫結的事務上下文。 
	 *允許任意鍵。返回的鍵需要匹配由resolveSpecifiedLookupKey方法解析的儲存查詢鍵型別
	 */
	@Nullable
	protected abstract Object determineCurrentLookupKey();

所以我們只需要重寫determineCurrentLookupKey,指定我們切換資料來源的名字即可;

public class DynamicDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return "db"+ DBContextHolder.getDBKey();
    }
}

在這部分對應上了前面建立資料來源的操作,實現的該DynamicDataSource,並傳入了預設資料來源(setDefaultTargetDataSource)和目標資料來源(setTargetDataSources);

自定義切點

前期資料來源的設定和資訊已經放到Spring容器中,可隨時使用;根據註解通過攔截器攔截方法中的資料。進行分庫分表的操作,通過擾動函數進行計算,將結果儲存到ThreadLocal中,方便後續讀取。

註解實現:

分庫註解:首先設定三要素。

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE,ElementType.METHOD})
public @interface DBRouter {

    /** 分庫分表欄位 */
    String key() default "";
}

通過自定義切點@Around(**"aopPoint()&&@annotation(dbRouter)"**),實現使用註解的時候就攔截對應的值:
在環繞處理的時候,判斷方法上註解是否對應有值,有的話通過註解傳入的value和方法傳入的引數進行路由計算:
計算規則:

  1. 獲取方法傳入的引數
  2. 計算庫表總數量:dbCount*tbCount
  3. 計算idx:**int **idx = (size -1) & (Key.hashCode() ^ (Key.hashCode() >>> 16))
    1. 簡單說明:與運算識別符號後面,通過混合高位和低位,增大隨機性
  4. **int **dbIdx = idx / dbCount() + 1
  5. **int **tbIdx = idx - tbCount() * (dbIdx - 1)

通過上述操作,將計算的記過儲存到ThreadLocal中。
獲取方法傳入的引數:

private String getAttrValue(String dbKey, Object[] args) {
    if(1 == args.length){
        return args[0].toString();
    }
    String filedValue = null;
    for(Object arg : args){
        try{
            if(StringUtils.isNotBlank(filedValue)){
                break;
            }
            filedValue = BeanUtils.getProperty(arg,dbKey);
        }catch (Exception e){
            log.info("獲取路由屬性失敗 attr:{}", dbKey,e);
        }
    }
    return filedValue;
}

自定義攔截器

我們定義了Interceptor將攔截StatementHandler(SQL語法構建處理攔截)中引數型別為Connection的prepare方法,具體需要深入mybatis原始碼;
主要功能:在執行SQL語句前攔截,針對相關功能實現SQL的修改
在上述文章中主要是針對分庫分表前做準備,下面才是決定資料入哪個庫哪張表
通過StatementHandler(MyBatis直接在資料庫執行SQL指令碼的物件)獲取mappedStatement(MappedStatement維護了一條<select|update|delete|insert>節點的封裝),根據maperdStatement獲取自定義註解dbRouterStrategy,判斷是否進行分表操作;

Class<?> clazz = Class.forName(className);
DBRouterStrategy dbRouterStrategy = clazz.getAnnotation(DBRouterStrategy.class);
if (null == dbRouterStrategy || !dbRouterStrategy.splitTable()){
    return invocation.proceed();
}

dbRouterStrategy註解預設是false不分表,直接進行資料的插入【更新】;

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE,ElementType.METHOD})
public @interface DBRouterStrategy {
    boolean splitTable() default false;
}

如果分表註解存在或者分表引數是true,則進行以下四步:

  1. 獲取SQL

    BoundSql:表示動態生成的SQL語句以及相應的引數資訊。

//獲取SQL
BoundSql boundSql = statementHandler.getBoundSql();
String sql = boundSql.getSql();
  1. 匹配SQL

通過正則匹配分割【insert/select/update】和表名,方便後續表名的拼接。

//替換SQL表名USER為USER_3;
Matcher matcher = pattern.matcher(sql);
String tableName = null;
if(matcher.find()){
    tableName = matcher.group().trim();
}
  1. 拼接SQL

則通過反射修改SQL語句,並且替換表名;其中filed.set()將指定物件實參上由此field物件表示的欄位設定為指定的新值。如果基礎欄位具有基元型別,則自動解開新值

assert null != tableName;
String replaceSQL = matcher.replaceAll(tableName + "_" + DBContextHolder.getTBKey());
//通過反射修改SQL語句
Field filed = boundSql.getClass().getDeclaredField("sql");
filed.setAccessible(true);
filed.set(boundSql,replaceSQL);

參考文章

https://www.cnblogs.com/aheizi/p/7071181.html

https://blog.csdn.net/wb1046329430/article/details/111501755

https://blog.csdn.net/supercmd/article/details/100042302

https://juejin.cn/post/6966241551810822151