數據庫分庫分表思路 數據庫分庫分表思路

數據庫分庫分表思路

 

 

 

 

一. 數據切分

關係型數據庫本身比較容易成爲系統瓶頸,單機存儲容量、連接數、處理能力都有限。當單表的數據量達到1000W或100G以後,由於查詢維度較多,即使添加從庫、優化索引,做很多操作時性能仍下降嚴重。此時就要考慮對其進行切分了,切分的目的就在於減少數據庫的負擔,縮短查詢時間。

數據庫分佈式核心內容無非就是數據切分(Sharding),以及切分後對數據的定位、整合。數據切分就是將數據分散存儲到多個數據庫中,使得單一數據庫中的數據量變小,通過擴充主機的數量緩解單一數據庫的性能問題,從而達到提升數據庫操作性能的目的。

數據切分根據其切分類型,可以分爲兩種方式:垂直(縱向)切分和水平(橫向)切分

 

1、垂直(縱向)切分

垂直切分常見有垂直分庫和垂直分表兩種。

垂直分庫就是根據業務耦合性,將關聯度低的不同表存儲在不同的數據庫。做法與大系統拆分爲多個小系統類似,按業務分類進行獨立劃分。與"微服務治理"的做法相似,每個微服務使用單獨的一個數據庫。如圖:

垂直分表是基於數據庫中的"列"進行,某個表字段較多,可以新建一張擴展表,將不經常用或字段長度較大的字段拆分出去到擴展表中。在字段很多的情況下(例如一個大表有100多個字段),通過"大表拆小表",更便於開發與維護,也能避免跨頁問題,MySQL底層是通過數據頁存儲的,一條記錄佔用空間過大會導致跨頁,造成額外的性能開銷。另外數據庫以行爲單位將數據加載到內存中,這樣表中字段長度較短且訪問頻率較高,內存能加載更多的數據,命中率更高,減少了磁盤IO,從而提升了數據庫性能。

垂直切分的優點:

  • 解決業務系統層面的耦合,業務清晰
  • 與微服務的治理類似,也能對不同業務的數據進行分級管理、維護、監控、擴展等
  • 高併發場景下,垂直切分一定程度的提升IO、數據庫連接數、單機硬件資源的瓶頸

缺點:

  • 部分表無法join,只能通過接口聚合方式解決,提升了開發的複雜度
  • 分佈式事務處理複雜
  • 依然存在單表數據量過大的問題(需要水平切分)

 

2、水平(橫向)切分

當一個應用難以再細粒度的垂直切分,或切分後數據量行數巨大,存在單庫讀寫、存儲性能瓶頸,這時候就需要進行水平切分了。

水平切分分爲庫內分表和分庫分表,是根據表內數據內在的邏輯關係,將同一個表按不同的條件分散到多個數據庫或多個表中,每個表中只包含一部分數據,從而使得單個表的數據量變小,達到分佈式的效果。如圖所示: 

庫內分表只解決了單一表數據量過大的問題,但沒有將表分佈到不同機器的庫上,因此對於減輕MySQL數據庫的壓力來說,幫助不是很大,大家還是競爭同一個物理機的CPU、內存、網絡IO,最好通過分庫分表來解決。

水平切分的優點:

  • 不存在單庫數據量過大、高併發的性能瓶頸,提升系統穩定性和負載能力
  • 應用端改造較小,不需要拆分業務模塊

缺點:

  • 跨分片的事務一致性難以保證
  • 跨庫的join關聯查詢性能較差
  • 數據多次擴展難度和維護量極大

水平切分後同一張表會出現在多個數據庫/表中,每個庫/表的內容不同。幾種典型的數據分片規則爲:

1、根據數值範圍

按照時間區間或ID區間來切分。例如:按日期將不同月甚至是日的數據分散到不同的庫中;將userId爲1~9999的記錄分到第一個庫,10000~20000的分到第二個庫,以此類推。某種意義上,某些系統中使用的"冷熱數據分離",將一些使用較少的歷史數據遷移到其他庫中,業務功能上只提供熱點數據的查詢,也是類似的實踐。

這樣的優點在於:

  • 單表大小可控
  • 天然便於水平擴展,後期如果想對整個分片集羣擴容時,只需要添加節點即可,無需對其他分片的數據進行遷移
  • 使用分片字段進行範圍查找時,連續分片可快速定位分片進行快速查詢,有效避免跨分片查詢的問題。

缺點:

  • 熱點數據成爲性能瓶頸。連續分片可能存在數據熱點,例如按時間字段分片,有些分片存儲最近時間段內的數據,可能會被頻繁的讀寫,而有些分片存儲的歷史數據,則很少被查詢

2、根據數值取模

一般採用hash取模mod的切分方式,例如:將 Customer 表根據 cusno 字段切分到4個庫中,餘數爲0的放到第一個庫,餘數爲1的放到第二個庫,以此類推。這樣同一個用戶的數據會分散到同一個庫中,如果查詢條件帶有cusno字段,則可明確定位到相應庫去查詢。

優點:

  • 數據分片相對比較均勻,不容易出現熱點和併發訪問的瓶頸

缺點:

  • 後期分片集羣擴容時,需要遷移舊的數據(使用一致性hash算法能較好的避免這個問題)
  • 容易面臨跨分片查詢的複雜問題。比如上例中,如果頻繁用到的查詢條件中不帶cusno時,將會導致無法定位數據庫,從而需要同時向4個庫發起查詢,再在內存中合併數據,取最小集返回給應用,分庫反而成爲拖累。

二. 分庫分錶帶來的問題

分庫分表能有效的環節單機和單庫帶來的性能瓶頸和壓力,突破網絡IO、硬件資源、連接數的瓶頸,同時也帶來了一些問題。下面將描述這些技術挑戰以及對應的解決思路。 

1、事務一致性問題

分佈式事務

當更新內容同時分佈在不同庫中,不可避免會帶來跨庫事務問題。跨分片事務也是分佈式事務,沒有簡單的方案,一般可使用"XA協議"和"兩階段提交"處理。

分佈式事務能最大限度保證了數據庫操作的原子性。但在提交事務時需要協調多個節點,推後了提交事務的時間點,延長了事務的執行時間。導致事務在訪問共享資源時發生衝突或死鎖的概率增高。隨着數據庫節點的增多,這種趨勢會越來越嚴重,從而成爲系統在數據庫層面上水平擴展的枷鎖。

最終一致性

對於那些性能要求很高,但對一致性要求不高的系統,往往不苛求系統的實時一致性,只要在允許的時間段內達到最終一致性即可,可採用事務補償的方式。與事務在執行中發生錯誤後立即回滾的方式不同,事務補償是一種事後檢查補救的措施,一些常見的實現方法有:對數據進行對賬檢查,基於日誌進行對比,定期同標準數據來源進行同步等等。事務補償還要結合業務系統來考慮。

 

2、跨節點關聯查詢 join 問題

切分之前,系統中很多列表和詳情頁所需的數據可以通過sql join來完成。而切分之後,數據可能分佈在不同的節點上,此時join帶來的問題就比較麻煩了,考慮到性能,儘量避免使用join查詢。

解決這個問題的一些方法:

1)全局表

全局表,也可看做是"數據字典表",就是系統中所有模塊都可能依賴的一些表,爲了避免跨庫join查詢,可以將這類表在每個數據庫中都保存一份。這些數據通常很少會進行修改,所以也不擔心一致性的問題。

2)字段冗餘

一種典型的反範式設計,利用空間換時間,爲了性能而避免join查詢。例如:訂單表保存userId時候,也將userName冗餘保存一份,這樣查詢訂單詳情時就不需要再去查詢"買家user表"了。

但這種方法適用場景也有限,比較適用於依賴字段比較少的情況。而冗餘字段的數據一致性也較難保證,就像上面訂單表的例子,買家修改了userName後,是否需要在歷史訂單中同步更新呢?這也要結合實際業務場景進行考慮。

3)數據組裝

在系統層面,分兩次查詢,第一次查詢的結果集中找出關聯數據id,然後根據id發起第二次請求得到關聯數據。最後將獲得到的數據進行字段拼裝。

4)ER分片

關係型數據庫中,如果可以先確定表之間的關聯關係,並將那些存在關聯關係的表記錄存放在同一個分片上,那麼就能較好的避免跨分片join問題。在1:1或1:n的情況下,通常按照主表的ID主鍵切分。如下圖所示:

這樣一來,Data Node1上面的order訂單表與orderdetail訂單詳情表就可以通過orderId進行局部的關聯查詢了,Data Node2上也一樣。

 

3、跨節點分頁、排序、函數問題

跨節點多庫進行查詢時,會出現limit分頁、order by排序等問題。分頁需要按照指定字段進行排序,當排序字段就是分片字段時,通過分片規則就比較容易定位到指定的分片;當排序字段非分片字段時,就變得比較複雜了。需要先在不同的分片節點中將數據進行排序並返回,然後將不同分片返回的結果集進行彙總和再次排序,最終返回給用戶。如圖所示:

上圖中只是取第一頁的數據,對性能影響還不是很大。但是如果取得頁數很大,情況則變得複雜很多,因爲各分片節點中的數據可能是隨機的,爲了排序的準確性,需要將所有節點的前N頁數據都排序好做合併,最後再進行整體的排序,這樣的操作時很耗費CPU和內存資源的,所以頁數越大,系統的性能也會越差。

在使用Max、Min、Sum、Count之類的函數進行計算的時候,也需要先在每個分片上執行相應的函數,然後將各個分片的結果集進行彙總和再次計算,最終將結果返回。如圖所示:

 

4、全局主鍵避重問題

在分庫分表環境中,由於表中數據同時存在不同數據庫中,主鍵值平時使用的自增長將無用武之地,某個分區數據庫自生成的ID無法保證全局唯一。因此需要單獨設計全局主鍵,以避免跨庫主鍵重複問題。有一些常見的主鍵生成策略:

1)UUID

UUID標準形式包含32個16進制數字,分爲5段,形式爲8-4-4-4-12的36個字符,例如:550e8400-e29b-41d4-a716-446655440000

UUID是主鍵是最簡單的方案,本地生成,性能高,沒有網絡耗時。但缺點也很明顯,由於UUID非常長,會佔用大量的存儲空間;另外,作爲主鍵建立索引和基於索引進行查詢時都會存在性能問題,在InnoDB下,UUID的無序性會引起數據位置頻繁變動,導致分頁。

2)結合數據庫維護主鍵ID表

在數據庫中建立 sequence 表:

CREATE TABLE `sequence` (  
  `id` bigint(20) unsigned NOT NULL auto_increment,  
  `stub` char(1) NOT NULL default '',  
  PRIMARY KEY  (`id`),  
  UNIQUE KEY `stub` (`stub`)  
) ENGINE=MyISAM;

stub字段設置爲唯一索引,同一stub值在sequence表中只有一條記錄,可以同時爲多張表生成全局ID。sequence表的內容,如下所示:

+-------------------+------+  
| id                | stub |  
+-------------------+------+  
| 72157623227190423 |    a |  
+-------------------+------+  

使用 MyISAM 存儲引擎而不是 InnoDB,以獲取更高的性能。MyISAM使用的是表級別的鎖,對錶的讀寫是串行的,所以不用擔心在併發時兩次讀取同一個ID值。

當需要全局唯一的64位ID時,執行:

REPLACE INTO sequence (stub) VALUES ('a');  
SELECT LAST_INSERT_ID();  

這兩條語句是Connection級別的,select last_insert_id() 必須與 replace into 在同一數據庫連接下才能得到剛剛插入的新ID。

使用replace into代替insert into好處是避免了錶行數過大,不需要另外定期清理。

此方案較爲簡單,但缺點也明顯:存在單點問題,強依賴DB,當DB異常時,整個系統都不可用。配置主從可以增加可用性,但當主庫掛了,主從切換時,數據一致性在特殊情況下難以保證。另外性能瓶頸限制在單臺MySQL的讀寫性能。

flickr團隊使用的一種主鍵生成策略,與上面的sequence表方案類似,但更好的解決了單點和性能瓶頸的問題。

這一方案的整體思想是:建立2個以上的全局ID生成的服務器,每個服務器上只部署一個數據庫,每個庫有一張sequence表用於記錄當前全局ID。表中ID增長的步長是庫的數量,起始值依次錯開,這樣能將ID的生成散列到各個數據庫上。如下圖所示:

由兩個數據庫服務器生成ID,設置不同的auto_increment值。第一臺sequence的起始值爲1,每次步長增長2,另一臺的sequence起始值爲2,每次步長增長也是2。結果第一臺生成的ID都是奇數(1, 3, 5, 7 ...),第二臺生成的ID都是偶數(2, 4, 6, 8 ...)。

這種方案將生成ID的壓力均勻分佈在兩臺機器上。同時提供了系統容錯,第一臺出現了錯誤,可以自動切換到第二臺機器上獲取ID。但有以下幾個缺點:系統添加機器,水平擴展時較複雜;每次獲取ID都要讀寫一次DB,DB的壓力還是很大,只能靠堆機器來提升性能。

可以基於flickr的方案繼續優化,使用批量的方式降低數據庫的寫壓力,每次獲取一段區間的ID號段,用完之後再去數據庫獲取,可以大大減輕數據庫的壓力。如下圖所示:

還是使用兩臺DB保證可用性,數據庫中只存儲當前的最大ID。ID生成服務每次批量拉取6個ID,先將max_id修改爲5,當應用訪問ID生成服務時,就不需要訪問數據庫,從號段緩存中依次派發0~5的ID。當這些ID發完後,再將max_id修改爲11,下次就能派發6~11的ID。於是,數據庫的壓力降低爲原來的1/6。

3)Snowflake分佈式自增ID算法

Twitter的snowflake算法解決了分佈式系統生成全局ID的需求,生成64位的Long型數字,組成部分:

  • 第一位未使用
  • 接下來41位是毫秒級時間,41位的長度可以表示69年的時間
  • 5位datacenterId,5位workerId。10位的長度最多支持部署1024個節點
  • 最後12位是毫秒內的計數,12位的計數順序號支持每個節點每毫秒產生4096個ID序列

這樣的好處是:毫秒數在高位,生成的ID整體上按時間趨勢遞增;不依賴第三方系統,穩定性和效率較高,理論上QPS約爲409.6w/s(1000*2^12),並且整個分佈式系統內不會產生ID碰撞;可根據自身業務靈活分配bit位。

不足就在於:強依賴機器時鐘,如果時鐘回撥,則可能導致生成ID重複。

綜上

結合數據庫和snowflake的唯一ID方案,可以參考業界較爲成熟的解法:Leaf——美團點評分佈式ID生成系統,並考慮到了高可用、容災、分佈式下時鐘等問題。

 

5、數據遷移、擴容問題

當業務高速發展,面臨性能和存儲的瓶頸時,纔會考慮分片設計,此時就不可避免的需要考慮歷史數據遷移的問題。一般做法是先讀出歷史數據,然後按指定的分片規則再將數據寫入到各個分片節點中。此外還需要根據當前的數據量和QPS,以及業務發展的速度,進行容量規劃,推算出大概需要多少分片(一般建議單個分片上的單表數據量不超過1000W)

如果採用數值範圍分片,只需要添加節點就可以進行擴容了,不需要對分片數據遷移。如果採用的是數值取模分片,則考慮後期的擴容問題就相對比較麻煩。

三. 什麼時候考慮切分

下面講述一下什麼時候需要考慮做數據切分。

1、能不切分儘量不要切分

並不是所有表都需要進行切分,主要還是看數據的增長速度。切分後會在某種程度上提升業務的複雜度,數據庫除了承載數據的存儲和查詢外,協助業務更好的實現需求也是其重要工作之一。

不到萬不得已不用輕易使用分庫分表這個大招,避免"過度設計"和"過早優化"。分庫分表之前,不要爲分而分,先盡力去做力所能及的事情,例如:升級硬件、升級網絡、讀寫分離、索引優化等等。當數據量達到單表的瓶頸時候,再考慮分庫分表。

2、數據量過大,正常運維影響業務訪問

這裏說的運維,指:

1)對數據庫備份,如果單表太大,備份時需要大量的磁盤IO和網絡IO。例如1T的數據,網絡傳輸佔50MB時候,需要20000秒才能傳輸完畢,整個過程的風險都是比較高的

2)對一個很大的表進行DDL修改時,MySQL會鎖住全表,這個時間會很長,這段時間業務不能訪問此表,影響很大。如果使用pt-online-schema-change,使用過程中會創建觸發器和影子表,也需要很長的時間。在此操作過程中,都算爲風險時間。將數據表拆分,總量減少,有助於降低這個風險。

3)大表會經常訪問與更新,就更有可能出現鎖等待。將數據切分,用空間換時間,變相降低訪問壓力

3、隨着業務發展,需要對某些字段垂直拆分

舉個例子,假如項目一開始設計的用戶表如下:

id                   bigint             #用戶的ID
name                 varchar            #用戶的名字
last_login_time      datetime           #最近登錄時間
personal_info        text               #私人信息
.....                                   #其他信息字段

在項目初始階段,這種設計是滿足簡單的業務需求的,也方便快速迭代開發。而當業務快速發展時,用戶量從10w激增到10億,用戶非常的活躍,每次登錄會更新 last_login_name 字段,使得 user 表被不斷update,壓力很大。而其他字段:id, name, personal_info 是不變的或很少更新的,此時在業務角度,就要將 last_login_time 拆分出去,新建一個 user_time 表。

personal_info 屬性是更新和查詢頻率較低的,並且text字段佔據了太多的空間。這時候,就要對此垂直拆分出 user_ext 表了。

4、數據量快速增長

隨着業務的快速發展,單表中的數據量會持續增長,當性能接近瓶頸時,就需要考慮水平切分,做分庫分表了。此時一定要選擇合適的切分規則,提前預估好數據容量

5、安全性和可用性

雞蛋不要放在一個籃子裏。在業務層面上垂直切分,將不相關的業務的數據庫分隔,因爲每個業務的數據量、訪問量都不同,不能因爲一個業務把數據庫搞掛而牽連到其他業務。利用水平切分,當一個數據庫出現問題時,不會影響到100%的用戶,每個庫只承擔業務的一部分數據,這樣整體的可用性就能提高。

四. 案例分析

1、用戶中心業務場景

用戶中心是一個非常常見的業務,主要提供用戶註冊、登錄、查詢/修改等功能,其核心表爲:

User(uid, login_name, passwd, sex, age, nickname)

uid爲用戶ID,  主鍵
login_name, passwd, sex, age, nickname,  用戶屬性

任何脫離業務的架構設計都是耍流氓,在進行分庫分表前,需要對業務場景需求進行梳理:

  • 用戶側:前臺訪問,訪問量較大,需要保證高可用和高一致性。主要有兩類需求:

    • 用戶登錄:通過login_name/phone/email查詢用戶信息,1%請求屬於這種類型
    • 用戶信息查詢:登錄之後,通過uid來查詢用戶信息,99%請求屬這種類型
  • 運營側:後臺訪問,支持運營需求,按照年齡、性別、登陸時間、註冊時間等進行分頁的查詢。是內部系統,訪問量較低,對可用性、一致性的要求不高。

 

2、水平切分方法

當數據量越來越大時,需要對數據庫進行水平切分,上文描述的切分方法有"根據數值範圍"和"根據數值取模"。

"根據數值範圍":以主鍵uid爲劃分依據,按uid的範圍將數據水平切分到多個數據庫上。例如:user-db1存儲uid範圍爲0~1000w的數據,user-db2存儲uid範圍爲1000w~2000wuid數據。

  • 優點是:擴容簡單,如果容量不夠,只要增加新db即可。

  • 不足是:請求量不均勻,一般新註冊的用戶活躍度會比較高,所以新的user-db2會比user-db1負載高,導致服務器利用率不平衡

"根據數值取模":也是以主鍵uid爲劃分依據,按uid取模的值將數據水平切分到多個數據庫上。例如:user-db1存儲uid取模得1的數據,user-db2存儲uid取模得0的uid數據。

  • 優點是:數據量和請求量分佈均均勻

  • 不足是:擴容麻煩,當容量不夠時,新增加db,需要rehash。需要考慮對數據進行平滑的遷移。

 

3、非uid的查詢方法

水平切分後,對於按uid查詢的需求能很好的滿足,可以直接路由到具體數據庫。而按非uid的查詢,例如login_name,就不知道具體該訪問哪個庫了,此時需要遍歷所有庫,性能會降低很多。

對於用戶側,可以採用"建立非uid屬性到uid的映射關係"的方案;對於運營側,可以採用"前臺與後臺分離"的方案。

3.1、建立非uid屬性到uid的映射關係

1)映射關係

例如:login_name不能直接定位到數據庫,可以建立login_name→uid的映射關係,用索引表或緩存來存儲。當訪問login_name時,先通過映射表查詢出login_name對應的uid,再通過uid定位到具體的庫。

映射表只有兩列,可以承載很多數據,當數據量過大時,也可以對映射表再做水平切分。這類kv格式的索引結構,可以很好的使用cache來優化查詢性能,而且映射關係不會頻繁變更,緩存命中率會很高。

2)基因法

分庫基因:假如通過uid分庫,分爲8個庫,採用uid%8的方式進行路由,此時是由uid的最後3bit來決定這行User數據具體落到哪個庫上,那麼這3bit可以看爲分庫基因。

上面的映射關係的方法需要額外存儲映射表,按非uid字段查詢時,還需要多一次數據庫或cache的訪問。如果想要消除多餘的存儲和查詢,可以通過f函數取login_name的基因作爲uid的分庫基因。生成uid時,參考上文所述的分佈式唯一ID生成方案,再加上最後3位bit值=f(login_name)。當查詢login_name時,只需計算f(login_name)%8的值,就可以定位到具體的庫。不過這樣需要提前做好容量規劃,預估未來幾年的數據量需要分多少庫,要預留一定bit的分庫基因。

 

3.2、前臺與後臺分離

對於用戶側,主要需求是以單行查詢爲主,需要建立login_name/phone/email到uid的映射關係,可以解決這些字段的查詢問題。

而對於運營側,很多批量分頁且條件多樣的查詢,這類查詢計算量大,返回數據量大,對數據庫的性能消耗較高。此時,如果和用戶側公用同一批服務或數據庫,可能因爲後臺的少量請求,佔用大量數據庫資源,而導致用戶側訪問性能降低或超時。

這類業務最好採用"前臺與後臺分離"的方案,運營側後臺業務抽取獨立的service和db,解決和前臺業務系統的耦合。由於運營側對可用性、一致性的要求不高,可以不訪問實時庫,而是通過binlog異步同步數據到運營庫進行訪問。在數據量很大的情況下,還可以使用ES搜索引擎或Hive來滿足後臺複雜的查詢方式。

五. 支持分庫分表中間件

站在巨人的肩膀上能省力很多,目前分庫分表已經有一些較爲成熟的開源解決方案:

六. 參考

數據庫分佈式架構掃盲——分庫分表(及銀行核心系統適用性思考) 

分庫分表的思想 

水平分庫分表的關鍵步驟以及可能遇到的問題 

從原則、方案、策略及難點闡述分庫分表 

Leaf——美團點評分佈式ID生成系統 

數據庫水平切分架構實踐-【架構師之路】公衆號

 


 

 

MySQL分庫分表 看完吊打面試官

 
等 

前言

在互聯網項目中比較常用到的關係型數據庫是MySQL,隨着用戶和業務的增長,傳統的單庫單表模式難以滿足大量的業務數據存儲以及查詢,單庫單表中大量的數據會使寫入、查詢效率非常之慢,此時應該採取分庫分表策略來解決。

提示:以下是本篇文章正文內容,案例僅供參考

一、業務場景介紹

假設目前有一個電商系統使用的是MySQL,要設計大數據量存儲、高併發、高性能可擴展的方案,數據庫中有用戶表。用戶會非常多,並且要實現高擴展性,你會怎麼去設計? OK咱們先看傳統的分庫分表方式

 

 

當然還有些小夥伴知道按照省份/地區或一定的業務關係進行數據庫拆分

 

 

OK,問題來了,如何保證合理的讓數據存儲在不同的庫不同的表裏呢?讓庫減少併發壓力?應該怎麼去制定分庫分表的規則?不用急,這不就來了

二、水平分庫分表方法

1.RANGE

第一種方法們可以指定一個數據範圍來進行分表,例如從1~1000000,1000001-2000000,使用一百萬一張表的方式,如下圖所示

 

 

當然這種方法需要維護表的ID,特別是分佈式環境下,這種分佈式ID,在不使用第三方分表工具的情況下,建議使用Redis,Redis的incr操作可以輕鬆的維護分佈式的表ID。

RANGE方法優點: 擴容簡單,提前建好庫、表就好

RANGE方法缺點: 大部分讀和寫都訪會問新的數據,有IO瓶頸,這樣子造成新庫壓力過大,不建議採用。

2.HASH取模

針對上述RANGE方式分表有IO瓶頸的問題,咱們可以採用根據用戶ID HASG取模的方式進行分庫分表,如圖所示:

 

 

這樣就可以將數據分散在不同的庫、表中,避免了IO瓶頸的問題。

HASH取模方法優點: 能保證數據較均勻的分散落在不同的庫、表中,減輕了數據庫壓力

HASH取模方法缺點: 擴容麻煩、遷移數據時每次都需要重新計算hash值分配到不同的庫和表

3.一致性HASH

通過HASH取模也不是最完美的辦法,那什麼纔是呢?

使用一致性HASH算法能完美的解決問題

普通HASH算法:

普通哈希算法將任意長度的二進制值映射爲較短的固定長度的二進制值,這個小的二進制值稱爲哈希值。哈希值是一段數據唯一且極其緊湊的數值表示形式。

普通的hash算法在分佈式應用中的不足:在分佈式的存儲系統中,要將數據存儲到具體的節點上,如果我們採用普通的hash算法進行路由,將數據映射到具體的節點上,如key%n,key是數據的key,n是機器節點數,如果有一個機器加入或退出集羣,則所有的數據映射都無效了,如果是持久化存儲則要做數據遷移,如果是分佈式緩存,則其他緩存就失效了。

一致性HASH算法: 按照常用的hash算法來將對應的key哈希到一個具有2^32次方個節點的空間中,即0~ (2^32)-1的數字空間中。現在我們可以將這些數字頭尾相連,想象成一個閉合的環形,如下圖所示。

 

 

這個圓環首尾相連,那麼假設現在有三個數據庫服務器節點node1、node2、node3三個節點,每個節點負責自己這部分的用戶數據存儲,假設有用戶user1、user2、user3,我們可以對服務器節點進行HASH運算,假設HASH計算後,user1落在node1上,user2落在node2上,user3落在user3上

 

 

OK,現在咱們假設node3節點失效了

 

 

user3將會落到node1上,而之前的node1和node2數據不會改變,再假設新增了節點node4

 

 

你會發現user3會落到node4上,你會發現,通過對節點的添加和刪除的分析,一致性哈希算法在保持了單調性的同時,還是數據的遷移達到了最小,這樣的算法對分佈式集羣來說是非常合適的,避免了大量數據遷移,減小了服務器的的壓力。

當然還有一個問題還需要解決,那就是平衡性。從圖我們可以看出,當服務器節點比較少的時候,會出現一個問題,就是此時必然造成大量數據集中到一個節點上面,極少數數據集中到另外的節點上面。

爲了解決這種數據傾斜問題,一致性哈希算法引入了虛擬節點機制,即對每一個服務節點計算多個哈希,每個計算結果位置都放置一個節點,稱爲虛擬節點。具體做法可以先確定每個物理節點關聯的虛擬節點數量,然後在ip或者主機名後面增加編號。例如上面的情況,可以爲每臺服務器計算三個虛擬節點,於是可以分別計算 “node 1-1”、“node 1-2”、“node 1-3”、“node 2-1”、“node 2-2”、“node 2-3”、“node 3-1”、“node 3-2”、“node 3-3”的哈希值,這樣形成九個虛擬節點

例如user1定位到node 1-1、node 1-2、node 1-3上其實都是定位到node1這個節點上,這樣能夠解決服務節點少時數據傾斜的問題,當然這個虛擬節點的個數不是說固定三個或者至多、至少三個,這裏只是一個例子,具體虛擬節點的多少,需要根據實際的業務情況而定。

一致性HASH方法優點: 通過虛擬節點方式能保證數據較均勻的分散落在不同的庫、表中,並且新增、刪除節點不影響其他節點的數據,高可用、容災性強。

一致性取模方法缺點: 嗯,比起以上兩種,可以認爲沒有。

三、單元測試

OK,不廢話,接下來上單元測試,假設有三個節點,每個節點有三個虛擬節點的情況

package com.hyh.core.test;

import com.hyh.utils.common.StringUtils;
import org.junit.Test;

import java.util.LinkedList;
import java.util.List;
import java.util.SortedMap;
import java.util.TreeMap;

/**
 * 一致性HASH TEST
 *
 * @Author heyuhua
 * @create 2021/1/31 19:50
 */
public class ConsistentHashTest {

    //待添加入Hash環的服務器列表
    private static String[] servers = {"192.168.5.1", "192.168.5.2", "192.168.5.3"};

    //真實結點列表,考慮到服務器上線、下線的場景,即添加、刪除的場景會比較頻繁,這裏使用LinkedList會更好
    private static List<String> realNodes = new LinkedList<>();

    //虛擬節點,key表示虛擬節點的hash值,value表示虛擬節點的名稱
    private static SortedMap<Integer, String> virtualNodes = new TreeMap<>();

    //一個真實結點對應3個虛擬節點
    private static final int VIRTUAL_NODES = 3;

    /**
     * 測試有虛擬節點的一致性HASH
     */
    @Test
    public void testConsistentHash() {
        initNodes();
        String[] users = {"user1", "user2", "user3", "user4", "user5", "user6", "user7", "user8", "user9"};
        for (int i = 0; i < users.length; i++)
            System.out.println("[" + users[i] + "]的hash值爲" +
                    getHash(users[i]) + ", 被路由到結點[" + getServer(users[i]) + "]");
    }

    /**
     * 先把原始的服務器添加到真實結點列表中
     */
    public void initNodes() {
        for (int i = 0; i < servers.length; i++)
            realNodes.add(servers[i]);
        for (String str : realNodes) {
            for (int i = 0; i < VIRTUAL_NODES; i++) {
                String virtualNodeName = str + "-虛擬節點" + String.valueOf(i);
                int hash = getHash(virtualNodeName);
                System.out.println("虛擬節點[" + virtualNodeName + "]被添加, hash值爲" + hash);
                virtualNodes.put(hash, virtualNodeName);
            }
        }
        System.out.println();
    }

    //使用FNV1_32_HASH算法計算服務器的Hash值,這裏不使用重寫hashCode的方法,最終效果沒區別
    private static int getHash(String str) {
        final int p = 16777619;
        int hash = (int) 2166136261L;
        for (int i = 0; i < str.length(); i++)
            hash = (hash ^ str.charAt(i)) * p;
        hash += hash << 13;
        hash ^= hash >> 7;
        hash += hash << 3;
        hash ^= hash >> 17;
        hash += hash << 5;

        // 如果算出來的值爲負數則取其絕對值
        if (hash < 0)
            hash = Math.abs(hash);
        return hash;
    }

    //得到應當路由到的結點
    private static String getServer(String key) {
        //得到該key的hash值
        int hash = getHash(key);
        // 得到大於該Hash值的所有Map
        SortedMap<Integer, String> subMap = virtualNodes.tailMap(hash);
        String virtualNode;
        if (subMap.isEmpty()) {
            //如果沒有比該key的hash值大的,則從第一個node開始
            Integer i = virtualNodes.firstKey();
            //返回對應的服務器
            virtualNode = virtualNodes.get(i);
        } else {
            //第一個Key就是順時針過去離node最近的那個結點
            Integer i = subMap.firstKey();
            //返回對應的服務器
            virtualNode = subMap.get(i);
        }
        //virtualNode虛擬節點名稱要截取一下
        if (StringUtils.isNotBlank(virtualNode)) {
            return virtualNode.substring(0, virtualNode.indexOf("-"));
        }
        return null;
    }
}

 

 

這裏模擬9個用戶對象hash後被路由的情況,看下結果

 

 

總結

分庫分表在分佈式微服務架構環境下建議強烈使用一致性HASH算法來做,當然分佈式環境下也會產生業務數據數據一致性、分佈式事務問題,下期咱們再來探討數據一致性、分佈式事務的解決方案

作者寄語

路漫漫其修遠兮,吾願與君上下而求索,感謝各位帥哥、靚妹的點贊、收藏和評論,我們下期見。

關注我帶你走進架構師的成長之路。

源碼地址:

作者:消滅知識盲區
鏈接:
來源:掘金
發佈於 02-02
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章