從分庫分表后遺癥,總結數據庫表拆分策略
本文將主要從背景、分庫分表帶來的后遺癥、分表策略以及一些注意事項等方面對數據庫分表來進行小結。
一、背景
最近一段時間內結束了數據庫表拆分項目,本次拆分主要包括訂單和優(yōu)惠券兩大塊,這兩塊都是覆蓋全集團所有分子公司所有業(yè)務線。隨著公司的業(yè)務飛速發(fā)展,不管是存儲的要求,還是寫入、讀取的性能都基本上到了警戒水位。
訂單是交易的核心,優(yōu)惠券是營銷的核心,這兩塊基本上是整個平臺的正向最核心部分。為了支持未來三到五年的快速發(fā)展,我們需要對數據進行拆分。
數據庫表拆分業(yè)內已經有很多成熟方案,已經不是什么高深的技術,基本上是純工程化的流程,但是能有機會進行實際的操刀一把,機會還是難得,所以非常有必要做個總結。
由于分庫分表包含的技術選型和方式方法多種多樣,這篇文章不是羅列和匯總介紹各種方法,而是總結我們在實施分庫分表過程中的一些經驗。
根據業(yè)務場景判斷,我們主要是做水平拆分,做邏輯DB拆分,考慮到未來數據庫寫入瓶頸可以將一組Sharding表直接遷移進分庫中。
二、分庫、分表帶來的后遺癥
分庫、分表會帶來很多的后遺癥,會使整個系統(tǒng)架構變的復雜。分的好與不好最關鍵就是如何尋找那個Sharding key,如果這個Sharding key剛好是業(yè)務維度上的分界線就會直接提升性能和改善復雜度,否則就會有各種腳手架來支撐,系統(tǒng)也就會變得復雜。
比如訂單系統(tǒng)中的用戶__ID__、訂單__type__、商家__ID__、渠道__ID__,優(yōu)惠券系統(tǒng)中的批次__ID__、渠道__ID__、機構__ID__ 等,這些都是潛在的Sharding key。
如果剛好有這么一個Sharding key存在后面處理路由(routing)就會很方便,否則就需要一些大而全的索引表來處理OLAP的查詢。
一旦Sharding之后首先要面對的問題就是查詢時排序分頁問題。
1、歸并排序
原來在一個數據庫表中處理排序分頁是比較方便的,Sharding之后就會存在多個數據源,這里我們將多個數據源統(tǒng)稱為分片。
想要實現(xiàn)多分片排序分頁就需要將各個片的數據都匯集起來進行排序,就需要用到歸并排序算法。這些數據在各個分片中可以做到有序的(輸出有序),但是整體上是無序的。
我們看個簡單的例子:
- shard node 1: {1、3、5、7、9}
- shard node 2: {2、4、6、8、10}
這是做奇偶Sharding 的兩個分片,我們假設分頁參數設置為每頁4條,當前第1頁,參數如下:
- pageParameter:pageSize:4、currentPage:1
最樂觀情況下我們需要分別讀取兩個分片節(jié)點中的前兩條:
- shard node 1: {1、3}
- shard node 2: {2、4}
排序完剛好是{1、2、3、4},但是這種場景基本上不太可能出現(xiàn),假設如下分片節(jié)點數據:
- shard node 1: {7、9、11、13、15}
- shard node 2: {2、4、6、8、10、12、14}
我們還是按照讀取每個節(jié)點前兩條肯定是錯誤的,因為最悲觀情況下(也是最真實的情況)就是排序完后所有的數據都來自一個分片。所以我們需要讀取每個節(jié)點的pageSize大小的數據出來才有可能保證數據的正確性。
這個例子只是假設我們的查詢條件輸出的數據剛好是均等的,真實的情況一定是各種各樣的查詢條件篩選出來的數據集合,此時這個數據一定不是這樣的排列方式,最真實的就是***者這種結構。
我們以此類推,如果我們的currentPage:1000,那么會出現(xiàn)什么問題?我們需要每個Sharding node讀取 __4000(1000*4=4000)__ 條數據出來排序,因為最悲觀情況下有可能所有的數據均來自一個Sharding node 。
這樣***制的翻頁下去,處理排序分頁的機器肯定會內存撐爆,就算不撐爆一定會觸發(fā)性能瓶頸。
這個簡單的例子用來說明分片之后,排序分頁帶來的現(xiàn)實問題,這也有助于我們理解分布式系統(tǒng)在做多節(jié)點排序分頁時為什么有***分頁限制。
2、深分頁性能問題
面對這種問題,我們需要改變查詢條件重新分頁。一個龐大的數據集會通過多種方式進行數據拆分,按機構、按時間、按渠道等等,拆分在不同的數據源中。一般的深分頁問題我們可以通過改變查詢條件來平滑解決,但是這種方案并不能解決所有的業(yè)務場景。
比如,我們有一個訂單列表,從C端用戶來查詢自己的訂單列表數據量不會很大,但是運營后臺系統(tǒng)可能面對全平臺的所有訂單數據量,所以數據量會很大。
改變查詢條件有兩種:
- ***種條件是顯示設置,盡量縮小查詢范圍,這種設置一般都會優(yōu)先考慮如時間范圍、支付狀態(tài)、配送狀態(tài)等等,通過多個疊加條件就可以橫豎過濾出很小一部分數據集;
- 第二種條件為隱式設置,比如訂單列表通常是按照訂單創(chuàng)建時間來排序,那么當翻頁到限制的條件時,我們可以改變這個時間。
- 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都會由發(fā)號器統(tǒng)一發(fā)放,多個集群多個消費者同時獲取,但是創(chuàng)建訂單的速度是不一樣的,所以順序性已經不存在了。
上面的兩個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條數據。這里我們可以通過修改查詢條件來讓整個翻頁變?yōu)橹匦虏樵儭?/p>
- where createDateTime>'2018-01-11 10:10:13'
因為我們可以確定在‘2018-01-11 10:10:13’時間之前所有的數據都已經查詢過,但是為什么時間不是從‘2018-01-21 10:10:10’開始,因為我們要考慮并發(fā)情況,在1s內會有多個訂單進來。
這種方式是實現(xiàn)最簡單,不需要借助外部的計算來支撐。這種方式有一個問題就是要想重新計算分頁的時候不丟失數據就需要保留原來一條數據,這樣才能知道開始的時間在哪里,這樣就會在下次的分頁中看到這條時間。但是從真實的深分頁場景來看也可以忽略,因為很少有人會一頁一頁一直到翻到500頁,而是直接跳到***幾頁,這個時候就不存在那個問題。
如果非要精準控制這個偏差就需要記住區(qū)間,或者用其他方式來實現(xiàn)了,比如全量查詢表、Sharding索引表、***下單tps值之類的,用來輔助計算。(可以利用數據同步中間件建立單表多級索引、多表多維度索引來輔助計算。我們使用到的數據同步中間件有datax、yugong、otter、canal可以解決全量、增量同步問題)。
三、分表策略
分表有多種方式,mod、rang、preSharding、自定義路由,每種方式都有一定的側重。
我們主要使用mod + preSharding的方式,這種方式帶來的***的一個問題就是后期的節(jié)點變動數據遷移問題,可以通過參考一致性Hash算法的虛擬節(jié)點來解決。
數據表拆分和Cache Sharding有一些區(qū)別,cache能接受cache miss ,通過被動緩存的方式可以維護起cache數據。但是數據庫不存在select miss這種場景。
在Cache Sharding場景下一致性Hash可以用來消除減少、增加Sharding node時相鄰分片壓力問題。但是數據庫一旦出現(xiàn)數據遷移,一定是不能接受數據查詢不出來的。所以我們?yōu)榱藢頂祿钠交w移,做了一個虛擬節(jié)點 + 真實節(jié)點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)位與的方式計算,這樣主要是大師們發(fā)現(xiàn)2的n次方的二進制除了高位是0之外所有地位都是1,通過位與可以快速反轉二進制然后地位加1就是最終的值。
我們在做數據庫Sharding的時候不需要參考這一原則,這一原則主要是為了程序內部Hash表使用,外部我們本來就是要Hash mod確定Sharding node 。
通過mod取模的方式會出現(xiàn)不均勻問題,在此基礎上可以做個自定義奇偶路由,這樣可以均勻兩邊的數據。
四、一些注意事項
1、在現(xiàn)有項目中集成Sharding-JDBC有一些小問題,Sharding-JDBC不支持批量插入,如果項目中已經使用了大量的批量插入語句就需要改造,或者使用輔助hash計算物理表名,再批量插入。
2、原有項目數據層使用Druid + MyBatis,集成了Sharding-JDBC之后Sharding-JDBC包裝了Druid ,所以一些Sharding-JDBC不支持的SQL語句基本就過不去了。
3、使用Springboot集成Sharding-JDBC的時候,在bean加載的時候我需要設置 IncrementIdGenerator ,但是出現(xiàn)classloader問題。I
- IncrementIdGenerator incrementIdGenerator = this.getIncrementIdGenerator(dataSource);
- ShardingRule ShardingRule = ShardingRuleConfiguration.build(dataSourceMap);
- ((IdGenerator) ShardingRule.getDefaultKeyGenerator()).setIncrementIdGenerator(incrementIdGenerator);
- private IncrementIdGenerator getIncrementIdGenerator(DataSource druidDataSource) {
- ...
- }
后來發(fā)現(xiàn)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自帶的實現(xiàn)讀寫分離,所以處理讀寫分離會方便很多。如果不是使用的這種就需要手動設置Datasource Hint來處理。
8、在使用MyBatis dao mapper的時候需要多份邏輯表,因為有些數據源數據表是不需要走Sharding的,自定義ShardingStragety來處理分支邏輯。
9、全局ID幾種方法:
- 如果使用 Zookeeper來做分布式ID,就要注意session expired可能會存在重復workid問題,加鎖或者接受一定程度的并行(有序列號保證一段時間空間)。
- 采用集中發(fā)號器服務,在主DB中采用預生成表+incrment 插件(經典取號器實現(xiàn),InnoDB存儲引擎中的TRX_SYS_TRX_ID_STORE 事務號也是這種方式)。
- 定長發(fā)號器、業(yè)務規(guī)則發(fā)號器,這種需要業(yè)務上下文的發(fā)號器實現(xiàn)都需要預先配置,然后每次請求帶上獲取上下文來說明獲取業(yè)務類型。
10、在項目中有些地方使用了自增ID排序,數據表拆分之后就需要進行改造,因為ID大小順序已經不存在了。根據數據的***排序時使用了ID排序需要改造成用時間字段排序。