
本文适合閱讀群衆:需要從單庫單表改造為多庫多表的新手。
本文主要闡述在分庫分表改造過程中需要考慮的因素以及對應的解法,還有踩過的那些坑。
一 前言
我們既然要做分庫分表,那總要有個做事的動機。那麼,在動手之前,首先就要弄明白下面兩個問題。
1 什麼是分庫分表?
其實就是字面意思,很好了解:
- 分庫:從單個資料庫拆分成多個資料庫的過程,将資料散落在多個資料庫中。
- 分表:從單張表拆分成多張表的過程,将資料散落在多張表内。
2 為什麼要分庫分表?
關鍵字:提升性能、增加可用性。
從性能上看
随着單庫中的資料量越來越大、資料庫的查詢QPS越來越高,相應的,對資料庫的讀寫所需要的時間也越來越多。資料庫的讀寫性能可能會成為業務發展的瓶頸。對應的,就需要做資料庫性能方面的優化。本文中我們隻讨論資料庫層面的優化,不讨論緩存等應用層優化的手段。
如果資料庫的查詢QPS過高,就需要考慮拆庫,通過分庫來分擔單個資料庫的連接配接壓力。比如,如果查詢QPS為3500,假設單庫可以支撐1000個連接配接數的話,那麼就可以考慮拆分成4個庫,來分散查詢連接配接壓力。
如果單表資料量過大,當資料量超過一定量級後,無論是對于資料查詢還是資料更新,在經過索引優化等純資料庫層面的傳統優化手段之後,還是可能存在性能問題。這是量變産生了質變,這時候就需要去換個思路來解決問題,比如:從資料生産源頭、資料處理源頭來解決問題,既然資料量很大,那我們就來個分而治之,化整為零。這就産生了分表,把資料按照一定的規則拆分成多張表,來解決單表環境下無法解決的存取性能問題。
從可用性上看
單個資料庫如果發生意外,很可能會丢失所有資料。尤其是雲時代,很多資料庫都跑在虛拟機上,如果虛拟機/主控端發生意外,則可能造成無法挽回的損失。是以,除了傳統的 Master-Slave、Master-Master 等部署層面解決可靠性問題外,我們也可以考慮從資料拆分層面解決此問題。
此處我們以資料庫當機為例:
- 單庫部署情況下,如果資料庫當機,那麼故障影響就是100%,而且恢複可能耗時很長。
- 如果我們拆分成2個庫,分别部署在不同的機器上,此時其中1個庫當機,那麼故障影響就是50%,還有50%的資料可以繼續服務。
- 如果我們拆分成4個庫,分别部署在不同的機器上,此時其中1個庫當機,那麼故障影響就是25%,還有75%的資料可以繼續服務,恢複耗時也會很短。
當然,我們也不能無限制的拆庫,這也是犧牲存儲資源來提升性能、可用性的方式,畢竟資源總是有限的。
二 如何分庫分表
1 分庫?分表?還是既分庫又分表?
從第一部分了解到的資訊來看,分庫分表方案可以分為下面3種:
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 分庫分表後,如何讓資料均勻散落在各個分庫分表内?
比如,當熱點事件出現後,怎麼避免熱點資料集中存取到某個特定庫/表,造成各分庫分表讀寫壓力不均的問題。
其實,細思之下可以發現這個問題其實跟負載均衡的問題很相似,是以,我們可以去借鑒下負載均衡的解法來解決。我們常見的負責均衡算法如下:
我們的選擇:基于 一緻性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段就會是下面這個樣子:
我們的問題單庫采用的就是這種先對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》:
四 分庫分表文中案例圖示
參考資料
[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