天天看點

應用不停服,平滑更新分庫分表還能這樣做

作者:dbaplus社群

背景

分庫分表是大型網際網路應用經常采用的一種資料層優化方案,常見的分庫分表中間件如 sharding-jdbc、mycat 都已經比較成熟,基本上可以應對我們一般的分庫分表需求。

做過分庫分表的同學應該知道,在給業務系統做分庫分表改造過程中,難的不是如何使用這些元件進行分庫分表,而是如何将非分庫分表的系統平滑的更新成一個分庫分表的系統,更新期間業務不可暫停,更新過程及更新後風險可控,這個過程就像是給飛行中的飛機更換引擎,處理不好會産生重大的業務事故。

去哪兒網機票輔營業務就經曆過從主從讀寫分離系統更新到分庫分表系統的過程,并在多次疊代過程中形成了一種與業務輕相關的平滑的分庫分表方案,後續業務更新分庫分表隻需通過配置切換就可以将單庫單表系統瞬切至分庫分表系統。

一、原始問題

去哪兒網有自研的分庫分表中間件 qdb,是基于資料源進行分庫分表的,它和那些開源的分庫分表中間件一樣,隻解決了如何進行分庫分表問題,沒有解決如何将一個非分庫分表系統更新至分庫分表系統過度的問題。如果我們直接使用 qdb 進行分庫分表,不做任何過度方案,那麼将有以下問題:

  • 更新過程中如果出現部分資料錯誤,如何復原?如做復原,新資料可能在復原前落入新庫,復原後落入舊庫,一部分資料在使用者層面将看不到;如果錯誤隻出現一次還好,可以通過洗數解決;但如果更新過程反複發現 bug,反複修訂,一定會對業務造成影響;
  • 遷移至分庫分表後,為了保證資料被查詢到且保證查詢的性能,一般情況下 sql 的查詢條件需要帶上分表(片)鍵,但一個已經運轉多年的業務系統它的 sql 肯定不能完全滿足這個要求,如果進行全量的 sql 改寫将是一個巨大的工作量,且有些業務場景根本就無法進行 sql 改寫,比如輔營交易系統表的分表鍵一般是自身業務的訂單号,但它有根據第三方券碼查訂單的客觀需求( 一般是三方回調接口中)。
  • 如何确定分庫分表後的系統資料業務等價于分庫分表前的系統。

解決了這三個問題也就能順利的從單庫單表遷移至分庫分表了。

二、第一次平滑遷移至分庫分表的實踐

簡單來說第一次進行分庫分表的平滑更新,其主要思路是:

  • 對資料進行雙寫;在分表鍵的基本之上增加了分表鍵映射的概念,通過 sql 條件分析自動或手動路由控制資料讀寫單庫單表或分庫分表;
  • 再通過一種特殊的事務來實作的兩套系統的一緻性;
  • 通過 iff 來确定兩套資料庫系統資料是等價的。

這3個點分别對應解決上述的三個問題。

1.前置知識

為了友善了解後續内容,有必要對 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 執行價段進行攔截。

2.資料雙寫

更新期間繼續保留的單庫單表資料庫,同時按新的規則建立分庫分表。在輔營業務系統中,分庫是以業務線為依據的,基本按照一個業務線一個庫來劃分,分表是以輔營系統自身的訂單号為依據,以月為機關進行劃分的(單号中含日期資訊)。

所謂資料雙寫,就是當業務需要進行資料進行增删改的時候,同時對兩套資料庫進行增删改;當業務需要讀資料的時候,隻需對一套資料庫進行讀即可。當我們确定在所有情況下,對分表進行讀寫與對單表進行讀寫是等效的時候,我們就可以下線單表那套資料源了。

這個雙寫的過程不是由業務代碼來完成的,而是通過 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 插件内部做了雙寫的實作。

3.分表鍵映射

通常不含分表鍵條件的 sql 來查詢資料是不可避免的,一般的分庫分表中間件對這種 sql 都是進行全表廣播,其性能自然不太理想。為了解決非分表鍵全表廣播的問題,我們提出了映射鍵的概念,映射鍵是相對分表鍵而言的,在 sql 查詢中如不含分表鍵,就找映射鍵,再通過映射鍵到分表鍵,然後根據分表鍵計算出分表的實體坐标,最後通過分庫分表中間件 sql 路由引導的 api (這裡的概念如果不了解後面有解釋)來引導分庫分表中間件完成查詢。映射鍵的映射關系維護在獨立的映射表中,這個映射自身也是分表的,分表規則就是映射鍵的值的 hash。

舉個例子,比如通過券碼(couponId)查訂單,那麼這裡的券碼(couponId)就是映射鍵, 券碼(couponId)的具體的值就是映射鍵的值,輔營自身的訂單号(orderId)就是分表鍵,couponId→orderId 就形成了一個映射, 我們在 sql 查詢中就可以隻包含 couponId。除了這種直接映射外,還有一個間接映射,在輔營系統中,表面是按訂單号進行分表的,本質是按訂單号中的時間條件進行分表的,在上下文已知業務線的情況下,如果查詢條件中包含訂單号的建立時間,那麼就算不含分表鍵和映射鍵也是可以對實體表坐标進行定位的,進而減少 sql 全表廣播的可能。

sql 執行過程可以抽象如下圖:

應用不停服,平滑更新分庫分表還能這樣做

映射鍵思想的提出使得我們不用改寫所有的 sql,大大提高了分庫分表的适用範圍。在實際開發過程中,還有一種 sql 也是無法改寫的,那就是全表資料掃苗,比如我們要定期掃苗待過期的券碼,在分庫分表環境下應該怎麼做呢?

這裡我們通過提供了一種手工定位和疊代所有實體庫和實體表的 api,把形如 selectAll 的查詢需求轉化為對實體庫和實體表下資料的分批通路,使用者通過設定回調函數來處理每一批資料。

4.diff和事務

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.前置知識

1)關鍵名稱解釋

  • 分⽚鍵: ⽤于分⽚的字段,是将資料庫(表)⽔平拆分的關鍵字段。
  • 邏輯表: 是指一組具有相同邏輯和資料結構表的總稱。
  • 實體表: 與邏輯表對應,一個 order_form 可以被拆成多個實體表。
  • 分片政策: 分⽚鍵 + 分⽚算法, 分片政策是 sql 進行路由的依據。

2)分庫分表中間件的基本原理

當我們執行一條 sql 的時候,分庫分表中間件會對這條 sql 進行分析,根據配置的分片政策将資料路由到對應的實體表,具體過程如下圖。

應用不停服,平滑更新分庫分表還能這樣做

一般的分庫分表中間件都是在 Datasource 層面做資料庫的路由,内部一般維護一個 dataSourceMap 的對象,key 就是分庫時的分片鍵; 在 connection 或 statement 上做分表的路由,在 Resultset 上做結果資料的 merge。

2.設計思路

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 來指導分庫分表中間的執行。

3.整體架構

應用不停服,平滑更新分庫分表還能這樣做

總體來說一共分為三層。

  • 接入層:負責給應用接入提供穩定和相容的接口, 其中的 spring 接入适配是在項目穩定後再視情況開發,一般是在 spring 環境上提供一些注解和 starter;
  • core 層: 路由邏輯的核心實作,并基于路由邏輯建立生命周期,提供插件化的擴充點,使外圍功能可以以插件的方式開發;
  • 存儲層: 負責最終 sql 的執行,資料的最後落地, 與接入層配合實作事務,主要由資料源和 mybatis 組成。

在實際編碼實作過程中将 core 層和存儲層放在一個工程 qmall_db_core 中, 将接入層單獨放入另外一個工程 qmall_db_shell; 處于接入 層的 api 都會在後續版本更新過程中保證向下相容。下面對核心部分實作和接入部分實作加以說明,存儲部分的實作主要是對 mybatis 做的增強,不在本文讨論範圍内。

4.核心實作

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,資料源按标準接口接入即可。
  • 分庫分表中間件的配置統一使用自定義分片政策配置,由我們的元件根據不同的分庫分表中間件的自定義分片政策接口來實作具體的分片,然後在分庫分表中間件的配置檔案中隻配置我們自己的分片政策。

第一件事是劃清了本元件與分庫分表中間件的邊界,即雙方隻按标準接口對接;第二件事相當于是讓分庫分表中件間通過自定義政策的方式将它的分庫分表規則委托給我們的元件,進而避免兩頭配置上的沖突,也就是最終如何分庫分表将以我們的配置解釋為準。

5.接入層實作

接入層分為四部分,限于篇幅,這裡簡單說明一下。

  • 基礎接口

自定義 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 和微服務拆分。

通過本文介紹,我相信讀者應該看到一種可能,那就是從單庫單表系統至分庫分表的系統的平滑過度是可能被中間件解決的。本文的産出是專用的,但思想通用的,随着實際場景的複雜,可能還會遇到更多問題,這需要我們開發者的更多的努力。

作者丨陳力

來源丨公衆号:Qunar技術沙龍(ID:QunarTL)

dbaplus社群歡迎廣大技術人員投稿,投稿郵箱:[email protected]

關于我們

dbaplus社群是圍繞Database、BigData、AIOps的企業級專業社群。資深大咖、技術幹貨,每天精品原創文章推送,每周線上技術分享,每月線下技術沙龍,每季度Gdevops&DAMS行業大會。

關注公衆号【dbaplus社群】,擷取更多原創技術文章和精選工具下載下傳

繼續閱讀