作者:小傅哥
部落格:https://bugstack.cn
沉澱、分享、成長,讓自己和他人都能有所收穫!?
一、前言
什麼?Java 面試就像造火箭?
單純了! 以前我也一直想 Java 面試就好好面試唄,嘎哈麼總考一些工作中也用不到的玩意,會用 Spring
、MyBatis
、Dubbo
、MQ
,把業務需求實現了不就行了!
但當工作幾年後,需要提升自己(要加錢)的時候,竟然開始覺得自己只是一個呼叫 API 攢介面的工具人。沒有知識寬度,沒有技術縱深,也想不出來更沒有意識,把日常開發的業務程式碼中通用的共性邏輯提煉出來,開發成公用的元件,更沒有去思考日常使用的一些元件是用什麼技術實現的。
所以有時候你說面試好像就是在造火箭,這些技術日常根本用不到,其實很多時候不是這個技術用不到,而是因為你沒用(嗯,以前我也沒用)。當你有這個想法想突破自己的薪資待遇瓶頸時,就需要去瞭解瞭解必備的資料結構
、學習學習Java的演算法邏輯
、熟悉熟悉通用的設計模式
、再結合像 Spring、ORM、RPC,這樣的原始碼實現邏輯,把相應的技術方案賦能到自己的日常業務開發中,把共性的問題用聚焦和提煉的方式進行解決,這些才是你在 CRUD 之外的能力體現(加薪籌碼)。
怎麼? 好像聽上去有道理,那麼舉個栗子,來一場資料庫路由
的需求分析和邏輯實現!
二、需求分析
如果要做一個資料庫路由,都需要做什麼技術點?
首先我們要知道為什麼要用分庫分表,其實就是由於業務體量較大,資料增長較快,所以需要把使用者資料拆分到不同的庫表中去,減輕資料庫壓力。
分庫分表操作主要有垂直拆分和水平拆分:
- 垂直拆分:指按照業務將表進行分類,分佈到不同的資料庫上,這樣也就將資料的壓力分擔到不同的庫上面。最終一個資料庫由很多表的構成,每個表對應著不同的業務,也就是專庫專用。
- 水平拆分:如果垂直拆分後遇到單機瓶頸,可以使用水平拆分。相對於垂直拆分的區別是:垂直拆分是把不同的表拆到不同的資料庫中,而水平拆分是把同一個表拆到不同的資料庫中。如:user_001、user_002
而本章節我們要實現的也是水平拆分的路由設計,如圖 1-1
那麼,這樣的一個資料庫路由設計要包括哪些技術知識點呢?
- 是關於 AOP 切面攔截的使用,這是因為需要給使用資料庫路由的方法做上標記,便於處理分庫分表邏輯。
- 資料來源的切換操作,既然有分庫那麼就會涉及在多個資料來源間進行連結切換,以便把資料分配給不同的資料庫。
- 資料庫表定址操作,一條資料分配到哪個資料庫,哪張表,都需要進行索引計算。在方法呼叫的過程中最終通過 ThreadLocal 記錄。
- 為了能讓資料均勻的分配到不同的庫表中去,還需要考慮如何進行資料雜湊的操作,不能分庫分表後,讓資料都集中在某個庫的某個表,這樣就失去了分庫分表的意義。
綜上,可以看到在資料庫和表的資料結構下完成資料存放,我需要用到的技術包括:AOP
、資料來源切換
、雜湊演算法
、雜湊定址
、ThreadLoca
l以及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