天天看點

從分庫分表後遺症,總結資料庫表拆分政策

本文将主要從背景、分庫分表帶來的後遺症、分表政策以及一些注意事項等方面對資料庫分表來進行小結。

一、背景

最近一段時間内結束了資料庫表拆分項目,本次拆分主要包括訂單和優惠券兩大塊,這兩塊都是覆寫全集團所有分子公司所有業務線。随着公司的業務飛速發展,不管是存儲的要求,還是寫入、讀取的性能都基本上到了警戒水位。

訂單是交易的核心,優惠券是營銷的核心,這兩塊基本上是整個平台的正向最核心部分。為了支援未來三到五年的快速發展,我們需要對資料進行拆分。

資料庫表拆分業内已經有很多成熟方案,已經不是什麼高深的技術,基本上是純工程化的流程,但是能有機會進行實際的操刀一把,機會還是難得,是以非常有必要做個總結。

由于分庫分表包含的技術選型和方式方法多種多樣,這篇文章不是羅列和彙總介紹各種方法,而是總結我們在實施分庫分表過程中的一些經驗。

根據業務場景判斷,我們主要是做水準拆分,做邏輯DB拆分,考慮到未來資料庫寫入瓶頸可以将一組Sharding表直接遷移進分庫中。

二、分庫、分表帶來的後遺症

分庫、分表會帶來很多的後遺症,會使整個系統架構變的複雜。分的好與不好最關鍵就是如何尋找那個Sharding key,如果這個Sharding key剛好是業務次元上的分界線就會直接提升性能和改善複雜度,否則就會有各種腳手架來支撐,系統也就會變得複雜。

比如訂單系統中的使用者__ID__、訂單__type__、商家__ID__、管道__ID__,優惠券系統中的批次__ID__、管道__ID__、機構__ID__ 等,這些都是潛在的Sharding key。

如果剛好有這麼一個Sharding key存在後面處理路由(routing)就會很友善,否則就需要一些大而全的索引表來處理OLAP的查詢。

一旦Sharding之後首先要面對的問題就是查詢時排序分頁問題。

1、歸并排序

原來在一個資料庫表中處理排序分頁是比較友善的,Sharding之後就會存在多個資料源,這裡我們将多個資料源統稱為分片。

想要實作多分片排序分頁就需要将各個片的資料都彙集起來進行排序,就需要用到歸并排序算法。這些資料在各個分片中可以做到有序的(輸出有序),但是整體上是無序的。

我們看個簡單的例子:

shard node 1: {1、3、5、7、9}
shard node 2: {2、4、6、8、10}
           

這是做奇偶Sharding 的兩個分片,我們假設分頁參數設定為每頁4條,目前第1頁,參數如下:

pageParameter:pageSize:4、currentPage:1
           

最樂觀情況下我們需要分别讀取兩個分片節點中的前兩條:

shard node 1: {1、3}
shard node 2: {2、4}
           

排序完剛好是{1、2、3、4},但是這種場景基本上不太可能出現,假設如下分片節點資料:

shard node 1: {7、9、11、13、15}
shard node 2: {2、4、6、8、10、12、14}
           

我們還是按照讀取每個節點前兩條肯定是錯誤的,因為最悲觀情況下(也是最真實的情況)就是排序完後所有的資料都來自一個分片。是以我們需要讀取每個節點的pageSize大小的資料出來才有可能保證資料的正确性。

這個例子隻是假設我們的查詢條件輸出的資料剛好是均等的,真實的情況一定是各種各樣的查詢條件篩選出來的資料集合,此時這個資料一定不是這樣的排列方式,最真實的就是最後者這種結構。

我們以此類推,如果我們的currentPage:1000,那麼會出現什麼問題?我們需要每個Sharding node讀取 4000(1000*4=4000) 條資料出來排序,因為最悲觀情況下有可能所有的資料均來自一個Sharding node 。

這樣無限制的翻頁下去,處理排序分頁的機器肯定會記憶體撐爆,就算不撐爆一定會觸發性能瓶頸。

這個簡單的例子用來說明分片之後,排序分頁帶來的現實問題,這也有助于我們了解分布式系統在做多節點排序分頁時為什麼有最大分頁限制。

2、深分頁性能問題

面對這種問題,我們需要改變查詢條件重新分頁。一個龐大的資料集會通過多種方式進行資料拆分,按機構、按時間、按管道等等,拆分在不同的資料源中。一般的深分頁問題我們可以通過改變查詢條件來平滑解決,但是這種方案并不能解決所有的業務場景。

比如,我們有一個訂單清單,從C端使用者來查詢自己的訂單清單資料量不會很大,但是營運背景系統可能面對全平台的所有訂單資料量,是以資料量會很大。

改變查詢條件有兩種:

第一種條件是顯示設定,盡量縮小查詢範圍,這種設定一般都會優先考慮如時間範圍、支付狀态、配送狀态等等,通過多個疊加條件就可以橫豎過濾出很小一部分資料集;

第二種條件為隐式設定,比如訂單清單通常是按照訂單建立時間來排序,那麼當翻頁到限制的條件時,我們可以改變這個時間。

Sharding node 1:orderID     createDateTime
100000      2018-01-10 10:10:10
200000      2018-01-10 10:10:11
300000      2018-01-10 10:10:12
400000      2018-01-10 10:10:13
500000      2018-01-20 10:10:10
600000      2018-01-20 10:10:11
700000      2018-01-20 10:10:12
Sharding node 2:orderID     createDateTime
110000      2018-01-11 10:10:10
220000      2018-01-11 10:10:11
320000      2018-01-11 10:10:12
420000      2018-01-11 10:10:13
520000      2018-01-21 10:10:10
620000      2018-01-21 10:10:11
720000      2018-01-21 10:10:12
           

我們假設上面是一個訂單清單,orderID訂單号大家就不要在意順序性了。因為Sharding之後所有的orderID都會由發号器統一發放,多個叢集多個消費者同時擷取,但是建立訂單的速度是不一樣的,是以順序性已經不存在了。

上面的兩個Sharding node基本上訂單号是交叉的,如果按照時間排序node 1和node 2是要交替擷取資料。

比如我們的查詢條件和分頁參數:

where createDateTime>'2018-01-11 00:00:00'
pageParameter:pageSize:5、currentPage:1
           

擷取的結果集為:

orderID     createDateTime
100000      2018-01-10 10:10:10
200000      2018-01-10 10:10:11
300000      2018-01-10 10:10:12
400000      2018-01-10 10:10:13
110000      2018-01-11 10:10:10
           

前面4條記錄來自node 1後面1條資料來自node 2 ,整個排序集合為:

Sharding node 1:orderID     createDateTime
100000      2018-01-10 10:10:10
200000      2018-01-10 10:10:11
300000      2018-01-10 10:10:12
400000      2018-01-10 10:10:13
500000      2018-01-20 10:10:10

Sharding node 2:orderID     createDateTime
110000      2018-01-11 10:10:10
220000      2018-01-11 10:10:11
320000      2018-01-11 10:10:12
420000      2018-01-11 10:10:13
520000      2018-01-21 10:10:10
           

按照這樣一直翻頁下去每翻頁一次就需要在node 1 、node 2多擷取5條資料。這裡我們可以通過修改查詢條件來讓整個翻頁變為重新查詢。

where createDateTime>'2018-01-11 10:10:13'

因為我們可以确定在‘2018-01-11 10:10:13’時間之前所有的資料都已經查詢過,但是為什麼時間不是從‘2018-01-21 10:10:10’開始,因為我們要考慮并發情況,在1s内會有多個訂單進來。

這種方式是實作最簡單,不需要借助外部的計算來支撐。這種方式有一個問題就是要想重新計算分頁的時候不丢失資料就需要保留原來一條資料,這樣才能知道開始的時間在哪裡,這樣就會在下次的分頁中看到這條時間。但是從真實的深分頁場景來看也可以忽略,因為很少有人會一頁一頁一直到翻到500頁,而是直接跳到最後幾頁,這個時候就不存在那個問題。

如果非要精準控制這個偏差就需要記住區間,或者用其他方式來實作了,比如全量查詢表、Sharding索引表、最大下單tps值之類的,用來輔助計算。(可以利用資料同步中間件建立單表多級索引、多表多元度索引來輔助計算。我們使用到的資料同步中間件有datax、yugong、otter、canal可以解決全量、增量同步問題)。

三、分表政策

分表有多種方式,mod、rang、preSharding、自定義路由,每種方式都有一定的側重。

我們主要使用mod + preSharding的方式,這種方式帶來的最大的一個問題就是後期的節點變動資料遷移問題,可以通過參考一緻性Hash算法的虛拟節點來解決。

資料表拆分和Cache Sharding有一些差別,cache能接受cache miss ,通過被動緩存的方式可以維護起cache資料。但是資料庫不存在select miss這種場景。

在Cache Sharding場景下一緻性Hash可以用來消除減少、增加Sharding node時相鄰分片壓力問題。但是資料庫一旦出現資料遷移,一定是不能接受資料查詢不出來的。是以我們為了将來資料的平滑遷移,做了一個虛拟節點 + 真實節點mapping 。

physics node : node 1 node 2 node 3 node 4
virtual node : node 1 node 2 node 3.....node 20
node mapping :
virtual node 1 ~ node 5 {physics node 1}
virtual node 6 ~ node 10 {physics node 2}
virtual node 11 ~ node 15 {physics node 3}
virtual node 16 ~ node 20 {physics node 4}
           

為了減少将來遷移資料時rehash的成本和延遲的開銷,将Hash後的值儲存在表裡,将來遷移直接查詢出來快速導入。

Hash片2的次方問題

在我們熟悉的HashMap裡,為了減少沖突和提供一定的性能将Hash桶的大小設定成2的n次方,然後采用Hash&(legnth-1)位與的方式計算,這樣主要是大師們發現2的n次方的二進制除了高位是0之外所有地位都是1,通過位與可以快速反轉二進制然後地位加1就是最終的值。

我們在做資料庫Sharding的時候不需要參考這一原則,這一原則主要是為了程式内部Hash表使用,外部我們本來就是要Hash mod确定Sharding node 。

通過mod取模的方式會出現不均勻問題,在此基礎上可以做個自定義奇偶路由,這樣可以均勻兩邊的資料。

四、一些注意事項

1、在現有項目中內建Sharding-JDBC有一些小問題,Sharding-JDBC不支援批量插入,如果項目中已經使用了大量的批量插入語句就需要改造,或者使用輔助hash計算實體表名,再批量插入。

2、原有項目資料層使用Druid + MyBatis,內建了Sharding-JDBC之後Sharding-JDBC包裝了Druid ,是以一些Sharding-JDBC不支援的SQL語句基本就過不去了。

3、使用Springboot內建Sharding-JDBC的時候,在bean加載的時候我需要設定 IncrementIdGenerator ,但是出現classloader問題。

IncrementIdGenerator incrementIdGenerator = this.getIncrementIdGenerator(dataSource);

ShardingRule ShardingRule = ShardingRuleConfiguration.build(dataSourceMap);
((IdGenerator) ShardingRule.getDefaultKeyGenerator()).setIncrementIdGenerator(incrementIdGenerator);
private IncrementIdGenerator getIncrementIdGenerator(DataSource druidDataSource) {
...
    }
           

後來發現Springboot的類加載器使用的是restartclassloader,是以導緻轉換一直失敗。隻要去掉spring-boot-devtools package即可,restartclassloader是為了熱啟動。

4、dao.xml逆向工程問題,我們使用的很多資料庫表MyBatis生成工具生成的時候都是實體表名,一旦我們使用了Sharding-JDCB之後都是用的邏輯表名,是以生成工具需要提供選項來設定邏輯表名。

5、為MyBatis提供的SqlSessionFactory需要在Druid的基礎上用Sharding-JDCB包裝下。

6、Sharding-JDBC DefaultkeyGenerator預設采用是snowflake算法,但是我們不能直接用我們需要根據datacenterid-workerid自己配合Zookeeper來設定 workerId 段。

(snowflake workId 10 bit 十進制 1023,dataCenterId 5 bit 十進制 31 、WorkId 5 bit 十進制 31)

7、由于我們使用的是mysql com.mysql.jdbc.ReplicationDriver自帶的實作讀寫分離,是以處理讀寫分離會友善很多。如果不是使用的這種就需要手動設定Datasource Hint來處理。

8、在使用MyBatis dao mapper的時候需要多份邏輯表,因為有些資料源資料表是不需要走Sharding的,自定義ShardingStragety來處理分支邏輯。

9、全局ID幾種方法:

如果使用 Zookeeper來做分布式ID,就要注意session expired可能會存在重複workid問題,加鎖或者接受一定程度的并行(有序列号保證一段時間空間)。

采用集中發号器服務,在主DB中采用預生成表+incrment 插件(經典取号器實作,InnoDB存儲引擎中的TRX_SYS_TRX_ID_STORE 事務号也是這種方式)。

定長發号器、業務規則發号器,這種需要業務上下文的發号器實作都需要預先配置,然後每次請求帶上擷取上下文來說明擷取業務類型。

10、在項目中有些地方使用了自增ID排序,資料表拆分之後就需要進行改造,因為ID大小順序已經不存在了。根據資料的最新排序時使用了ID排序需要改造成用時間字段排序。

原文釋出時間為:2018-08-04

本文作者:王清培

本文來自雲栖社群合作夥伴“

DBAplus社群

”,了解相關資訊可以關注“ DBAplus社群”。