分庫分表是大型互聯網應用經常採用的一種數據層優化方案,常見的分庫分表中間件如 sharding-jdbc、mycat 都已經比較成熟,基本上可以應對我們一般的分庫分表需求。 做過分庫分表的同學應該知道,在給業務系統做分庫分表改造過程中,難的不是如何使用這些組件進行分庫分表,而是如何將非分庫分表的系... ...
背景
分庫分表是大型互聯網應用經常採用的一種數據層優化方案,常見的分庫分表中間件如 sharding-jdbc、mycat 都已經比較成熟,基本上可以應對我們一般的分庫分表需求。
做過分庫分表的同學應該知道,在給業務系統做分庫分表改造過程中,難的不是如何使用這些組件進行分庫分表,而是如何將非分庫分表的系統平滑的升級成一個分庫分表的系統,升級期間業務不可暫停,升級過程及升級後風險可控,這個過程就像是給飛行中的飛機更換引擎,處理不好會產生重大的業務事故。
去哪兒網機票輔營業務就經歷過從主從讀寫分離系統升級到分庫分表系統的過程,併在多次迭代過程中形成了一種與業務輕相關的平滑的分庫分表方案,後續業務升級分庫分表只需通過配置切換就可以將單庫單表系統瞬切至分庫分表系統。
一、原始問題
去哪兒網有自研的分庫分表中間件 qdb,是基於數據源進行分庫分表的,它和那些開源的分庫分表中間件一樣,只解決瞭如何進行分庫分表問題,沒有解決如何將一個非分庫分表系統升級至分庫分表系統過度的問題。如果我們直接使用 qdb 進行分庫分表,不做任何過度方案,那麼將有以下問題:
-
升級過程中如果出現部分數據錯誤,如何回滾?如做回滾,新數據可能在回滾前落入新庫,回滾後落入舊庫,一部分數據在用戶層面將看不到;如果錯誤只出現一次還好,可以通過洗數解決;但如果升級過程反覆發現 bug,反覆修訂,一定會對業務造成影響;
-
遷移至分庫分表後,為了保證數據被查詢到且保證查詢的性能,一般情況下 sql 的查詢條件需要帶上分表(片)鍵,但一個已經運轉多年的業務系統它的 sql 肯定不能完全滿足這個要求,如果進行全量的 sql 改寫將是一個巨大的工作量,且有些業務場景根本就無法進行 sql 改寫,比如輔營交易系統表的分表鍵一般是自身業務的訂單號,但它有根據第三方券碼查訂單的客觀需求( 一般是三方回調介面中)。
-
如何確定分庫分表後的系統數據業務等價於分庫分表前的系統。
解決了這三個問題也就能順利的從單庫單表遷移至分庫分表了。
二、第一次平滑遷移至分庫分表的實踐
簡單來說第一次進行分庫分表的平滑升級,其主要思路是:
-
對數據進行雙寫;在分表鍵的基本之上增加了分表鍵映射的概念,通過 sql 條件分析自動或手動路由控制數據讀寫單庫單表或分庫分表;
-
再通過一種特殊的事務來實現的兩套系統的一致性;
-
通過 iff 來確定兩套資料庫系統數據是等價的。
這3個點分別對應解決上述的三個問題。
為了方便理解後續內容,有必要對 mybatis 和 mybatis-spring 的一些原理作一些簡單介紹,讀者如果非常瞭解 spring 事務和 mybatis 的原碼則可以跳過這一部分。
1)mybatis的整的框架
-
介面層:是 mybatis 提供給開發人員的 api,其主要是 SqlSession 對象, 開發人員通過 SqlSession 和 Mapper 介面來操作數據;平時我們做業務開發的時候感知不到 SqlSession,只是聲明瞭一下 Dao 層的 Mapper 介面,就可以在 Spring 容器中拿到對應 Mapper 介面的實現來操作數據,這是因為框架幫我們做了很多事情,實際內部就是通 SqlSession 完成的,只是這個 SqlSession 的操作過程封裝到了一個實現了 Mapper 介面的動態代理中,mybatis-spring 框架在掃苗包路徑的時候將 Mapper 對應的動態代理實現註入到了 Spring 容器;對這塊原理感興趣的讀取可以查詢 mybatis 源碼中 MapperProxy 及其相關類的實現。
-
數據處理層: mybatis 的核心實現,主要是參數處理及 sql 解析、映射、執行、結果構建,詳細處理流程見後文說明。
-
基礎支撐層: 主要包括連接管理、事務管理、配置載入和緩存處理,將他們抽取出來作為最基礎的組件,為上層的數據處理層提供最基礎的支撐。
2)Mybatis-Spring及Mybatis的處理流程
對這個圖中涉及的原理做一個簡單解釋,讀者如果對細節感興趣,在隨意起一個使用了 mybatis-spring 的項目,將圖中關點節點打上斷點觀察即可。
sql執行流程流程解釋(紅色組件部分)
① 由 SqlSession 開始, SqlSession 如上文所提及的是 mybatis 開放給用戶頂層 api,它定義了 sql 操作的一個會話;SqlSession 通過Executor來完成操作;
② Executor 是調度核心,它負責SQL語句的生成,調用 StatementHandler 訪問資料庫,查詢緩存的維護,將 MappedStatement 對象進行解析,sql 參數轉化、動態 sql 拼接,生成 jdbc Statement 對象;
③ StatementHandler 封裝了 JDBC Statement 操作,負責對 JDBCstatement 的操作,設置參數、將 Statement 結果集轉換成List 集合,是真正訪問資料庫的地方;在 StatementHandler 和 JDBC Statement 之間可以通過:
-
ParameterHandler 負責將用戶傳遞的參數轉換成;
-
ResultSetHandler 負責將 JDBC 返回的 ResultSet 結果集對象轉換成 List 類型的集合;處理查詢結果;
-
TypeHandler 負責 java 數據類型和 jdbc 數據類型之間的映射和轉換。
Dao介面對應bean的創建及調用實現(綠色部分)
① ClassPathBeanDefinitionScanner 負現掃苗由 @MapperScan 註解描述的包路徑,對符合條件的 Dao 介面通過 Spring 的 BeanDefinitionRegistry 進行註冊,並將 BeanDefinition 的 beanClass 屬性設置為 MapperFactoryBean;
②大家在業務代碼中通過 Spring 容器拿到的 Dao 的實際其實就是 MapperFactoryBean 的通過調用 FactoryBean 介面的 getObject() 獲取的;
③ getObject() 方法是通過 SqlSession 的 getMapper 方法(參數是 Dao 介面的類名)獲取到了,前文提到過了 MapperProxy 實例,它體質就是動態代碼調用 SqlSession,只是調過過程中的參數是由環境、Configuration 及上下文中獲取;
④MapperFactoryBean 的 SqlSession 一般就是 SessionTemplate,SessionTemplate 是 Mybatis-Spring 給的 SqlSession 的標準實現,它的核心功能是通過 SqlSessionFactory 來獲取實際的 SqlSession 和對 SqlUtils 對獲取過程進行攔截;
⑤ SqlUtils 對獲取 SqlSession 的攔截主要目的就是聯結 Spring 的事務處理環境,它會判定如果是在事務環境中,同一事務下通過 SqlSessionHolder 復用 SqlSession。
SessionFactory的註入(藍色部分)
①SqlSessionFactoryBean 對過繼承 Spring 的擴展介面FactoryBean、InitializingBean 在 Spring 初始化 bean 的時候SqlSessionFactoryBean 通過調用自身的 buildSqlSessionFactory 來構建 SqlSessionFactory,這個構建過程要麼是通過 xml 要麼是通過註解,構建的時候也完成的 Configuration 的設置,這個Configuration 主要包括了 MappedStatement 和 Interceptor(插件)。
② MappedStatement 就是用來存放我們 SQL 映射文件中的信息包括 sql 語句,輸入參數,輸出參數等等。一個 Dao 方法對應一個 MappedStatement 對象。
③ Interceptor 就是 mybatis 的插件,它通過責任鏈模式實現,可分別在 Executor 階段或 StatementHanlder 執行價段進行攔截。
升級期間繼續保留的單庫單表資料庫,同時按新的規則建立分庫分表。在輔營業務系統中,分庫是以業務線為依據的,基本按照一個業務線一個庫來劃分,分表是以輔營系統自身的訂單號為依據,以月為單位進行劃分的(單號中含日期信息)。
所謂數據雙寫,就是當業務需要進行數據進行增刪改的時候,同時對兩套資料庫進行增刪改;當業務需要讀數據的時候,只需對一套資料庫進行讀即可。當我們確定在所有情況下,對分表進行讀寫與對單表進行讀寫是等效的時候,我們就可以下線單表那套數據源了。
這個雙寫的過程不是由業務代碼來完成的,而是通過 mybatis 插件來實現。使用 mybatis 插件來攔截 sql,更換 sqlSession 及改寫 sql,可以覆蓋當下所有的 sql 及未來可能出現的 sql,同時開關切換可細化到 DAO 層的一個具體方法上,單庫切換成分庫的過程就可以最小粒度到一條條Sq的l執行上,在漸近性升級過程中,可以一步一確認了,通過監控和日誌觀察,如發現存在問題可以立馬切回,將發生錯誤的負面影響降到最低。
另外,藉助於去哪兒的配置中心 qconfig 的部分推送功能,可以將線上的應用進程先小部分切換,待確定穩定後再推送到全部實例。
雙寫思路看著簡單,但要真正實現雙寫並不容易,中間會引出新的技術問題:
第一個問題是,在 mybatis 內部如何正確切換執行的目標庫;
第二個問題是,在 mybatis 內部如何對 sql 的執行過程進行複製並用分庫的執行結果替換掉原有的執行結果。
-
對於第一個問題
我們第一版本的做法是直接從待切數據源(分表)中重新獲取 sqlSession,通過 Spring 的事務管理 api 來判定當前是否屬於事務環境,如果是事務環境,則先從線程上下文中獲取,如果不存在再從待先切數據源中獲取。
在事務環境下,我們需要將切換過來的連接的autoCommit 屬性設置為 false(autoCommit 屬性為 true 的時候相當於是自動提交事務,基本就是一條sql執行在一個事務里,在事務環境下它需要為false,業務開發平時感知不到這個值的設置是因為 spring 事務框架自動幫我們做了這件事。
現在由於我們自己從新的數據源中撈了一個不在標準 Spring 流程的連接,所以需要自己補一下這個連接的維護), 在事務提交時再設置成 false(相當於歸還連接池時進行複位了),這期間連接的獲取和釋放得小心,切過來的那個連接的 ConnectionHolder 和 SessionHolder 都需要補一下引用記數的維護,因為它在 Spring 和 mybatis 標準處理之外,如果不作處理就會出現連接泄露或復用了已經關閉的連接。
-
對於第二個問題
這個處理包括解代理、複製參數、根據參數構建新的 statement、再通過反射調用來實現 sql 在待切數據源上執行。為什麼重新構鍵一個 statement呢?這是因為 statement 是 jdbc 提供的操作資料庫的介面和概念,一個 statement 是和一個 connection 相關連的,既然雙寫階段兩個 connection 同時存在,那麼 statement 也是有兩個,分別來做兩個庫的執行。
為瞭解決 mybatis 插件內部再次調用 sql(再次調用是原於下文中分表鍵的處理)出現上下文間的干擾,我們定義 sql 執行的父子上下文的概念,父上下文感知不到子上下文的存在,子上下文對變數做的任何修改、覆蓋或添加只在子上下文中有效,在父上下文環境下都是無效的,這相當於給子上下文開了一個安全的環境,在內部執行的 sql 不會對外層環境產生破壞。
第二個問題是由我們的技術實現方案帶來的新問題,所謂的上下文干擾一般包含分庫分表中間件內部基於 ThreadLocal 做的一些變數記錄,在 mybatis 插件內部再次調用另一條 sql 時可能就會出插件內調用的 sql 的上下文污染了原將要執行 sql 上下文。
雙寫其實可以在 mybatis 外部進行的, 在 mybatis 外部進行時就沒有那麼複雜的 statement 的複製和其參數的構建過程,但由於當時我們系統 mybatis 外部調用入口多且不統一,且先前在 Dao 層做了很多特殊註解和功能,這些功能沒有考慮有兩個完全一樣的 Dao 的情況,直接在 mybatis 外部進行雙寫,改動太多其負面影響也不好預估,所以才在 mybatis 插件內部做了雙寫的實現。
通常不含分表鍵條件的 sql 來查詢數據是不可避免的,一般的分庫分表中間件對這種 sql 都是進行全表廣播,其性能自然不太理想。為瞭解決非分表鍵全表廣播的問題,我們提出了映射鍵的概念,映射鍵是相對分表鍵而言的,在 sql 查詢中如不含分表鍵,就找映射鍵,再通過映射鍵到分表鍵,然後根據分表鍵計算出分表的物理坐標,最後通過分庫分表中間件 sql 路由引導的 api (這裡的概念如果不瞭解後面有解釋)來引導分庫分表中間件完成查詢。映射鍵的映射關係維護在獨立的映射表中,這個映射自身也是分表的,分表規則就是映射鍵的值的 hash。
舉個例子,比如通過券碼(couponId)查訂單,那麼這裡的券碼(couponId)就是映射鍵, 券碼(couponId)的具體的值就是映射鍵的值,輔營自身的訂單號(orderId)就是分表鍵,couponId→orderId 就形成了一個映射, 我們在 sql 查詢中就可以只包含 couponId。除了這種直接映射外,還有一個間接映射,在輔營系統中,錶面是按訂單號進行分表的,本質是按訂單號中的時間條件進行分表的,在上下文已知業務線的情況下,如果查詢條件中包含訂單號的創建時間,那麼就算不含分表鍵和映射鍵也是可以對物理表坐標進行定位的,從而減少 sql 全表廣播的可能。
sql 執行過程可以抽象如下圖:
映射鍵思想的提出使得我們不用改寫所有的 sql,大大提高了分庫分表的適用範圍。在實際開發過程中,還有一種 sql 也是無法改寫的,那就是全表數據掃苗,比如我們要定期掃苗待過期的券碼,在分庫分表環境下應該怎麼做呢?
這裡我們通過提供了一種手工定位和迭代所有物理庫和物理表的 api,把形如 selectAll 的查詢需求轉化為對物理庫和物理表下數據的分批訪問,用戶通過設置回調函數來處理每一批數據。
diff 是指的是對單庫單表和分庫分表的內容進行比較,如果 diff 的結果一樣,且主分是在一個事務中則可驗證分庫分表前後系統在業務上是等價的。然而,要做到這兩點好像是不可能的,特別是事務,事務應該只在一個資料庫會話中才是有效的。
理論上追求的是嚴緊、是必然;工程上追求的是可行、是可然和近然。接下來我們看一下 diff 和事務是如何實現的。
對於 diff,同步 diff 肯定影響性能,也不能進行採樣化解,畢竟我們要確定全部數據是否一致;而非同步 diff,可能由於讀取的時間點不一樣數據已經被改變了,這樣就算 diff 結果顯示不一致也不能說明同一時刻的數據是不一致的。
我們最終的 diff 方案是離線 diff 加實時 diff 相結合的方式,通過 diff 一段時間,如果 diff 差異是收斂的說明細節的修訂是有效的, 當24小時內偶發個位級別的不一致,我們就可以認為兩邊數據上基本等價了(實際上,我們最終 diff 差不多是0)。
離線 diff 就是對兩套數據源當日之前的數據進行全量 diff,實時 diff 是指對當下數據操作進行 diff。實時 diff 先 diff 數據修改的返回結果,如果在數據增刪改過程都不一樣,那麼數據讀的過程就沒必要進行 diff 了,畢竟在過度階段雙寫是必然要進行了,直接拿雙寫的結果進行 diff 是沒有額外性能開銷的,待雙寫 diff 達到完全一致時,再有選擇的分批對讀進行 diff。為了不影響性能,讀 diff 是非同步的,前面也說過讀 diff 不一致不能完成說明是數據是不一致的,但是可以作為一種參考,當 diff 出現不一致時我們列印出兩邊的線程堆棧來排查可能的不一致的原因。我們最終以離線 diff 的為判定依據,實時 diff 還是多用於排查問題和確認問題。
再來說一下事務,事務用來保證兩個地方的一致性。第一個是映射表與業務表的一致性,兩方表任何一方漏數據必然導至業務在某個查詢下檢索不到數據,所以對於映射表的操作是和業務表的操作強綁在一個事務中。第二個是單庫與分庫在進行雙寫時也需要在一個事務中,這裡顯然要使用到分散式事務,傳統的幾種分散式事務都不適用我們的場景,不是需要一定的業務侵入配合就是性能上有影響,我們在這裡採用了一種特殊的"分散式"事務的設計,既滿足了性能要求,又能儘量做到一致性。其實現原理參見下圖:
事務管理器只能設置一個 DataSoure,當在事務環境下需要對另外一個數據源進行操作時,會將另一個數據源中獲取的 connection 包在一個 Spring 的事務同步器中,並將這個 connection 的 autoCommit 屬性設置為 false, 在同步器的回調函數 beforeCompletion 中分別增加 SqlSessionHolder 和 ConnectionHolder 引用計數(不增加會被 Spring和 mybatis 框架錯誤回收,到 afterCompletion 環節時連接就可能是已經關閉狀態), 在 afterCompletion 回調函數中根據事務狀態對這個 connection 做提交或回滾,並分別將 SqlSessionHolder 和 ConnectionHolder 引用計數減一,將 autoCommit 重置為 true。
這個相當於一個數據源使用的 spring 事務框架事務,另外一個藉助它的擴展手工處理事務, 雖然從嚴格意義上來說它們不是一個完整的事務,但是兩個事務關聯在一起只有後者(手工的那個)提交失敗,前者提交成功才會引發不一致,出現這種情況的時間視窗很小,且在前者與後者間加段監控可以監測到這種現象的出來。我們上線後通過觀察沒有出現過這種情況,只在人為測試製造這種 case 的時候才會出現,其他情況兩個事務的狀態完全一致。
三、新的問題
上述設計雖然幫助我們完成了輔營交易資料庫從單庫單表平滑遷移到分庫分表,但是也存在一些後續問題,這些問題主要表現在以下方面:
-
測試開發不友好,分庫分表的設計很重,如果所寫的測試關係到數據層的話則需要依賴一整套分庫分表環境,這個環境的建立是有成本的,結果大家只是依賴公共的測試環境,多人依賴測試數據容易有衝突,且對於本地測試極度不友好,集中表現在寫本地單元測試時,啟動一套分庫分表過程很慢。
-
維護成本有點高,這個與方案本身的關係可能不太大,主要是技術實現細節上造成的。早期,主分之間的路由判定依賴於大量的註解和配置中心的配置,還有項目中的各種配置,新加分表關註點很多,如果不是很瞭解原理和技術的實現細節很容易錯配,從而導致事故。
-
不好復用,實現上有一些業務侵入,比如依賴從spring 容器中取數據源的 bean; 分庫分表規則也是一次性的,如果未來有變化也沒有擴展點,比如說從一月分一次分表,改為一周分一次分表,那麼就會出現新舊不相容。這套代碼也不好做到從一個項目遷一到另一個需要分庫分表的項目中直接復用,遷代碼需要大量修訂。
正因為上述問題的存在,我們在輔營 DDD 重構微服務拆分過程中,將這個分庫分表方案進行組件化。除了方便方案更好的復用外, 在易用性上做了很多的提升,可以很方便的切換單庫和分庫的環境,也可以很方便的修改分庫規則。
這對於新建的 DDD 項目是非常提效的,結合單測工具,在項目初建的時候可以完全只考慮業務領域模型問題,將分庫分表後置,待業務邏輯跑通後,先配置單庫驗證訂單數據的完整生命周期,無誤後再通過一點配置就切換至分庫分表環境了,且在開發過程中如表結果發生表數量表結構的變化可以隨意修改分表規則配置,不會引起業務代碼的改寫。
四、分庫分表平滑遷移組件化
如何將這個方案組件化,並且讓大家在接入的時候做到最少知道,不必關心組件自身原理和實現細節呢?
在談具體實現過程前,再給大家普及一些分庫分表中間件原理的基礎知識,瞭解這部分的同學可以跳過。
1)關鍵名稱解釋
-
分⽚鍵: ⽤於分⽚的欄位,是將資料庫(表)⽔平拆分的關鍵欄位。
-
邏輯表: 是指一組具有相同邏輯和數據結構表的總稱。
-
物理表: 與邏輯表對應,一個 order_form 可以被拆成多個物理表。
-
分片策略: 分⽚鍵 + 分⽚演算法, 分片策略是 sql 進行路由的依據。
2)分庫分表中間件的基本原理
當我們執行一條 sql 的時候,分庫分表中間件會對這條 sql 進行分析,根據配置的分片策略將數據路由到對應的物理表,具體過程如下圖。
一般的分庫分表中間件都是在 Datasource 層面做資料庫的路由,內部一般維護一個 dataSourceMap 的對象,key 就是分庫時的分片鍵; 在 connection 或 statement 上做分表的路由,在 Resultset 上做結果數據的 merge。
1)設計定位
本著不重覆造輪子的原則,我們基礎的分庫分表能力還是藉助現有的分庫分表中間件,我們要做的是輔助分庫分表中間件適配更多的 sql 場景和做好 sql 分發,所以我們定位在分庫分表中間件上層做 plus。
2)設定切入點
組件化要求儘量做到對業務透明,為了滿足這一要求就要從現有數據層中找核心概念(介面)進行擴展,我們來看看數據層一些核心概念及其所屬的位置。
一般分庫分表中間件都是從 DataSource 或 connection 之後開始做擴展的, 由於我們的系統中固定使用了 Spring 和 MyBatis,所以我們可以從 Spring 和 Mybatis 開始做擴展,這樣雙寫或多寫邏輯就可以在 mybatis 外部進行,實現上更容易,且不用改變 mybatis 內部的預設邏輯,沒有 mybatis 本身升級所帶來的相容風險,同時可以對 mybatis 做一次增強,例如根據用戶配置的分片鍵預設生成一批常見的 sql 映射到 BaseMapper 中,減少業務研發日常編寫代碼工作量(關於對 mybatis 增強這塊不在本文討論的範圍,有興趣的同學可以去看 mybatis-plus 的原碼,原理是相同的)。
3)路由引導
是指對分庫分表中間件分發 sql 的過程進行引導,使其按期望的過程進行,具體來說就在邏輯表轉化成物理表的過程中指定轉換的範圍。根據前面介紹的分庫表分中間件的原理,這個 api 就算中間件不提供也可以自己適配一個出來,通常可以通過自定義分片策略來造出來。去哪兒的分庫分表中間件 qdb 直接提供了路由干預的 api,或者說是手動路由 api。
4)sql路由
sql 路由是實現整個分庫分表增強的核心,在執行流程到達分庫分表中間件之前先通過我們的組件進 sql 路由,具體路由過程見下圖所示:
註意該圖路由範圍僅畫出分庫分表中間件之上的部分,分庫分表中間件內部如何路由對我們來說是透明的, 也就是說我們是可以按需更換分庫分表中間件的。
-
流程解讀:
對於走分庫還是非分庫是在最開始的時候由用戶配置來決定,如果用戶配置中有分庫分表中間件,走分庫分表邏輯;如果是單庫則走單庫邏輯;如果分表庫和非分表都有則兩個各配置配置一個 SessionFactory,分庫的 SessionFactory 管理的表走分表庫邏輯,單庫的 SessionFactory 管理的表走單庫邏輯;這一點與輔營交易現有的 SessionFactory 的分工是不同的,輔營交易現有分庫分表上實現上,主表庫的 SessionFactory 還管理著分表庫的表。
值得說明的一點的是,在我們的設計里不強調全局表和廣播表的概念,取之以單獨的主庫表替代,這種方式經營成本更低,缺點是表分別位於分表庫和主表庫中,無法進行 join 查詢。事實上,我們在劃分表空間時,根據 DDD 結果也會儘量將同一個業務領域的表劃分到一起,以便其可以進行 join 查詢;所以一般不會出來主表庫要和分表庫進行 join 的場景。
無論是分表還是單表,執行流程都會進入 SqlRouteInterceptor (mybatis的插件),都會進行路由干預,因為主表庫至少也是有讀寫分離控制要求的嘛。
是否進行路由干預是由有無映射表邏輯或業務層面調用路由干預 api 來進行判定的,如果沒有那麼直接走後面邏輯即可,如果有,則對 sql 類型和條件進行判定,對於有複雜查詢條件的 sql 查詢可以走 ES 和數據組寬表查詢的介面(初版沒有開發這個功能,後續可按情況添加)。
對於有映射表邏輯的 sql 操作,先從映射表中找出分表鍵,然後再能通過分庫分表中間的路由引導 api 來指導分庫分表中間的執行。
總體來說一共分為三層。
-
接入層:負責給應用接入提供穩定和相容的介面, 其中的 spring 接入適配是在項目穩定後再視情況開發,一般是在 spring 環境上提供一些註解和 starter;
-
core 層: 路由邏輯的核心實現,並基於路由邏輯建立生命周期,提供插件化的擴展點,使外圍功能可以以插件的方式開發;
-
存儲層: 負責最終 sql 的執行,數據的最後落地, 與接入層配合實現事務,主要由數據源和 mybatis 組成。
在實際編碼實現過程中將 core 層和存儲層放在一個工程 qmall_db_core 中, 將接入層單獨放入另外一個工程 qmall_db_shell; 處於接入 層的 api 都會在後續版本升級過程中保證向下相容。下麵對核心部分實現和接入部分實現加以說明,存儲部分的實現主要是對 mybatis 做的增強,不在本文討論範圍內。
1) 核心代碼流程
以 SqlRouterInterceptor(mybatis插件)為 sql 進入路由的入口, QmallDataSourceSupport 為參數處理的入口。SqlRouteProcessor 用於組織協調各組件進行路由干預。SqlRouteProcessor 隔離了對 mybatis 的依賴(也就是它之後的調用不依賴於 mybatis),彙集了巨集觀路由流程。其主要流程如下:
-
調用語法分析行到 SqlInfo, SqlInfo 中包含了後續 sql 路由分析的所有數據結構,如sql中含有的查詢條件、sql中關係到的表和列、sql的類型等;
-
調用參數處理,將 DAO 中傳遞的參數填充到 SqlInfo 結構中, 以使後續流程可以很方便的找到列或條件對應的實參值;
-
根據 SqlInfo 的內容選擇合適的路由策略 RouteStrategy,選擇的路由策略過程就是匹配得分最高的一個策略,比如有兩個讀的策略,一個是按分片鍵路由,一個是按映射鍵路由,當 sql 條件中有分片鍵時會優先命中分片鍵路由,而沒有分片鍵的時候將命中映射鍵路由,路由也可以定製化,當對於某個特殊的 sql 想走 es 索引時可以針對這個 sql 的 Dao 名加方法特定命中一個走 es 索引的路由;
-
路由策略根據操作的類型來組織路由規則,對於寫是所有路由規則都執行,對於讀只要一個規則判定成功就返回,這個寫的路由規則通常就是維護映射鍵的映射表數據,而讀的路由規則是從多個映射鍵中選擇一個可行的映射規則找到映射鍵的值,然後通過映射鍵的值找到分表鍵的值,通過分表鍵加分片策略算出待查數據的物理坐標;
-
根據計算出物理坐標,調用分庫分表的路由中間件的引導 api 來執行路由引導,這個引導 api 就是圖中定義的 SqlRouteGuide 介面,不同的分庫分表中間件可以對這個介面進行實現來完成與本組件的基礎能力對接。(完整對接還要有配置適配上的對接)
2)關鍵點
-
語法分析與參數填充的實現
語法分析主要藉助 druid 的語法分析工具對 sql 進行解析並提取出期望的數據結構。參數填充這裡使用了一些技巧,應用在接入本組件設置數據源的時候顯式或隱式的將這個數據源包裝成 QmallDataSourceSupport 的子類實現,通過 QmallDataSourceSupport 來獲取的 connection 是一個被包裝後的 ConnectionSqlParameterSupport 實例,這個實例在執行 prepareStatement 方法的時候返回的是我們通過動態代理 PreparedStatement 介面的實例,其實際調用過程是通過實現了 InvocationHandler 介面的 PreparedParameterSupport 類完成, PreparedParameterSupport 類的作用是前面的 PreparedStatement 實例在調用各種 set 方法時記錄下當時的參數的位置 ,這個位置與占用符的位置剛好是一一對應的(sql 語法解釋出來的內容順序要與 sql 字元串中占位符的順序一致),所以可以非常準確的將參數回填時 SqlInfo 這個結構中。
有人可能要問,為什麼不直接分析 mybatis 內部的那個參數結構呢?這個試過但有各種坑,mybatis 在 DAO 的參數處理過程中它自己會做一些處理,map 中有值相同 key 不同的重覆內容且從那個 map 獲取不到 key 也會拋異常,另外我們的設計也不太想和 mybatis 內部數據結構有耦合,否則若 mybatis 升級把這個數據結構改動了我們這個系統不就用不了啦。其實,還有一種實現就是使用自定義 mybatis 的 ParameterHandler,這個實現方式我們也做過,兩相比較,還是包裝代理 Connection 的方式更好,因 Connection 是可以執行流程中被很容易拿到的,附帶一些功能很方便,而且與 mybatis 沒有任何關係,就算不用 mybatis 用 springjdbc 這套方案仍然是有效的。
-
路由策略RouteStartegy、路由規則Rule、分片策略間的關係
路由策略是由一組路由規則組成的,選擇不同的路由策略就像選擇不同的資料庫索引。路由規則是決定數據如何路由的原子單元,比如一個映射鍵可以構建一個路由規則,多個映射鍵就構建多個路由規則,一條sql中是可能包括多個映射鍵的,它就有多個路由規則,這多個路由規則共同構成一個路由策略。對於寫邏輯多個映射關係都需要維護,所以寫邏輯的路由規則必須都生效;對於讀邏輯只需要通過一個映射鍵到找了分表鍵,後面的映射鍵的路由規則就不需要執行了,所以讀的時候只需要一個路由規則有效即可。路由規則是由配置的分庫分表規則動態生成的,分庫分表規則使用到了不同的分片策略。
-
配置定義
我們來看一下分庫分表規則在我們這個組件中如何定義的?
在項目的 resource 目錄下放置一個 sharding.properties 的配置文件,內容如下:
#庫的首碼(這麼做完全是為了照顧qdb的配置)
db.prefix=qmall_supply_
#分庫配置
db.index.qmall.flight={dbIndex: 0}
db.index.qmall.inter={dbIndex: 1}
db.index.qmall.ticket={dbIndex: 2}
db.index.qmall.hermes={dbIndex: 3}
#分表鍵配置
sharding.user_info=[{shardingKey: 'last_name',intervalMonth:2,hashCount:0,startTime: '2020-11-01'},{shardingKey: 'last_name',intervalMonth:2,hashCount:2,startTime: '2024-07-01'},{shardingKey: 'last_name',intervalMonth:2,hashCount:1,startTime: '2021-07-01'},{shardingKey: 'last_name',intervalMonth:1,hashCount:2,startTime: '2022-07-01'}]
sharding.supply_order=[{shardingKey: 'supply_order_id',intervalMonth:1,hashCount:2,startTime: '2022-11-01', hashGroupReg: '20[0-9]{2}(0[1-9]|1[0-2])[0-9]{6}'}]
sharding.supply_order_ext=[{shardingKey: 'supply_order_id',intervalMonth:1,hashCount:2,startTime: '2022-11-01'}]
#分表鍵日期提取正則(視情況可選)
shardingKey.extract.date.pattern={supply_order_id: '20[0-9]{2}(0[1-9]|1[0-2])'}
#映射鍵配置, priority越大,優先順序越高, priority不可出現相同的值
table.supply_order=[{mapKey: 'business_order_id', type: 'one2many', priority: 1, maintain: 'auto_manual'}]
table.user_info=[{mapKey: 'id', type: 'one2one', priority: 1},{mapKey: 'phone', type: 'one2one', priority: 1, maintain: 'auto_manual'}]
從這個配置文件中我們可以看到,分庫配置按我們內容業務線,分表配置是由分表鍵配置和映射鍵配置組成,分表鍵的分片演算法配置項中目前只按我們自身業務需要支持的 hash 分片和時間分片,這兩者可以同時使用。不知道讀者有沒有註意對於同一張表,我們可以有多條分片規則配置,它們主要是 startTime 不同,startTime 的含義是本條分片配置生效的起始時間,其作用時間範圍直至出現下一個大於當前的 startTime,下一個 startTime 不出現則表示當條規則持續生效。一張表只有分表鍵配置存在時,映射鍵配置才有意義。正因為配置存在這樣一些特點,我們可以通過配置平滑的把一個單表變成一個分表。比如,要將 supply_order 表從單表切換成分表,只需按下麵進行分表配置即可
sharding.supply_order=[{shardingKey: 'supply_order_id',intervalMonth:0,hashCount:1,startTime: '2000-01-01'},{shardingKey: 'supply_order_id',intervalMonth:1,hashCount:2,startTime: '2023-11-01', hashGroupReg: '20[0-9]{2}(0[1-9]|1[0-2])[0-9]{6}'}]
第一條配置作用時間是2000-01-01至2023-11-01,這期間是沒有分表的;第二條配置作用從2023-11-01開始,每間隔一個月hash分兩張表;這樣就相當於業務無感知的從單表過渡到分表了。對於單庫和分庫的切換則使用的是多環境打包完成的,不同環境激活的是不同的數據源,單庫激活的是單庫的資料庫連接池,多庫激活多庫的連接池。
sharding.properties 這個配置文件也可以在不同環境中存在不同的內容;這樣就可以很方便的做到本地測試用單庫,測試和線上環境使用分庫分表了。註意要將不同環境的分表鍵及映射鍵的規則定義一致,這樣在單庫上能跑通的 sql 在分庫分表環境上也不會有任何問題(因為只要分表的配置規則相同,即使底層數據源是單庫或者沒有用分庫分表中間件,我們內部的那些路由判定規則一樣會執行,不符合要求的 sql 是能暴露出來的)。
那麼可能有人要問,你這裡定義的分庫分表規則,分庫分表中間件里也定義了規則,兩者有衝突怎麼處理?
答案是沒衝突。在決定開發配置的時候,我們就思考:
-
新的組件是對已有分庫分表中間件做 plus,所以必然會有一些新的配置需求,這些新的配置需求最好能很直觀的與已有分庫分表配置關聯;
-
不要對已有分庫分表中間件的配置文件做修改或對其有代碼侵入,否則會增加用戶的學習成本,而且從長期來看也會形成耦合,一旦分庫分表中間件有大版本升級就不方便跟進了。
為此,我們做了兩件事:
-
我們的組件不直接依賴任何底層數據源或分庫分表的配置,只依賴 sharding.properties,數據源按標準介面接入即可。
-
分庫分表中間件的配置統一使用自定義分片策略配置,由我們的組件根據不同的分庫分表中間件的自定義分片策略介面來實現具體的分片,然後在分庫分表中間件的配置文件中只配置我們自己的分片策略。
第一件事是劃清了本組件與分庫分表中間件的邊界,即雙方只按標準介面對接;第二件事相當於是讓分庫分表中件間通過自定義策略的方式將它的分庫分表規則委托給我們的組件,從而避免兩頭配置上的衝突,也就是最終如何分庫分表將以我們的配置解釋為準。
接入層分為四部分,限於篇幅,這裡簡單說明一下。
-
基礎介面
自定義 DataSouce 和自定義 SessionFactoryBean。自定義 DataSouce 主要作用就是將對外部傳入 DataSouce 進行包裝,它的功能包括識別是否分庫數據源、讀寫分離、多數源事務關聯。SessionFactoryBean 的主要功能是組建初始化入口、自動生成分表、掃描 mybatis 的 mapper 文件初始化 mapper 實例。
-
數據介面
主要有兩個,一個是 DAOTemplate,模仿的是 JDBCTemplate, 與其不同的是它能很方便在分庫分表環境下寫各種臨時 sql,適合測試場景寫一條只在測試時才用的 sql 或一次性 sql;另一個是 BaseMapper, 它自帶基礎的增刪改查功能,業務的 Mapper 繼承於它可以省寫很多常見的操作。
-
路由介面
提供了手動指定路由過程的介面,如使用 SqlRouteHelper.runOnSpecificContext (SqlRouteCondition condition, Runnable runnable), 則 runnable 的運行過程中其內部的 sql 路由將受 condition 的影響,condition 的內容為是否走從庫、走哪個物理庫、哪些邏輯表、哪些物理表,這四方面內容可部分指定也可全部指定;除此之外,還提供了一些用於排查路由問題或數據問題的靜態方法,比如通過映射鍵找分片鍵,通過映射鍵的值獲取 db 索引等。
-
事務介面
事務介面是對 DataSourceTransactionManager 與 TransactionTemplate 的繼承,用於實現上文所提到的“分散式”事務。
總結
本文介紹了兩次進行平滑分庫分表的設計,第一次是在已經運行多年的系統上進行分庫分表改造,這個過程為了求穩,主要採用了雙寫加 diff 的方式通過一條條的 sql 切換來降低升級過程中的風險,同時對於不支持分表鍵查詢的 sql 採用了映射鍵和映射表的方式解決。
第二次是在從舊系統拆分出新系統過程中,新系統也有分庫分表需求,為了照顧新系統的易用性以及初始編碼過程中可能出現的變化,減少底層分庫分表的變化對上層業務編碼的返工,在承接第一次方案的設計的基礎上將方案進行了組件化。基於開發成本和開發時間的考慮,目前產出儘管不通用,但他完成了我們當時的首要目標——完成業務應用的 DDD 和微服務拆分。
通過本文介紹,我相信讀者應該看到一種可能,那就是從單庫單表系統至分庫分表的系統的平滑過度是可能被中間件解決的。本文的產出是專用的,但思想通用的,隨著實際場景的複雜,可能還會遇到更多問題,這需要我們開發者的更多的努力。
本文來自博客園,作者:古道輕風,轉載請註明原文鏈接:https://www.cnblogs.com/88223100/p/smoothly-upgraded-to-sub-databases-and-tables-in-this-way.html