開發與維運

淺談分庫分表那些事兒

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#integer_division
[5]https://github.com/alibaba/tb_tddl

Leave a Reply

Your email address will not be published. Required fields are marked *