單純了!開發數據庫路由,竟包含擾動函數、哈希散列、數據源切換一堆知識!

⚠️ 本文爲掘金社區首發簽約文章,未獲受權禁止轉載java

做者:小傅哥
博客:bugstack.cn面試

沉澱、分享、成長,讓本身和他人都能有所收穫!😄算法

1、前言

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

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

但當工做幾年後,須要提高本身(要加錢)的時候,居然開始以爲本身只是一個調用 API 攢接口的工具人。沒有知識寬度,沒有技術縱深,也想不出來更沒有意識,把平常開發的業務代碼中通用的共性邏輯提煉出來,開發成公用的組件,更沒有去思考平常使用的一些組件是用什麼技術實現的。設計模式

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

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

2、需求分析

若是要作一個數據庫路由,都須要作什麼技術點?數據結構

首先咱們要知道爲何要用分庫分表,其實就是因爲業務體量較大,數據增加較快,因此須要把用戶數據拆分到不一樣的庫表中去,減輕數據庫壓力。app

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

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

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

圖 1-1

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

  • 是關於 AOP 切面攔截的使用,這是由於須要給使用數據庫路由的方法作上標記,便於處理分庫分表邏輯。
  • 數據源的切換操做,既然有分庫那麼就會涉及在多個數據源間進行連接切換,以便把數據分配給不一樣的數據庫。
  • 數據庫表尋址操做,一條數據分配到哪一個數據庫,哪張表,都須要進行索引計算。在方法調用的過程當中最終經過 ThreadLocal 記錄。
  • 爲了能讓數據均勻的分配到不一樣的庫表中去,還須要考慮如何進行數據散列的操做,不能分庫分表後,讓數據都集中在某個庫的某個表,這樣就失去了分庫分表的意義。

綜上,能夠看到在數據庫和表的數據結構下完成數據存放,我須要用到的技術包括:AOP數據源切換散列算法哈希尋址ThreadLocal以及SpringBoot的Starter開發方式等技術。而像哈希散列尋址數據存放,其實這樣的技術與 HashMap 有太多類似之處,那麼學完源碼造火箭的機會來了 若是你有過深刻分析和學習過 HashMap 源碼、Spring 源碼、中間件開發,那麼在設計這樣的數據庫路由組件時必定會有不少思路的出來。接下來咱們一塊兒嘗試下從源碼學習到造火箭!

3、技術調研

在 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個元素進行擴容或者樹化。
  • 學到什麼:能夠把散列算法、尋址方式都運用到數據庫路由的設計實現中,還有整個數組+鏈表的方式其實庫+表的方式也有相似之處。

4、設計實現

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_0一、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

5、總結

綜上 就是咱們從 HashMap、ThreadLocal、Spring等源碼學習中瞭解到技術內在原理,並把這樣的技術用在一個數據庫路由設計上。若是沒有經歷過這些總被說成造火箭的技術沉澱,那麼幾乎也不太可能順利開發出一個這樣一箇中間件,全部不少時候根本不是技術沒用,而是本身沒用上沒機會用而已。不要總惦記那一片片重複的 CRUD,看看還有哪些知識是真的能夠提高我的能力的!若是你對中間件的設計和實現感興趣,也能夠參考這個掘金小冊:juejin.cn/book/694099…

相關文章
相關標籤/搜索