基於AOP和HashMap原理學習,開發Mysql分庫分表路由元件!

小傅哥發表於2021-08-18


作者:小傅哥
部落格:https://bugstack.cn

沉澱、分享、成長,讓自己和他人都能有所收穫!?

一、前言

什麼?Java 面試就像造火箭?

單純了! 以前我也一直想 Java 面試就好好面試唄,嘎哈麼總考一些工作中也用不到的玩意,會用 SpringMyBatisDubboMQ,把業務需求實現了不就行了!

但當工作幾年後,需要提升自己(要加錢)的時候,竟然開始覺得自己只是一個呼叫 API 攢介面的工具人。沒有知識寬度,沒有技術縱深,也想不出來更沒有意識,把日常開發的業務程式碼中通用的共性邏輯提煉出來,開發成公用的元件,更沒有去思考日常使用的一些元件是用什麼技術實現的。

所以有時候你說面試好像就是在造火箭,這些技術日常根本用不到,其實很多時候不是這個技術用不到,而是因為你沒用(嗯,以前我也沒用)。當你有這個想法想突破自己的薪資待遇瓶頸時,就需要去瞭解瞭解必備的資料結構學習學習Java的演算法邏輯熟悉熟悉通用的設計模式、再結合像 Spring、ORM、RPC,這樣的原始碼實現邏輯,把相應的技術方案賦能到自己的日常業務開發中,把共性的問題用聚焦和提煉的方式進行解決,這些才是你在 CRUD 之外的能力體現(加薪籌碼)。

怎麼? 好像聽上去有道理,那麼舉個栗子,來一場資料庫路由的需求分析和邏輯實現!

二、需求分析

如果要做一個資料庫路由,都需要做什麼技術點?

首先我們要知道為什麼要用分庫分表,其實就是由於業務體量較大,資料增長較快,所以需要把使用者資料拆分到不同的庫表中去,減輕資料庫壓力。

分庫分表操作主要有垂直拆分和水平拆分:

  • 垂直拆分:指按照業務將表進行分類,分佈到不同的資料庫上,這樣也就將資料的壓力分擔到不同的庫上面。最終一個資料庫由很多表的構成,每個表對應著不同的業務,也就是專庫專用。
  • 水平拆分:如果垂直拆分後遇到單機瓶頸,可以使用水平拆分。相對於垂直拆分的區別是:垂直拆分是把不同的表拆到不同的資料庫中,而水平拆分是把同一個表拆到不同的資料庫中。如:user_001、user_002

而本章節我們要實現的也是水平拆分的路由設計,如圖 1-1

圖 1-1

那麼,這樣的一個資料庫路由設計要包括哪些技術知識點呢?

  • 是關於 AOP 切面攔截的使用,這是因為需要給使用資料庫路由的方法做上標記,便於處理分庫分表邏輯。
  • 資料來源的切換操作,既然有分庫那麼就會涉及在多個資料來源間進行連結切換,以便把資料分配給不同的資料庫。
  • 資料庫表定址操作,一條資料分配到哪個資料庫,哪張表,都需要進行索引計算。在方法呼叫的過程中最終通過 ThreadLocal 記錄。
  • 為了能讓資料均勻的分配到不同的庫表中去,還需要考慮如何進行資料雜湊的操作,不能分庫分表後,讓資料都集中在某個庫的某個表,這樣就失去了分庫分表的意義。

綜上,可以看到在資料庫和表的資料結構下完成資料存放,我需要用到的技術包括:AOP資料來源切換雜湊演算法雜湊定址ThreadLocal以及SpringBoot的Starter開發方式等技術。而像雜湊雜湊定址資料存放,其實這樣的技術與 HashMap 有太多相似之處,那麼學完原始碼造火箭的機會來了 如果你有過深入分析和學習過 HashMap 原始碼、Spring 原始碼、中介軟體開發,那麼在設計這樣的資料庫路由元件時一定會有很多思路的出來。接下來我們一起嘗試下從原始碼學習到造火箭!

三、技術調研

在 JDK 原始碼中,包含的資料結構設計有:陣列、連結串列、佇列、棧、紅黑樹,具體的實現有 ArrayList、LinkedList、Queue、Stack,而這些在資料存放都是順序儲存,並沒有用到雜湊索引的方式進行處理。而 HashMap、ThreadLocal,兩個功能則用了雜湊索引、雜湊演算法以及在資料膨脹時候的拉鍊定址和開放定址,所以我們要分析和借鑑的也會集中在這兩個功能上。

1. ThreadLocal

@Test
public void test_idx() {
    int hashCode = 0;
    for (int i = 0; i < 16; i++) {
        hashCode = i * 0x61c88647 + 0x61c88647;
        int idx = hashCode & 15;
        System.out.println("斐波那契雜湊:" + idx + " 普通雜湊:" + (String.valueOf(i).hashCode() & 15));
    }
} 

斐波那契雜湊:7 普通雜湊:0
斐波那契雜湊:14 普通雜湊:1
斐波那契雜湊:5 普通雜湊:2
斐波那契雜湊:12 普通雜湊:3
斐波那契雜湊:3 普通雜湊:4
斐波那契雜湊:10 普通雜湊:5
斐波那契雜湊:1 普通雜湊:6
斐波那契雜湊:8 普通雜湊:7
斐波那契雜湊:15 普通雜湊:8
斐波那契雜湊:6 普通雜湊:9
斐波那契雜湊:13 普通雜湊:15
斐波那契雜湊:4 普通雜湊:0
斐波那契雜湊:11 普通雜湊:1
斐波那契雜湊:2 普通雜湊:2
斐波那契雜湊:9 普通雜湊:3
斐波那契雜湊:0 普通雜湊:4
  • 資料結構:雜湊表的陣列結構
  • 雜湊演算法:斐波那契(Fibonacci)雜湊法
  • 定址方式:Fibonacci 雜湊法可以讓資料更加分散,在發生資料碰撞時進行開放定址,從碰撞節點向後尋找位置進行存放元素。公式:f(k) = ((k * 2654435769) >> X) << Y對於常見的32位整數而言,也就是 f(k) = (k * 2654435769) >> 28 ,黃金分割點:(√5 - 1) / 2 = 0.6180339887 1.618:1 == 1:0.618
  • 學到什麼:可以參考定址方式和雜湊演算法,但這種資料結構與要設計實現作用到資料庫上的結構相差較大,不過 ThreadLocal 可以用於存放和傳遞資料索引資訊。

2. HashMap

public static int disturbHashIdx(String key, int size) {
    return (size - 1) & (key.hashCode() ^ (key.hashCode() >>> 16));
}
  • 資料結構:雜湊桶陣列 + 連結串列 + 紅黑樹
  • 雜湊演算法:擾動函式、雜湊索引,可以讓資料更加雜湊的分佈
  • 定址方式:通過拉鍊定址的方式解決資料碰撞,資料存放時會進行索引地址,遇到碰撞產生資料連結串列,在一定容量超過8個元素進行擴容或者樹化。
  • 學到什麼:可以把雜湊演算法、定址方式都運用到資料庫路由的設計實現中,還有整個陣列+連結串列的方式其實庫+表的方式也有類似之處。

四、設計實現

1. 定義路由註解

定義

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

    String key() default "";

}

使用

@Mapper
public interface IUserDao {

     @DBRouter(key = "userId")
     User queryUserInfoByUserId(User req);

     @DBRouter(key = "userId")
     void insertUser(User req);

}
  • 首先我們需要自定義一個註解,用於放置在需要被資料庫路由的方法上。
  • 它的使用方式是通過方法配置註解,就可以被我們指定的 AOP 切面進行攔截,攔截後進行相應的資料庫路由計算和判斷,並切換到相應的運算元據源上。

2. 解析路由配置

  • 以上就是我們實現完資料庫路由元件後的一個資料來源配置,在分庫分表下的資料來源使用中,都需要支援多資料來源的資訊配置,這樣才能滿足不同需求的擴充套件。
  • 對於這種自定義較大的資訊配置,就需要使用到 org.springframework.context.EnvironmentAware 介面,來獲取配置檔案並提取需要的配置資訊。

資料來源配置提取

@Override
public void setEnvironment(Environment environment) {
    String prefix = "router.jdbc.datasource.";    

    dbCount = Integer.valueOf(environment.getProperty(prefix + "dbCount"));
    tbCount = Integer.valueOf(environment.getProperty(prefix + "tbCount"));    

    String dataSources = environment.getProperty(prefix + "list");
    for (String dbInfo : dataSources.split(",")) {
        Map<String, Object> dataSourceProps = PropertyUtil.handle(environment, prefix + dbInfo, Map.class);
        dataSourceMap.put(dbInfo, dataSourceProps);
    }
}
  • prefix,是資料來源配置的開頭資訊,你可以自定義需要的開頭內容。
  • dbCount、tbCount、dataSources、dataSourceProps,都是對配置資訊的提取,並存放到 dataSourceMap 中便於後續使用。

3. 資料來源切換

在結合 SpringBoot 開發的 Starter 中,需要提供一個 DataSource 的例項化物件,那麼這個物件我們就放在 DataSourceAutoConfig 來實現,並且這裡提供的資料來源是可以動態變換的,也就是支援動態切換資料來源。

建立資料來源

@Bean
public DataSource dataSource() {
    // 建立資料來源
    Map<Object, Object> targetDataSources = new HashMap<>();
    for (String dbInfo : dataSourceMap.keySet()) {
        Map<String, Object> objMap = dataSourceMap.get(dbInfo);
        targetDataSources.put(dbInfo, new DriverManagerDataSource(objMap.get("url").toString(), objMap.get("username").toString(), objMap.get("password").toString()));
    }     

    // 設定資料來源
    DynamicDataSource dynamicDataSource = new DynamicDataSource();
    dynamicDataSource.setTargetDataSources(targetDataSources);
    return dynamicDataSource;
}
  • 這裡是一個簡化的建立案例,把基於從配置資訊中讀取到的資料來源資訊,進行例項化建立。
  • 資料來源建立完成後存放到 DynamicDataSource 中,它是一個繼承了 AbstractRoutingDataSource 的實現類,這個類裡可以存放和讀取相應的具體呼叫的資料來源資訊。

4. 切面攔截

在 AOP 的切面攔截中需要完成;資料庫路由計算、擾動函式加強雜湊、計算庫表索引、設定到 ThreadLocal 傳遞資料來源,整體案例程式碼如下:

@Around("aopPoint() && @annotation(dbRouter)")
public Object doRouter(ProceedingJoinPoint jp, DBRouter dbRouter) throws Throwable {
    String dbKey = dbRouter.key();
    if (StringUtils.isBlank(dbKey)) throw new RuntimeException("annotation DBRouter key is null!");

    // 計算路由
    String dbKeyAttr = getAttrValue(dbKey, jp.getArgs());
    int size = dbRouterConfig.getDbCount() * dbRouterConfig.getTbCount();

    // 擾動函式
    int idx = (size - 1) & (dbKeyAttr.hashCode() ^ (dbKeyAttr.hashCode() >>> 16));

    // 庫表索引
    int dbIdx = idx / dbRouterConfig.getTbCount() + 1;
    int tbIdx = idx - dbRouterConfig.getTbCount() * (dbIdx - 1);   

    // 設定到 ThreadLocal
    DBContextHolder.setDBKey(String.format("%02d", dbIdx));
    DBContextHolder.setTBKey(String.format("%02d", tbIdx));
    logger.info("資料庫路由 method:{} dbIdx:{} tbIdx:{}", getMethod(jp).getName(), dbIdx, tbIdx);
   
    // 返回結果
    try {
        return jp.proceed();
    } finally {
        DBContextHolder.clearDBKey();
        DBContextHolder.clearTBKey();
    }
}
  • 簡化的核心邏輯實現程式碼如上,首先我們提取了庫表乘積的數量,把它當成 HashMap 一樣的長度進行使用。
  • 接下來使用和 HashMap 一樣的擾動函式邏輯,讓資料分散的更加雜湊。
  • 當計算完總長度上的一個索引位置後,還需要把這個位置折算到庫表中,看看總體長度的索引因為落到哪個庫哪個表。
  • 最後是把這個計算的索引資訊存放到 ThreadLocal 中,用於傳遞在方法呼叫過程中可以提取到索引資訊。

5. 測試驗證

5.1 庫表建立

create database `bugstack_01`;
DROP TABLE user_01;
CREATE TABLE user_01 ( id bigint NOT NULL AUTO_INCREMENT COMMENT '自增ID', userId varchar(9) COMMENT '使用者ID', userNickName varchar(32) COMMENT '使用者暱稱', userHead varchar(16) COMMENT '使用者頭像', userPassword varchar(64) COMMENT '使用者密碼', createTime datetime COMMENT '建立時間', updateTime datetime COMMENT '更新時間', PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
DROP TABLE user_02;
CREATE TABLE user_02 ( id bigint NOT NULL AUTO_INCREMENT COMMENT '自增ID', userId varchar(9) COMMENT '使用者ID', userNickName varchar(32) COMMENT '使用者暱稱', userHead varchar(16) COMMENT '使用者頭像', userPassword varchar(64) COMMENT '使用者密碼', createTime datetime COMMENT '建立時間', updateTime datetime COMMENT '更新時間', PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
DROP TABLE user_03;
CREATE TABLE user_03 ( id bigint NOT NULL AUTO_INCREMENT COMMENT '自增ID', userId varchar(9) COMMENT '使用者ID', userNickName varchar(32) COMMENT '使用者暱稱', userHead varchar(16) COMMENT '使用者頭像', userPassword varchar(64) COMMENT '使用者密碼', createTime datetime COMMENT '建立時間', updateTime datetime COMMENT '更新時間', PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
DROP TABLE user_04;
CREATE TABLE user_04 ( id bigint NOT NULL AUTO_INCREMENT COMMENT '自增ID', userId varchar(9) COMMENT '使用者ID', userNickName varchar(32) COMMENT '使用者暱稱', userHead varchar(16) COMMENT '使用者頭像', userPassword varchar(64) COMMENT '使用者密碼', createTime datetime COMMENT '建立時間', updateTime datetime COMMENT '更新時間', PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
  • 建立相同表結構的多個庫存資訊,bugstack_01、bugstack_02

5.2 語句配置

<select id="queryUserInfoByUserId" parameterType="cn.bugstack.middleware.test.infrastructure.po.User"
        resultType="cn.bugstack.middleware.test.infrastructure.po.User">
    SELECT id, userId, userNickName, userHead, userPassword, createTime
    FROM user_${tbIdx}
    where userId = #{userId}
</select>               

<insert id="insertUser" parameterType="cn.bugstack.middleware.test.infrastructure.po.User">
    insert into user_${tbIdx} (id, userId, userNickName, userHead, userPassword,createTime, updateTime)
    values (#{id},#{userId},#{userNickName},#{userHead},#{userPassword},now(),now())
</insert>
  • 在 MyBatis 的語句使用上,唯一變化的需要在表名後面新增一個佔位符,${tbIdx} 用於寫入當前的表ID。

5.3 註解配置

@DBRouter(key = "userId")
User queryUserInfoByUserId(User req);   

@DBRouter(key = "userId")
void insertUser(User req);
  • 在需要使用分庫分表的方法上新增註解,新增註解後這個方法就會被 AOP 切面管理。

5.4 單元測試

22:38:20.067  INFO 19900 --- [           main] c.b.m.db.router.DBRouterJoinPoint        : 資料庫路由 method:queryUserInfoByUserId dbIdx:2 tbIdx:3
22:38:20.594  INFO 19900 --- [           main] cn.bugstack.middleware.test.ApiTest      : 測試結果:{"createTime":1615908803000,"id":2,"userHead":"01_50","userId":"980765512","userNickName":"小傅哥","userPassword":"123456"}
22:38:20.620  INFO 19900 --- [extShutdownHook] o.s.s.concurrent.ThreadPoolTaskExecutor  : Shutting down ExecutorService 'applicationTaskExecutor'1

  • 以上就是我們使用自己的資料庫路由元件執行時的一個日誌資訊,可以看到這裡包含了路由操作,在2庫3表:資料庫路由 method:queryUserInfoByUserId dbIdx:2 tbIdx:3

五、總結

綜上 就是我們從 HashMap、ThreadLocal、Spring等原始碼學習中瞭解到技術內在原理,並把這樣的技術用在一個資料庫路由設計上。如果沒有經歷過這些總被說成造火箭的技術沉澱,那麼幾乎也不太可能順利開發出一個這樣一箇中介軟體,所有很多時候根本不是技術沒用,而是自己沒用上沒機會用而已。不要總惦記那一片片重複的 CRUD,看看還有哪些知識是真的可以提升個人能力的!參考資料:https://codechina.csdn.net/MiddlewareDesign

六、系列推薦

相關文章