分庫分表核心理念,你學(xué)會了嗎?
首先,我們需要知道所謂的"分庫分表",根本就不是一件事,而是三件事,它們要解決的問題也都不一樣。
這三件事分別是"只分庫不分表"、"只分表不分庫"、以及"既分庫又分表"。
什么時候分庫?
其實(shí),分庫主要解決的是并發(fā)量大的問題。因?yàn)椴l(fā)量一旦上來了,那么數(shù)據(jù)庫就可能會成為瓶頸,因?yàn)閿?shù)據(jù)庫的連接數(shù)是有限的,雖然可以調(diào)整,但也不是無限調(diào)整的。
所以,當(dāng)你的數(shù)據(jù)庫的讀或者寫的 QPS 過高,導(dǎo)致數(shù)據(jù)庫連接數(shù)不足的時候,就需要考慮分庫了,通過增加數(shù)據(jù)庫實(shí)例的方式來提供更多的可用數(shù)據(jù)庫連接,從而提升系統(tǒng)的并發(fā)度。
比較典型的分庫場景就是在做微服務(wù)拆分的時候,會按照業(yè)務(wù)邊界,把各個業(yè)務(wù)的數(shù)據(jù)從一個單一的數(shù)據(jù)庫中拆分開,分別把訂單、物流、商品、會員等單獨(dú)放到對應(yīng)的數(shù)據(jù)庫中。
圖片
還有就是有的時候可能會把歷史訂單挪到歷史庫里面去。這也是分庫的一種具體做法。
什么時候分表?
分庫主要解決的是并發(fā)量大的問題,那分表其實(shí)主要解決的是數(shù)據(jù)量大的問題。
假如你的單表數(shù)據(jù)量非常大,因?yàn)椴l(fā)不高,數(shù)據(jù)庫連接可能還夠,但是存儲和查詢的性能遇到了瓶頸,做了很多優(yōu)化之后還是無法提升效率的時候,就需要考慮做分表了。
圖片
一般我們認(rèn)為,單表行數(shù)超過 500 萬行或者單表容量超過 2GB 時,才需要考慮做分庫分表。
那我們是不是等到數(shù)據(jù)量到達(dá) 500 萬后,才開始分庫分表呢?
這個也不絕對,應(yīng)該提前規(guī)劃分庫分表,如果估算 3 年后,表的數(shù)據(jù)量都不會到達(dá) 500 萬,則不需要分庫分表。
分庫分表的時候需要考慮數(shù)據(jù)未來 2~3 年的一個增量,即使現(xiàn)在數(shù)據(jù)量不多,但是每天的數(shù)據(jù)增量很可觀,幾個月之后就可以突破 500 萬上限,那么不是等到數(shù)據(jù)量到達(dá) 500 萬的時候才分庫分表,而是現(xiàn)在就應(yīng)該考慮了。
什么時候既分庫又分表?
那么什么時候分庫又分表呢,那就是既需要解決并發(fā)量大的問題,又需要解決數(shù)據(jù)量大的問題的時候。通常情況下,高并發(fā)和大數(shù)據(jù)量的問題都是同時發(fā)生的,所以,我們會經(jīng)常遇到分庫分表需要同時進(jìn)行的情況。
橫向拆分 & 縱向拆分
談及到分庫分表,那就要涉及到該如何做拆分的問題。
通常在做拆分的時候有兩種分法,分別是橫向拆分(水平拆分)和縱向拆分(垂直拆分)。
假如我們有一張表,如果把這張表中某一條記錄的多個字段,拆分到多張表中,這種就是縱向拆分。那如果把一張表中的不同的記錄分別放到不同的表中,這種就是橫向拆分。
橫向拆分的結(jié)果是數(shù)據(jù)庫表中的數(shù)據(jù)會分散到多張分表中,使得每一個單表中的數(shù)據(jù)的條數(shù)都有所下降。比如我們可以把不同的用戶的訂單,分表拆分放到不同的表中。
圖片
縱向拆分的結(jié)果是數(shù)據(jù)庫表中的數(shù)據(jù)的字段數(shù)會變少,使得每一個單表中的數(shù)據(jù)的存儲有所下降。比如可以把商品詳情信息、價格信息、庫存信息等等分別拆分到不同的表中。
圖片
縱向拆分比較適合做冷熱分離,可以使得行數(shù)據(jù)變小,一個數(shù)據(jù)頁就能存放更多的數(shù)據(jù),在查詢時就會減少I/O次數(shù)。
分表算法
選定了分表字段之后,如何基于這個分表字段來準(zhǔn)確的把數(shù)據(jù)分表到某一張表中呢?
這就是分表算法要做的事情了,但是不管什么算法,我們都需要確保一個前提,那就是同一個分表字段,經(jīng)過這個算法處理后,得到的結(jié)果一定是一致的,不可變的。
通常的分表算法有以下幾種:
Range 范圍
Range,即范圍策略劃分表。比如我們可以將表的主鍵 order_id,按照從 0~300萬 的劃分為一個表,300萬 ~ 600萬劃分到另外一個表。
有時候我們也可以按時間范圍來劃分,如不同年月的訂單放到不同的表。
- 優(yōu)點(diǎn):范圍分表,有利于擴(kuò)容。
- 缺點(diǎn):最近一段時間的數(shù)據(jù)都是匯聚在一張表里面,可能會有熱點(diǎn)問題。比如最近一個月的訂單都在 0~300萬之間,平時用戶一般都查最近一個月的訂單比較多,那么請求就都打到 order_01 了。
Hash 取模
Hash 取模策略:
指定的路由key(一般是 user_id、order_id 等作為key)對分表總數(shù)進(jìn)行取模,把數(shù)據(jù)分散到各個表中。
比如原始訂單表信息,我們把它分成4張分表:
圖片
比如 id=1,對 4 取模,就會得到1,就把它放到 t_order_1 ;
一般,我們會取哈希值,再做取余:
Math.abs(orderId.hashCode()) % table_number
- 優(yōu)點(diǎn):Hash取模的方式,不會存在明顯的熱點(diǎn)問題。
- 缺點(diǎn):如果未來某個時候,表數(shù)據(jù)量又到瓶頸了,需要擴(kuò)容,就比較麻煩。所以一般建議提前規(guī)劃好,一次性分夠(可以考慮一致性哈希)。
一致性 Hash
為了解決 Hash 擴(kuò)容的問題,我們可以采用一致性哈希的方式來做分表。
圖片
一致性哈??梢园凑粘S玫?Hash 算法來將對應(yīng)的 key 哈希到一個具有 2^32 次方個節(jié)點(diǎn)的空間中,形成一個順時針首尾相接的閉合環(huán)形,這個環(huán)稱為哈希環(huán)。
當(dāng)添加一臺新的數(shù)據(jù)庫服務(wù)器時,只有增加服務(wù)器的位置和逆時針方向第一臺服務(wù)器之間的鍵會受影響。
簡單來說,一致性哈希算法能夠使機(jī)器節(jié)點(diǎn)的變動對整個集群的影響達(dá)到最小。
一致性哈希也存在一些問題,如:節(jié)點(diǎn)漂移、數(shù)據(jù)傾斜。這些都有對應(yīng)的解決方案,這里不再贅述。
參考:一致性哈希問題及其解決方案。
斐波那契散列
前面幾種分表算法,大家會接觸多一點(diǎn),斐波那契散列實(shí)際在分表算法中幾乎不被使用。
JDK 的 ThreadLocal 源碼中有一段有意思的代碼,如下所示:
圖片
定義了一個魔法值 HASH_INCREMENT = 0x61c88647,這個值被稱之為 “魔數(shù)”。
0x61c88647 與一個神奇的數(shù)字產(chǎn)生了聯(lián)系,它就是 (Math.sqrt(5) - 1)/2。也就是傳說中的黃金比例 0.618
(0.618 只是一個粗略值),即0x61c88647 = 2^32 * 黃金分割比
,同時也對應(yīng)了上文提到的斐波那契散列。
它常用于在散列中增加哈希值。上面的代碼注釋中也解釋到是為了讓哈希碼能均勻的分布在 2 的 N 次方的數(shù)組里。
至于為什么使用斐波那契數(shù)列后散列更均勻,就涉及到相關(guān)數(shù)學(xué)問題了,此處不做更多解釋。
嚴(yán)格雪崩標(biāo)準(zhǔn)(SAC)
上面介紹了一些分表算法,那么一個好的分表算法有沒有參考標(biāo)準(zhǔn)呢?
在密碼學(xué)中,雪崩效應(yīng)(avalanche effect)指加密算法的一種理想屬性。雪崩效應(yīng)是指當(dāng)輸入發(fā)生最微小的改變(例如,反轉(zhuǎn)一個二進(jìn)制位)時,也會導(dǎo)致輸出的不可區(qū)分性改變(輸出中每個二進(jìn)制位有50%的概率發(fā)生反轉(zhuǎn))。
嚴(yán)格雪崩標(biāo)準(zhǔn)(SAC),建立于密碼學(xué)的完全性概念上,是雪崩效應(yīng)的形式化。它指出,當(dāng)任何一個輸入位被反轉(zhuǎn)時,輸出中的每一位均有 50% 的概率發(fā)生變化。
簡單來說,當(dāng)我們對數(shù)據(jù)庫從 8庫32表 擴(kuò)容到 16庫32表 的時候,每一個表中的數(shù)據(jù)總量都應(yīng)該以 50% 的數(shù)量進(jìn)行減少。這樣才是合理的。
引入嚴(yán)格雪崩標(biāo)準(zhǔn)(SAC) 之后,斐波那契散列是不滿足這個標(biāo)準(zhǔn)的,也就是說使用斐波那契散列,在分庫分表擴(kuò)容情況下,可能導(dǎo)致數(shù)據(jù)分布不均勻,這也是為什么斐波那契散列幾乎不用于分表算法的原因。
訂單分庫分表實(shí)戰(zhàn)
背景:訂單表的讀寫場景復(fù)雜,?般有買家維度、賣家維度、訂單號維度 3 個主要維度。多讀寫維度情況下?論采取哪種維度做分庫分表,對另外兩種維度的查詢性能來說,基本都是災(zāi)難。
解決方案:雙拆分列哈希(RANGE_HASH)。
選取兩個拆分鍵,兩個拆分鍵的后 N 位需確保一致,根據(jù)任一拆分鍵后 N 位計算哈希值,然后再按分庫數(shù)取模,完成路由計算。
先采用 RANGE_HASH 拆分算法按買家 id 后 N 位、訂單號后 N 位維度做分庫分表,作為買家表邏輯表。再用 HASH 拆分函數(shù)按商家 id 冗余一份數(shù)據(jù),作為賣家表邏輯表。
訂單號生成規(guī)則需要根據(jù)買家表分表特性訂單號后 N 位等于買家 id 后 N 位做設(shè)計。
比如用戶id為 12345678,則用戶在下單時生成的單號為:xxxxxxxxx345678,單號前幾位可以根據(jù)公司自己規(guī)則設(shè)定,但是要注意不能重復(fù)。
全局 ID 的生成
涉及到分庫分表,就會引申出分布式系統(tǒng)中唯一主鍵 ID 的生成問題,有以下幾種方式:
UUID
UUID 是可以做到全局唯一的,而且生成方式也簡單,但是我們通常不推薦使用它做唯一ID,首先 UUID 太長了,其次字符串的查詢效率也比較慢,而且沒有業(yè)務(wù)含義,根本看不懂。
基于某個單表做自增主鍵
多張單表生成的自增主鍵會沖突,但是如果所有表的主鍵都從同一張表生成是不是就可以了。
所有的表在需要主鍵的時候,都到這張表中獲取一個自增的 ID。
這樣做是可以做到唯一,也能實(shí)現(xiàn)自增,但是問題是這個單表就變成整個系統(tǒng)的瓶頸,而且也存在單點(diǎn)問題,一旦他掛了,那整個數(shù)據(jù)庫就都無法寫入了。
雪花算法
圖片
雪花算法也是比較常用的一種分布式 ID 的生成方式,它具有全局唯一、遞增、高可用的特點(diǎn)。
雪花算法生成的主鍵主要由 4 部分組成,1bit 符號位、41bit 時間戳位、10bit 工作進(jìn)程位以及 12bit 序列號位。
時間戳占用 41bit,精確到毫秒,總共可以容納約 69 年的時間。
工作進(jìn)程位占用 10bit,其中高位 5bit 是數(shù)據(jù)中心 ID,低位 5bit 是工作節(jié)點(diǎn) ID,最多可以容納 1024 個節(jié)點(diǎn)。
序列號占用 12bit,每個節(jié)點(diǎn)每毫秒從0開始不斷累加,最多可以累加到 4095,一共可以產(chǎn)生 4096 個 ID。
所以,雪花算法在同一毫秒內(nèi)最多可以生成 1024 X 4096 = 4194304 個唯一的 ID。
時間回?fù)軉栴}
熟悉雪花算法的可能了解到雪花算法存在名為“時間回?fù)堋?的問題。
時間回?fù)埽河捎跈C(jī)器的時間是動態(tài)調(diào)整的,有可能會出現(xiàn)時間跑到之前幾毫秒,如果這個時候獲取到了這種時間,則會出現(xiàn)數(shù)據(jù)重復(fù)。
時間回?fù)軉栴}解決思路可以參考美團(tuán)開源的 Leaf。
美團(tuán) Leaf 引入了 Zookeeper 來解決時鐘回?fù)軉栴},其大致思路為:每個 Leaf 運(yùn)行時定時向 zk 上報時間戳。每次 Leaf 服務(wù)啟動時,先校驗(yàn)本機(jī)時間與上次發(fā) ID 的時間,再校驗(yàn)與 zk 上所有節(jié)點(diǎn)的平均時間戳。如果任何一個階段有異常,那么就啟動失敗報警。
這個解決方案還是比較好理解的,就是對比上次發(fā) ID 的時間,還有其他機(jī)器的平均時間,通過本地存儲時間戳 + 定時上報時間戳的方式,解決了時間回?fù)艿膯栴}。
分庫分表遷移
有一個未分庫分表的系統(tǒng),現(xiàn)在要分庫分表,如何才可以讓系統(tǒng)從未分庫分表切換到分庫分表上?
停機(jī)遷移方案
先說一個最 low 的方案,就是很簡單,大伙凌晨 12點(diǎn) 開始運(yùn)維,網(wǎng)站或者 app 掛個公告,說 0 點(diǎn)到早上 6 點(diǎn)進(jìn)行服務(wù)器維護(hù),無法訪問......
接著到 0 點(diǎn),停機(jī),系統(tǒng)停掉,沒有流量寫入了,此時老的單庫單表數(shù)據(jù)庫靜止了。然后提前寫好一個導(dǎo)數(shù)的一次性工具,此時直接跑起來,然后將單庫單表的數(shù)據(jù)讀出來,寫到分庫分表里面去。
導(dǎo)數(shù)完了之后,就 ok 了,修改系統(tǒng)的數(shù)據(jù)庫連接配置啥的,包括可能代碼和 SQL 也許有修改,那你就用最新的代碼,然后直接啟動連到新的分庫分表上去。
但是這個方案比較 low,有個致命的問題就是業(yè)務(wù)要中斷,來看看高大上一點(diǎn)的方案。
雙寫遷移方案
這個是常用的一種遷移方案,比較靠譜一些,不用停機(jī)。
大致步驟如下:
- 先改造我們的數(shù)據(jù)寫入端, 使數(shù)據(jù)同時寫入舊數(shù)據(jù)庫和新數(shù)據(jù)庫。
- 對存量數(shù)據(jù)進(jìn)行不停機(jī)的遷移。
- 等到雙寫服務(wù)運(yùn)行一段時間,再次進(jìn)行舊數(shù)據(jù)和新數(shù)據(jù)的校驗(yàn)同步。
- 完全切換讀取的數(shù)據(jù)源為新數(shù)據(jù)庫,關(guān)閉舊數(shù)據(jù)庫的寫入和讀取,下線舊數(shù)據(jù)庫。
這種方式的好處是:遷移的過程可以隨時回滾,將遷移的風(fēng)險降到了最低。劣勢是:時間周期比較長,應(yīng)用有改造的成本。
分庫分表帶來的問題
分庫分表之后,會帶來很多問題。
首先,做了分庫分表之后,所有的讀和寫操作,都需要帶著分表字段,這樣才能知道具體去哪個庫、哪張表中去查詢數(shù)據(jù)。如果不帶的話,就得支持全表掃描。
還有,一旦我們要從多個數(shù)據(jù)庫中查詢或者寫入數(shù)據(jù),就有很多事情都不能做了,比如跨庫事務(wù)就是不支持的。
圖片
所以,分庫分表之后就會帶來因?yàn)椴恢С质聞?wù)而導(dǎo)致的數(shù)據(jù)一致性的問題。
其次,做了分庫分表之后,以前單表中很方便的分頁查詢、排序等等操作就都失效了。因?yàn)槲覀儾荒芸缍啾磉M(jìn)行分頁、排序。
總之,分庫分表雖然能解決一些大數(shù)據(jù)量、高并發(fā)的問題,但是同時也會帶來一些新的問題。所以,在做數(shù)據(jù)庫優(yōu)化的時候,還是建議大家優(yōu)先選擇其他的優(yōu)化方式,最后再考慮分庫分表。