分庫分表的一些思考

分庫分表這個技術在之前一家公司其實也有接觸。上一家公司在業務上按照用戶水平分庫的,所以避免了很多業務上的問題,但也只是基於Spring的AbstractRoutingDataSource,根據userId做了簡單的路由。之前也在網上聽說過sharding-jdbc等中間件,也僅限於瞭解。所以結合我從內網看到的關於TDDL中間件的文章和外網看到的一些文章,在這篇文章中整理一下我這個新人對分庫分表的認識。

1、單庫單表

剛開始的時候,應用的數據比較少,業務也不會特別複雜,所以應用只有一個數據庫,數據庫中的每一張表都是完整的數據,這也是數據庫的最初形態。

2、讀寫分離

隨着業務發展,數據量和訪問量都不斷增長,但大多數業務都是讀多寫少。比如新聞網站的新聞、購物網站的商品,運營人員在後臺編輯好後,所有的互聯網用戶都可能會去讀取這些數據,因此數據庫面臨的讀壓力遠大於寫壓力。這個時候**在原來數據庫(Master)基礎上增加一個備用數據庫(Slave),備庫和主庫存儲相同的數據,但只提供讀服務,不提供寫服務。寫操作以及事務中的讀操作走主庫,其他讀操作走備庫,這樣就實現了讀寫分離。**在實現讀寫分離的基礎上也能避免單機故障,導致無法對外提供服務。主數據庫宕機,可以自動切換到備庫,以實現系統容災。

讀寫分離帶來的問題:

  • 數據的複製:新寫入的數據只會在主庫中,備庫需要將數據的新增和修改從主庫中複製過來。這個一般依靠數據庫提供的複製功能實現,比如mysql基於binlog實現的的replication。
  • 數據源的選擇:讀寫分離後,我們都知道寫要找主庫,讀要找備庫,但是程序不知道。所以程序中應該根據SQL判斷出是讀操作還是寫操作,進而選擇要訪問的數據庫。這就涉及到SQL語法樹的解析了,比如Druid連接池就提供了SQL Parser模塊

3、垂直分庫

數據量和訪問量仍在持續上升,主備庫的壓力都在上升。這時可以根據業務特點考慮將數據庫進行功能性拆分,也就是把數據庫中不同業務單元的數據表劃分到不同的數據庫中

垂直分庫

比如新聞網站中,註冊用戶的信息與新聞數據是沒有多大關係的,數據庫訪問壓力大時可以嘗試把用戶註冊信息的表放在一個數據庫,新聞相關的表放在另一個數據庫中,這樣減小了數據庫的訪問壓力,同時便於對每個單獨的業務按需進行水平擴展。這就與微服務的思想逐漸靠近,但具體業務拆分如何拆分,怎麼控制拆分粒度,這需要根據業務進行仔細考量了。因爲垂直分庫會帶來以下幾個問題:

  • 事務的ACID將被打破:數據被分到不同的數據庫,原來的事務操作將會受很大影響。比如說註冊用戶時需要在一個事務中往用戶表和用戶信息表插入一條數據,單機數據庫可以利用本地事務很好地完成這件事兒,但是多機就會變得比較麻煩。這個問題就涉及到分佈式事務,分佈式事務的解決方案有很多,比如使用強一致性的分佈式事務框架Seata,或者使用RocketMQ等消息隊列實現最終一致性。
  • Join聯表操作困難:這個也毋庸置疑了,解決方案一般是將聯表查詢改成多個單次查詢,在代碼層進行關聯。
  • 外鍵約束受影響:因爲外鍵約束和唯一性約束一樣本質還是依靠索引實現的,所以分庫後外鍵約束也會收到影響。但外鍵約束本就不太推薦使用,一般都是在代碼層進行約束,這個問題倒也不會有很大影響。

4、垂直分表

除了垂直分庫還有垂直分表的方式:主要以字段爲依據,按照字段的活躍度,將表中的字段拆分到不同的表中。將熱點數據(可能會冗餘經常一起查詢的數據)放在一起作爲主表,非熱點數據放在一起作爲擴展表。這樣主表的單行數據所需的存儲空間變小,更多的熱點數據就能被緩存下來,進而減少了隨機磁盤I/O。拆了之後,要想獲得全部數據就需要關聯兩個表來取數據。

垂直分表

比如用戶表數據,用戶的用戶名、密碼、年齡、性別、手機號等字段會被經常查詢,而用戶的家庭住址、個人介紹等字段又長而且不常訪問,所以將這些字段拆分出來單獨存一張表,可以讓數據庫的緩存更高效。

5、水平分庫分表

數據量繼續增長,特別對於電商、社交媒體這樣的UGC(User Generated Content)業務,數據增長會隨着業務擴大達到驚人的地步,每張表都存放着大量的數據,任何CRUD都將變成一次極其消耗性能的操作。

以MySQL爲例進行簡單的估算,假設主鍵使用8字節的bigint形式存儲,InnoDB默認頁大小爲16KB,一頁能存NN個Key的話,會有N+1N+1個下級指針,假定物理指針也是8字節(4字節就能達到4G的尋址能力,8字節能達到16EB的尋址能力,綽綽有餘了)。

(2N+1)×8B=16KBN2101000 (2N+1) \times 8B=16KB \Rightarrow N \approx 2^{10} \approx 1000

根據推算一頁能存下大概1000個Key。

根據B+樹的結構,不考慮InnoDB的聚簇索引爲了後續插入和修改預留的116\frac{1}{16}空間,那麼填充因子滿足:

12FillFactor1 \frac{1}{2}\leqslant FillFactor\leqslant 1

我們先考慮主鍵是隨機插入的最差情況FillFactor=12FillFactor=\frac{1}{2},意味着一頁能存292^9個Key。

B+29key 按照B+樹的結構計算,根結點存儲2^9個key

29218key2916KB=8MB 第二層就有2^9個節點,能存儲2^{18}個key,佔用2^9*16KB=8MB空間

218227key21816KB=4GB 第三層就有2^{18}個節點,能存儲2^{27}個key,佔用2^{18}*16KB=4GB空間

227236key22716KB=2TB 第四層就有2^{27}個節點,能存儲2^{36}個key,佔用2^{27}*16KB=2TB空間

最好的情況下主鍵單調遞增插入FillFactor=1FillFactor=1,意味着一頁能存2102^{10}個Key。

B+210key 按照B+樹的結構計算,根結點存儲2^{10}個key

210220key21016KB=16MB 第二層就有2^{10}個節點,能存儲2^{20}個key,佔用2^{10}*16KB=16MB空間

220230key22016KB=16GB 第三層就有2^{20}個節點,能存儲2^{30}個key,佔用2^{20}*16KB=16GB空間

230240key23016KB=16TB 第四層就有2^{30}個節點,能存儲2^{40}個key,佔用2^{30}*16KB=16TB空間

假如MySQL服務器有足夠的內存能將前三層索引緩存在內存中,索引只有三層,那麼通過聚簇索引訪問數據只需一次磁盤I/O。而當我們數據量過大,索引層級達到四層或四層以上時,通過聚簇索引訪問就需要兩次以上的磁盤I/O了。

所以當數據達到一定量級後,水平拆分顯得尤爲重要——將一張大表拆分成多張結構相同的子表

比如將一張5千萬的用戶表水平拆分成5張表後,每張表只有1千萬的數據。

不過水平拆分有兩種策略:

5.1、水平分表

水平分表——以字段爲依據,按照一定策略(hash、range等),將一個表中的數據拆分到多個表中。

水平分表

其實MySQL的分區表能提供類似的功能。區別在於MySQL底層會自動分成多個文件存儲,而手動分表需要在代碼層改寫SQL,根據分表字段映射到真正的表名。顯然後者成本有點高。

分表能夠解決單表數據量過大帶來的查詢效率下降的問題,但是卻無法給數據庫的併發處理能力帶來質的提升。所以這個適合數據量上來但是併發訪問量沒上來的情況。

我上一家公司做的系統其實就是這一類型,數據量很大,但是訪問量很小。但是我們並沒有採用水平分表的策略,而是使用水平分庫。由於MySQL實例進程支持多個數據庫,我們將多個庫分配在同一個數據庫實例上以共享數據庫硬件資源。一方面方便後續拆分成多個數據庫實例,一方面避免了前面說的表名映射問題。

5.2、水平分庫

水平分庫

這種方式明顯更容易擴展——庫多了,io和cpu的壓力自然可以成倍緩解。

5.3、水平分庫分錶帶來的問題

  • 自增主鍵會有影響:分表中如果使用的是自增主鍵的話,那麼就不能產生唯一的 ID 了,因爲邏輯上來說多個分表其實都屬於一張表,數據庫的自增主鍵無法標識每一條數據。一般採用分佈式的id生成策略解決這個問題。

    比如我上一家公司在分庫之上有一個目錄庫,裏面存了數據量不是很大的系統公共信息,其中包括一張類似於Oracle的sequence的hibernate_sequence表用於實現的id序列生成。

  • 有些單表查詢會變成多表:比如說 count 操作,原來是一張表的問題,現在要從多張分表中共同查詢才能得到結果。

  • 排序和分頁影響較大:比如 order by id limit 10按照10個一頁取出第一頁,原來只需要一張表執行直接返回給用戶,現在有5個分庫要從5張分表分別拿出10條數據然後排序,返回50條數據中最前面的10條。當翻到第二頁的時候,需要每張表拿出20條數據然後排序,返回100條數據中的第二個11~20條。很明顯這個操作非常損耗性能。

6、分庫擴容問題

通常爲了保證數據平均分配在多個分庫中大多會採用hash的方式進行水平分庫。

hash

當數據量上漲後,容量無法支撐需要擴容,在原來的基礎上再加一個庫。

hash擴容策略

不過此時由於分片規則進行了變化(uid%3 變爲uid%4),大部分的數據,無法命中在原有的數據庫上了,需要重新分配,大量數據需要遷移。

6.1、一致性Hash

一致性Hash是麻省理工的David Karger教授在一篇論文中提出來的,現在被用在很多分佈式系統中。

簡單來說,一致性哈希將整個哈希值空間組織成一個虛擬的圓環,如假設某哈希函數H的值空間爲023210\sim2^{32}-1(即哈希值是一個32位無符號整形),整個哈希空間環如下:

Hash環

下一步將各個服務器使用Hash進行一個哈希,具體可以選擇服務器的ip或主機名作爲關鍵字進行哈希,這樣每臺機器就能確定其在哈希環上的位置,這裏假設將上文中四臺服務器使用ip地址哈希後在環空間的位置如下:

一致性Hash

將數據key使用相同的函數Hash計算出哈希值,並確定此數據在環上的位置,從此位置沿環順時針“行走”,第一臺遇到的服務器就是其應該定位到的服務器。

例如我們有Object A、Object B、Object C、Object D四個數據對象,經過哈希計算後,在環空間上的位置如下:

一致性Hash

一致性哈希算法的容錯性

假設Node C不幸宕機,可以看到此時對象A、B、D不會受到影響,只有C對象被重定位到Node D。一般的,在一致性哈希算法中,如果一臺服務器不可用,則受影響的數據僅僅是此服務器到其環空間中前一臺服務器(即沿着逆時針方向行走遇到的第一臺服務器)之間數據,其它不會受到影響。

一致性哈希算法的可擴展性

如果在系統中增加一臺服務器Node X,如下圖所示:

擴展性

此時對象Object A、B、D不受影響,只有對象C需要重定位到新的Node X 。

另外,一致性哈希算法在服務節點太少時,容易因爲節點分部不均勻而造成數據傾斜問題。例如系統中只有兩臺服務器,其環分佈如下:

Hash環

此時必然造成大量數據集中到Node A上,而只有極少量會定位到Node B上。爲了解決這種數據傾斜問題,一致性哈希算法引入了虛擬節點機制,即對每一個服務節點計算多個哈希,每個計算結果位置都放置一個此服務節點,稱爲虛擬節點。具體做法可以在服務器ip或主機名的後面增加編號來實現。例如上面的情況,可以爲每臺服務器計算三個虛擬節點,於是可以分別計算 “Node A#1”、“Node A#2”、“Node A#3”、“Node B#1”、“Node B#2”、“Node B#3”的哈希值,於是形成六個虛擬節點:

虛擬節點

下面是Java語言的實現代碼(Wikipedia上還有其他語言的實現):

import java.util.Collection;
import java.util.SortedMap;
import java.util.TreeMap;

public class ConsistentHash<T> {

 private final HashFunction hashFunction;
 private final int numberOfReplicas; // 虛擬節點個數
 private final SortedMap<Integer, T> circle = new TreeMap<>();

 public ConsistentHash(HashFunction hashFunction, int numberOfReplicas,
     Collection<T> nodes) {
   this.hashFunction = hashFunction;
   this.numberOfReplicas = numberOfReplicas;

   for (T node : nodes) {
     add(node);
   }
 }

 public void add(T node) {
   for (int i = 0; i < numberOfReplicas; i++) {
     circle.put(hashFunction.hash(node.toString() + i), node);
   }
 }

 public void remove(T node) {
   for (int i = 0; i < numberOfReplicas; i++) {
     circle.remove(hashFunction.hash(node.toString() + i));
   }
 }

 public T get(Object key) {
   if (circle.isEmpty()) {
     return null;
   }
   int hash = hashFunction.hash(key);
   if (!circle.containsKey(hash)) {
     SortedMap<Integer, T> tailMap = circle.tailMap(hash);
     hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
   }
   return circle.get(hash);
 }

}

參考資料:

TDDL 基礎:https://www.atatech.org/articles/59193

認識 TDDL 數據層:https://www.atatech.org/articles/153342

TDDL:來自淘寶的分佈式數據層:https://www.biaodianfu.com/tddl.html

TDDL動態數據源開源-基本說明:http://jm.taobao.org/2012/04/27/tddl-open-source-intro/

分佈式數據庫中間件—TDDL的使用介紹:https://www.2cto.com/database/201806/752199.html

MySQL:互聯網公司常用分庫分表方案彙總:https://yq.aliyun.com/articles/752683

十分鐘入門RocketMQ:http://jm.taobao.org/2017/01/12/rocketmq-quick-start-in-10-minutes/

InnoDB物理結構:https://dev.mysql.com/doc/refman/8.0/en/innodb-physical-structure.html

分庫分表平滑擴容:https://www.cnblogs.com/barrywxx/p/11532122.html

五分鐘看懂一致性哈希算法:https://juejin.im/post/5ae1476ef265da0b8d419ef2

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章