淺談分庫分表那些事兒

2021-03-09 12:01:26
簡介:本文主要闡述在分庫分表改造過程中需要考慮的因素以及對應的解法,還有踩過的那些坑。

image.png

本文適合閱讀群眾:需要從單庫單表改造為多庫多表的新手。

本文主要闡述在分庫分表改造過程中需要考慮的因素以及對應的解法,還有踩過的那些坑。

一 前言

我們既然要做分庫分表,那總要有個做事的動機。那麼,在動手之前,首先就要弄明白下面兩個問題。

1 什麼是分庫分表?

其實就是字面意思,很好理解:

  • 分庫:從單個資料庫拆分成多個資料庫的過程,將資料散落在多個資料庫中。
  • 分表:從單張表拆分成多張表的過程,將資料散落在多張表內。

2 為什麼要分庫分表?

關鍵字:提升效能、增加可用性。

從效能上看

隨著單庫中的資料量越來越大、資料庫的查詢QPS越來越高,相應的,對資料庫的讀寫所需要的時間也越來越多。資料庫的讀寫效能可能會成為業務發展的瓶頸。對應的,就需要做資料庫效能方面的優化。本文中我們只討論資料庫層面的優化,不討論快取等應用層優化的手段。

如果資料庫的查詢QPS過高,就需要考慮拆庫,通過分庫來分擔單個資料庫的連線壓力。比如,如果查詢QPS為3500,假設單庫可以支撐1000個連線數的話,那麼就可以考慮拆分成4個庫,來分散查詢連線壓力。

如果單表資料量過大,當資料量超過一定量級後,無論是對於資料查詢還是資料更新,在經過索引優化等純資料庫層面的傳統優化手段之後,還是可能存在效能問題。這是量變產生了質變,這時候就需要去換個思路來解決問題,比如:從資料生產源頭、資料處理源頭來解決問題,既然資料量很大,那我們就來個分而治之,化整為零。這就產生了分表,把資料按照一定的規則拆分成多張表,來解決單表環境下無法解決的存取效能問題。

從可用性上看

單個資料庫如果發生意外,很可能會丟失所有資料。尤其是雲時代,很多資料庫都跑在虛擬機器器上,如果虛擬機器器/宿主機發生意外,則可能造成無法挽回的損失。因此,除了傳統的 Master-Slave、Master-Master 等部署層面解決可靠性問題外,我們也可以考慮從資料拆分層面解決此問題。

此處我們以資料庫宕機為例:

  • 單庫部署情況下,如果資料庫宕機,那麼故障影響就是100%,而且恢復可能耗時很長。
  • 如果我們拆分成2個庫,分別部署在不同的機器上,此時其中1個庫宕機,那麼故障影響就是50%,還有50%的資料可以繼續服務。
  • 如果我們拆分成4個庫,分別部署在不同的機器上,此時其中1個庫宕機,那麼故障影響就是25%,還有75%的資料可以繼續服務,恢復耗時也會很短。

當然,我們也不能無限制的拆庫,這也是犧牲儲存資源來提升效能、可用性的方式,畢竟資源總是有限的。

二 如何分庫分表

1 分庫?分表?還是既分庫又分表?

從第一部分了解到的資訊來看,分庫分表方案可以分為下面3種:

image.png

2 如何選擇我們自己的切分方案?

如果需要分表,那麼分多少張表合適?

由於所有的技術都是為業務服務的,那麼,我們就先從資料方面回顧下業務背景。

比如,我們這個業務系統是為了解決會員的諮詢訴求,通過我們的XSpace客服平臺系統來服務會員,目前主要以同步的離線工單資料作為我們的資料來源來構建自己的資料。

假設,每一筆離線工單都會產生對應一筆會員的諮詢問題(我們簡稱:問題單),如果:

  • 線上渠道:每天產生 3w 筆聊天對談,假設,其中50%的對談會生成一筆離線工單,那麼每天可生成 3w * 50% = 1.5w 筆工單;
  • 熱線渠道:每天產生 2.5w 通電話,假設,其中80%的電話都會產生一筆工單,那麼每天可生成 2.5w * 80% = 2w 筆/天;
  • 離線渠道:假設離線渠道每天直接生成 3w 筆;

合計共 1.5w + 2w + 3w = 6.5w 筆/天

考慮到以後可能要繼續覆蓋的新的業務場景,需要提前預留部分擴充套件空間,這裡我們假設為每天產生 8w 筆問題單。

除問題單外,還有另外2張常用的業務表:使用者操作紀錄檔表、使用者提交的表單資料表。

其中,每筆問題單都會產生多條使用者操作紀錄檔,根據歷史統計資料來可以看到,平均每個問題單大約會產生8條操作紀錄檔,我們預留一部分空間,假設每個問題單平均產生約10條使用者操作紀錄檔。

如果系統設計使用年限5年,那麼問題單資料量大約 = 5年 365天/年 8w/天 = 1.46億,那麼估算出的表數量如下:

  • 問題單需要:1.46億/500w = 29.2 張表,我們就按 32 張表來切分;
  • 操作紀錄檔需要 :32 10 = 320 張表,我們就按 32 16 = 512 張表來切分。
如果需要分庫,那麼分多少庫合適?

分庫的時候除了要考慮平時的業務峰值讀寫QPS外,還要考慮到諸如雙11大促期間可能達到的峰值,需要提前做好預估。

根據我們的實際業務場景,問題單的資料查詢來源主要來自於阿里客服小蜜首頁。因此,可以根據歷史QPS、RT等資料評估,假設我們只需要3500資料庫連線數,如果單庫可以承擔最高1000個資料庫連線,那麼我們就可以拆分成4個庫。

3 如何對資料進行切分?

根據行業慣例,通常按照 水平切分、垂直切分 兩種方式進行切分,當然,有些複雜業務場景也可能選擇兩者結合的方式。

(1)水平切分

這是一種橫向按業務維度切分的方式,比如常見的按會員維度切分,根據一定的規則把不同的會員相關的資料散落在不同的庫表中。由於我們的業務場景決定都是從會員視角進行資料讀寫,所以,我們就選擇按照水平方式進行資料庫切分。

(2)垂直切分

垂直切分可以簡單理解為,把一張表的不同欄位拆分到不同的表中。

比如:假設有個小型電商業務,把一個訂單相關的商品資訊、買賣家資訊、支付資訊都放在一張大表裡。可以考慮通過垂直切分的方式,把商品資訊、買家資訊、賣家資訊、支付資訊都單獨拆分成獨立的表,並通過訂單號跟訂單基本資訊關聯起來。

也有一種情況,如果一張表有10個欄位,其中只有3個欄位需要頻繁修改,那麼就可以考慮把這3個欄位拆分到子表。避免在修改這3個資料時,影響到其餘7個欄位的查詢行鎖定。

三 分庫分表之後帶來的新問題

1 分庫分表後,如何讓資料均勻散落在各個分庫分表內?

比如,當熱點事件出現後,怎麼避免熱點資料集中存取到某個特定庫/表,造成各分庫分表讀寫壓力不均的問題。

其實,細思之下可以發現這個問題其實跟負載均衡的問題很相似,所以,我們可以去借鑑下負載均衡的解法來解決。我們常見的負責均衡演演算法如下:

image.png

我們的選擇:基於 一致性Hash演演算法 裁剪,相較於一致性Hash演演算法,我們裁剪後的演演算法
主要區別在以下幾個點:

(1)Hash環節點數量的不同

一致性Hash有2^32-1個節點,考慮到我們按照buyerId切分,而且buyerId基數本就很龐大,整體已經具備一定的均勻度,所以,我們把Hash環的數量降低到4096個;

(2)DB索引演演算法的不同

一致性Hash通過類似 hash(DB的IP) % 2^32 公式計算DB在Hash環的位置。如果DB數量較少,需要通過增加虛擬節點來解決Hash環偏斜問題,而且DB的位置可能會隨著IP的變動而變化,尤其是在雲環境下。

資料均勻分佈到Hash環的問題,經過之前的判斷,我們可以通過 Math.abs(buyerId.hashCode()) % 4096 計算定位到Hash環位置,那麼剩下的問題就是讓DB也均勻分佈到這個Hash環上即可。由於我們都是使用阿里的TDDL中介軟體,只需要通過邏輯上的分庫索引號定位DB,因此,我們把分庫DB均分到這個Hash環上即可,如果是hash環有4096個環節,拆分4庫的話,那麼4個庫分別位於第1、1025、2049、3073個節點上。分庫的索引定位可通過 (Math.abs(buyerId.hashCode()) % 4096) / (4096 / DB_COUNT) 這個公式計算得出。

分庫索引的 Java 虛擬碼實現如下:

/**
 * 分庫數量
 */
public static final int DB_COUNT = 4;

/**
 * 獲取資料庫分庫索引號
 *
 * @param buyerId 會員ID
 * @return
 */
public static int indexDbByBuyerId(Long buyerId) {
    return (Math.abs(buyerId.hashCode()) % 4096) / (4096 / DB_COUNT);
}

2 分庫分表環境下,如何解決分庫後主鍵ID的唯一性問題?

在單庫環境下,我們的問題單主表的ID採用的MySQL自增的方式。但是,分庫之後如果還繼續使用資料庫自增的方式,就很容易出現各門口的主鍵ID重複問題。

對於這種情況,有很多種解決方案,比如採用UUID的方式,不過UUID太長,查詢效能太差,佔用空間也大,而且主鍵的型別也變了,也不利於應用平滑遷移。

其實,我們也可以對ID繼續拆分,比如對ID進行分段,不同的庫表使用不同的ID段,但也會產生新的問題,這個ID段要多長才合適?如果ID段分配完了,那可能會佔用第二個庫的ID段,產生ID不唯一問題。

但是,如果我們讓所有的分庫使用的ID段按照等差數列進行分隔,每次ID段用完之後,再按照固定的步長比遞增的話,那是不是就可以解決這個問題了。

比如,像下面這樣,假設每次分配的ID間隔為1000,也就是步長1000,那麼每次分配的ID段起止索引則可以按照下面的公式計算得出:

  • 第X庫、第Y次分配的ID段起始索引就是:
X * 步長 + (Y-1) * (庫數量 * 步長)
  • 第X庫、第Y次分配的ID段結束索引就是:
X * 步長 + (Y-1) * (庫數量 * 步長) + (1000 -1)

如果是分4庫,那麼最終分配的ID段就會是下面這個樣子:

image.png

我們的問題單庫採用的就是這種先對ID分段,再按固定步長遞增的方式。這也是TDDL官方提供的解決方案。

除此之外,實際場景下,通常為了分析排查問題方便,往往會在ID中增加一些額外資訊,比如我們自己的問題單ID就包含了日期、版本、分庫索引等資訊。

問題單 ID 生成 Java 虛擬碼參考:

import lombok.Setter;
import org.apache.commons.lang3.time.DateFormatUtils;

/**
 * 問題單ID構建器
 * <p>
 * ID格式(18位元):6位日期 + 2位版本號 + 2位庫索引號 + 8位元序列號
 * 範例:180903010300001111
 * 說明這個問題單是2018年9月3號生成的,採用的01版本的ID生成規則,資料存放在03庫,最後8位元00001111是生成的序列號ID。* 採用這種ID格式還有個好處就是每天都有1億(8位元)的序列號可用。* </p>
 */
@Setter
public class ProblemOrdIdBuilder {
  public static final int DB_COUNT = 4;    
    private static final String DATE_FORMATTER = "yyMMdd";

    private String version = "01";
    private long buyerId;
    private long timeInMills;
    private long seqNum;

    public Long build() {
        int dbIndex = indexDbByBuyerId(buyerId);
        StringBuilder pid = new StringBuilder(18)
            .append(DateFormatUtils.format(timeInMills, DATE_FORMATTER))
            .append(version)
            .append(String.format("%02d", dbIndex))
            .append(String.format("%08d", seqNum % 10000000));
        return Long.valueOf(pid.toString());
    }

    /**
     * 獲取資料庫分庫索引號
     *
     * @param buyerId 會員ID
     * @return
     */
    public int indexDbByBuyerId(Long buyerId) {
        return (Math.abs(buyerId.hashCode()) % 4096) / (4096 / DB_COUNT);
    }
}

3 分庫分表環境下,事務問題怎麼解決?

由於分散式環境下,一個事務可能跨多個分庫,所以,處理起來相對複雜。目前常見的有2種解決方案:

(1)使用分散式事務

  • 優點:由應用伺服器/資料庫去管理事務,實現簡單
  • 缺點:效能代價較高,尤其是涉及到分庫數量較多時尤為明顯。而且,還依賴於一些特定的應用伺服器/資料庫提供的分散式事務實現方案。

(2)由應用程式+資料庫共同控制

  • 原理:大事化小,將多個大事務拆分成可由單個分庫處理的小事務,由應用程式去控制這些小事務。
  • 優點:效能良好,少了一個分散式事務協調處理層
  • 缺點:需要從應用程式自身上做事務控制的靈活設計。從業務應用上做處理,應該改造成本高。

針對上面2種分散式事務解決方案,我們該如何選擇?

首先,沒有萬能的解決方案,只有適合自己的方案。那就先看看我們的業務中,事務的使用場景有哪些吧。

無論是來諮詢問題的會員,還是為會員解決問題的客服小二,亦或者從第三方系統同步相關資料。主要有2個核心動作:

  • 以會員維度查詢相關進度資料,包含會員問題資料,以及對應的問題處理操作紀錄檔/進度資料;
  • 以會員視角提交相關憑證/反饋新情況等資料,或者是客服小二代會員提交這些資料。提交的資料也可能會決定問題是否解決(被完結)。

由於問題單資料、操作紀錄檔都是分開查詢,所以,不涉及分散式關聯查詢場景,這個可以忽略不考慮。

那麼就剩下使用者提交資料場景了,可能會同時寫入問題單以及操作紀錄檔資料。

既然使用場景確定了,那麼可以選擇事務解決方案了。雖然分散式事務實現簡單,但這個簡單是因為中介軟體幫我們解決了它本身的複雜性。複雜性越高,必然會帶來一定的效能損耗。而且,目前大部分應用都是基於 SpringBoot 開發,預設使用的都是內嵌 tomcat 容器,不像 IBM 提供的 WebSphere Application Server、Oracle 的 WebLogic 這些重量級應用伺服器,都提供了內建的分散式事務管理器。因此,如果我們要接入,必然要自己引入額外的分散式事務管理器,這個接入成本就更高了。所以,這種方案就暫不考慮了。那麼,就只能自己想辦法把大事務切分成單庫可以解決的小事務了。

所以,現在問題就成了,如何讓同一個會員的問題單資料和這個問題單相關的操作紀錄檔資料寫入到同一個分庫中。其實,解決方案也比較簡單,由於都是使用會員ID做切分,那麼使用相同的分庫路由規則即可。

最後,我來看下最終的 TDDL 分庫分表規則設定:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN//EN" "http://www.springframework.org/dtd/spring-beans.dtd">

<beans>
  <bean id="vtabroot" class="com.taobao.tddl.interact.rule.VirtualTableRoot" init-method="init">
    <property name="dbType" value="MYSQL" />
    <property name="defaultDbIndex" value="PROBLEM_0000_GROUP" />
    <property name="tableRules">
      <map>
        <entry key="problem_ord" value-ref="problem_ord" />
        <entry key="problem_operate_log" value-ref="problem_operate_log" />
      </map>
    </property>
  </bean>
  <!-- 問題(訴求)單表 -->
  <bean id="problem_ord" class="com.taobao.tddl.interact.rule.TableRule">
    <property name="dbNamePattern" value="PROBLEM_{0000}_GROUP" />
    <property name="tbNamePattern" value="problem_ord_{0000}" />
    <property name="dbRuleArray" value="((Math.abs(#buyer_id,1,4#.hashCode()) % 4096).intdiv(1024))" />
    <property name="tbRuleArray">
      <list>
        <value>
          <![CDATA[
            def hashCode = Math.abs(#buyer_id,1,32#.hashCode());
            int dbIndex = ((hashCode % 4096).intdiv(1024)) as int;
            int tableCountPerDb = 32 / 4;
            int tableIndexStart = dbIndex * tableCountPerDb;
            int tableIndexOffset = (hashCode % tableCountPerDb) as int;
            int tableIndex = tableIndexStart + tableIndexOffset;
            return tableIndex;
          ]]>
        </value>
      </list>
    </property>
    <property name="allowFullTableScan" value="false" />
  </bean>
  <!-- 問題操作紀錄檔表 -->
  <bean id="problem_operate_log" class="com.taobao.tddl.interact.rule.TableRule">
    <property name="dbNamePattern" value="PROBLEM_{0000}_GROUP" />
    <property name="tbNamePattern" value="problem_operate_log_{0000}" />
    <!-- 【#buyer_id,1,4#.hashCode()】 -->
    <!-- buyer_id 代表分片欄位;1代表分庫步長;4代表一共4個分庫,當執行全表掃描時會用到 -->
    <property name="dbRuleArray" value="((Math.abs(#buyer_id,1,4#.hashCode()) % 4096).intdiv(1024))" />
    <property name="tbRuleArray">
      <list>
        <value>
          <![CDATA[
            def hashCode = Math.abs(#buyer_id,1,512#.hashCode());
            int dbIndex = ((hashCode % 4096).intdiv(1024)) as int;
            int tableCountPerDb = 512 / 4;
            int tableIndexStart = dbIndex * tableCountPerDb;
            int tableIndexOffset = (hashCode % tableCountPerDb) as int;
            int tableIndex = tableIndexStart + tableIndexOffset;
            return tableIndex;
          ]]>
        </value>
      </list>
    </property>
    <property name="allowFullTableScan" value="false" />
  </bean>
</beans>

4 分庫分表後,歷史資料如何平滑遷移?

資料庫複製方案,阿里雲上面也開放了以前阿里內部使用的資料庫複製、遷移方案《資料傳輸服務(Data Transmission Service)》[1],詳情可諮詢阿里雲客服或者阿里雲資料庫專家。

分庫切換髮布流程可選擇停機、不停機發布兩種:

(1)如果選擇停機發布

  • 首先,要選擇一個夜黑風高、四處無人的夜晚。寒風刺骨能讓你清醒,四處無人,你好辦事打劫偷資料,我們就挑了個凌晨4點寂靜無人的時候做切換;如果可以,能臨時關閉業務存取入口最好。
  • 然後,在DTS上面新增一個全量的資料複製任務,把單庫的資料複製到新的分庫中(這個過程很快,千萬級資料應該10分左右就能搞定);
  • 之後,切換 TDDL 設定(單庫->分庫),並重新啟動應用,檢查是否生效。
  • 最後,開放業務存取入口,提供服務。

(2)如果選擇不停機發布話,流程會略微複雜點

  • 首先,同樣需要選擇一個夜黑風高的夜晚,來襯托你的帥氣。
  • 然後,通過DTS複製某個時間點前的資料,比如:今天前的歷史資料。
  • 之後,從單庫切換到分庫(最好是提前釋出好應用、準備好設定),這樣切換時只需要幾分鐘重新啟動生效即可。在切換到分庫前,聯絡DBA在切換期間停止老的單庫讀寫。
  • 最後,分庫切換完成後,再通過DTS增量複製老的單庫中今天凌晨之後產生的資料。
  • 最後的最後,持續觀察一段時間,如果沒問題,老的單庫就可以下線了。

5 TDDL設定分庫分表路由時的注意事項

由於阿里的TDDL中介軟體使用groovy指令碼計算分庫分表路由,而 groovy 的 / 運運算元 或者 /= 運運算元 可能會產生一個 double 型別的結果,並非像 Java 那樣得出一個整數,因此需要使用 x.intdiv(y) 函數做整除運算。

// 在 Java 中
System.out.println(5 / 3); // 結果 = 1

// 在 Groovy 中
println (5 / 3);       // 結果 = 1.6666666667          
println (5.intdiv(3)); // 結果 = 1(Groovy整除正確用法)

詳情可檢視 Groovy 官方說明 《The case of the division operator》:

image.png

四 分庫分表文中案例圖示

image.png

參考資料
[1] https://baijiahao.baidu.com/s?id=1622441635115622194&wfr=spider&for=pc
[2] http://www.zsythink.net/archives/1182
[3] https://www.aliyun.com/product/dts
[4] https://docs.groovy-lang.org/latest/html/documentation/core-syntax.html
[5] https://github.com/alibaba/tb_tddl

原文連結:https://developer.aliyun.com/article/782489?

版權宣告:本文內容由阿里雲實名註冊使用者自發貢獻,版權歸原作者所有,阿里雲開發者社群不擁有其著作權,亦不承擔相應法律責任。具體規則請檢視《阿里雲開發者社群使用者服務協定》和《阿里雲開發者社群智慧財產權保護指引》。如果您發現本社群中有涉嫌抄襲的內容,填寫侵權投訴表單進行舉報,一經查實,本社群將立刻刪除涉嫌侵權內容。