一文讀懂?dāng)?shù)據(jù)庫優(yōu)化之分庫分表
作者:tayroctang,騰訊 PCG 后臺開發(fā)工程師
本文從 5W1H 角度介紹了分庫分表手段,其在解決如 IO 瓶頸、讀寫性能、物理存儲(chǔ)瓶頸、內(nèi)存瓶頸、單機(jī)故障影響面等問題的同時(shí)也帶來如事務(wù)性、主鍵沖突、跨庫 join、跨庫聚合查詢等問題。anyway,在綜合業(yè)務(wù)場景考慮,正如緩存的使用一樣,本著非必須勿使用原則。如數(shù)據(jù)庫確實(shí)成為性能瓶頸時(shí),在設(shè)計(jì)分庫分表方案時(shí)也應(yīng)充分考慮方案的擴(kuò)展性,或者考慮采用成熟熱門的分布式數(shù)據(jù)庫解決方案,如 TiDB。
閱讀此文你將了解:
- 什么是分庫分表以及為什么分庫分表
- 如何分庫分表
- 分庫分表常見幾種方式以及優(yōu)缺點(diǎn)
- 如何選擇分庫分表的方式
數(shù)據(jù)庫常見優(yōu)化方案
對于后端程序員來說,繞不開數(shù)據(jù)庫的使用與方案選型,那么隨著業(yè)務(wù)規(guī)模的逐漸擴(kuò)大,其對于存儲(chǔ)的使用上也需要隨之進(jìn)行升級和優(yōu)化。
隨著規(guī)模的擴(kuò)大,數(shù)據(jù)庫面臨如下問題:
- 讀壓力:并發(fā) QPS、索引不合理、SQL 語句不合理、鎖粒度
- 寫壓力:并發(fā) QPS、事務(wù)、鎖粒度
- 物理性能:磁盤瓶頸、CPU 瓶頸、內(nèi)存瓶頸、IO 瓶頸
- 其他:宕機(jī)、網(wǎng)絡(luò)異常
面對上述問題,常見的優(yōu)化手段有:

索引優(yōu)化、主從同步、緩存、分庫分表每個(gè)技術(shù)手段都可以作為一個(gè)專題進(jìn)行講解,本文主要介紹分庫分表的技術(shù)方案實(shí)現(xiàn)。
什么是分庫分表?
對于閱讀本文的讀者來說,分庫分表概念應(yīng)該并不會(huì)陌生,其拆開來講是分庫和分表兩個(gè)手段:
- 分表:將一個(gè)表中的數(shù)據(jù)按照某種規(guī)則分拆到多張表中,降低鎖粒度以及索引樹,提升數(shù)據(jù)查詢效率。
- 分庫:將一個(gè)數(shù)據(jù)庫中的數(shù)據(jù)按照某種規(guī)則分拆到多個(gè)數(shù)據(jù)庫中,以緩解單服務(wù)器的壓力(CPU、內(nèi)存、磁盤、IO)。
為什么分庫分表?
- 性能角度:CPU、內(nèi)存、磁盤、IO 瓶頸
- 隨著業(yè)務(wù)體量擴(kuò)大,數(shù)據(jù)規(guī)模達(dá)到百萬行,數(shù)據(jù)庫索引樹龐大,查詢性能出現(xiàn)瓶頸。
- 用戶并發(fā)流量規(guī)模擴(kuò)大,由于單庫(單服務(wù)器)物理性能限制也無法承載大流量。
- 可用性角度:單機(jī)故障率影響面
- 如果是單庫,數(shù)據(jù)庫宕機(jī)會(huì)導(dǎo)致 100%服務(wù)不可用,N 庫則可以將影響面降低 N 倍。
分庫分表帶來的問題?
- 事務(wù)性問題
- 方案一:在進(jìn)行分庫分表方案設(shè)計(jì)過程中,從業(yè)務(wù)角度出發(fā),盡可能保證一個(gè)事務(wù)所操作的表分布在一個(gè)庫中,從而實(shí)現(xiàn)數(shù)據(jù)庫層面的事務(wù)保證。
- 方案二:方式一無法實(shí)現(xiàn)的情況下,業(yè)務(wù)層引入分布式事務(wù)組件保證事務(wù)性,如事務(wù)性消息、TCC、Seata 等分布式事務(wù)方式實(shí)現(xiàn)數(shù)據(jù)最終一致性。
- 分庫可能導(dǎo)致執(zhí)行一次事務(wù)所需的數(shù)據(jù)分布在不同服務(wù)器上,數(shù)據(jù)庫層面無法實(shí)現(xiàn)事務(wù)性操作,需要更上層業(yè)務(wù)引入分布式事務(wù)操作,難免會(huì)給業(yè)務(wù)帶來一定復(fù)雜性,那么要想解決事務(wù)性問題一般有兩種手段:
- 主鍵(自增 ID)唯一性問題
- 在數(shù)據(jù)庫表設(shè)計(jì)時(shí),經(jīng)常會(huì)使用自增 ID 作為數(shù)據(jù)主鍵,這就導(dǎo)致后續(xù)在遷庫遷表、或者分庫分表操作時(shí),會(huì)因?yàn)橹麈I的變化或者主鍵不唯一產(chǎn)生沖突,要解決主鍵不唯一問題,有如下方案:
- 方案一:自增 ID 做主鍵時(shí),設(shè)置自增步長,采用等差數(shù)列遞增,避免各個(gè)庫表的主鍵沖突。但是這個(gè)方案仍然無法解決遷庫遷表、以及分庫分表擴(kuò)容導(dǎo)致主鍵 ID 變化問題
- 方案二:主鍵采用全局統(tǒng)一 ID 生成機(jī)制:如 UUID、雪花算法、數(shù)據(jù)庫號段等方式。
- 跨庫多表 join 問題
- 首先來自大廠 DBA 的建議是,線上服務(wù)盡可能不要有表的 join 操作,join 操作往往會(huì)給后續(xù)的分庫分表操作帶來各種問題,可能導(dǎo)致數(shù)據(jù)的死鎖??梢圆捎枚啻尾樵儤I(yè)務(wù)層進(jìn)行數(shù)據(jù)組裝(需要考慮業(yè)務(wù)上多次查詢的事務(wù)性的容忍度)
- 跨庫聚合查詢問題
分庫分表會(huì)導(dǎo)致常規(guī)聚合查詢操作,如 group by,order by 等變的異常復(fù)雜。需要復(fù)雜的業(yè)務(wù)代碼才能實(shí)現(xiàn)上述業(yè)務(wù)邏輯,其常見操作方式有:
§ 方案一:賽道賽馬機(jī)制,每次從 N 個(gè)庫表中查詢出 TOP N 數(shù)據(jù),然后在業(yè)務(wù)層代碼中進(jìn)行聚合合并操作。
javascript" tabindex="0" style="box-sizing: border-box; list-style: inherit; padding: 1em 1em 1em 3.8em; color: rgb(204, 204, 204); background-color: rgb(80, 85, 107); border-radius: 3px; overflow: auto; font-family: Consolas, Monaco, " andale="" ubuntu="" overflow-wrap:="" word-spacing:="" word-break:="" line-height:="" tab-size:="" hyphens:="" position:="" counter-reset:="" linenumber="">§ 假設(shè): 以2庫1表為例,每次分頁查詢N條數(shù)據(jù)。 § § 第一次查詢: § ① 每個(gè)表中分別查詢出N條數(shù)據(jù): § SELECT * FROM db1_table1 where $col > 0 order by $col LIMITT 0,N§ SELECT * FROM db2_table1 where $col > 0 order by $col LIMITT 0,N§ ② 業(yè)務(wù)層代碼對上述兩者做歸并排序,假設(shè)最終取db1數(shù)據(jù)K1條,取db2數(shù)據(jù)K2條,則K1+K2 = N§ 此時(shí)的DB1 可以計(jì)算出OffSet為K1 ,DB2計(jì)算出Offset為K2§ 將獲取的N條數(shù)據(jù)以及相應(yīng)的Offset K1/K2返回給 端上。 § § 第二次查詢: § ① 端上將上一次查詢對應(yīng)的數(shù)據(jù)庫的Offset K1/K2 傳到后端 § ② 后端根據(jù)Offset構(gòu)造查詢語句查詢分別查詢出N條語句 § SELECT * FROM db1_table1 where $col > 0 order by $col LIMITT $K1,N§ SELECT * FROM db2_table1 where $col > 0 order by $col LIMITT $K2,N§ ③ 再次使用歸并排序,獲取TOP N數(shù)據(jù),將獲取的N條數(shù)據(jù)以及相應(yīng)的Offset K1/K2返回給 端上。 § § 第三次查詢:依次類推.......
§ 方案二:可以將經(jīng)常使用到 groupby,orderby 字段存儲(chǔ)到一個(gè)單一庫表(可以是 REDIS、ES、MYSQL)中,業(yè)務(wù)代碼中先到單一表中根據(jù)查詢條件查詢出相應(yīng)數(shù)據(jù),然后根據(jù)查詢到的主鍵 ID,到分庫分表中查詢詳情進(jìn)行返回。2 次查詢操作難點(diǎn)會(huì)帶來接口耗時(shí)的增加,以及極端情況下的數(shù)據(jù)不一致問題。
什么是好的分庫分表方案?
- 滿足業(yè)務(wù)場景需要:根據(jù)業(yè)務(wù)場景的不同選擇不同分庫分表方案:比如按照時(shí)間劃分、按照用戶 ID 劃分、按照業(yè)務(wù)能力劃分等
- 方案可持續(xù)性:
- 何為可持續(xù)性?其實(shí)就是:業(yè)務(wù)數(shù)據(jù)量級和流量量級未來進(jìn)一步達(dá)到新的量級的時(shí)候,我們的分庫分表方案可以持續(xù)靈活擴(kuò)容處理。
- 最小化數(shù)據(jù)遷移:擴(kuò)容時(shí)一般涉及到歷史數(shù)據(jù)遷移,其擴(kuò)容后需要遷移的數(shù)據(jù)量越小其可持續(xù)性越強(qiáng),理想的遷移前后的狀態(tài)是(同庫同表>同表不同庫>同庫不同表>不同庫不同表)
- 數(shù)據(jù)偏斜:數(shù)據(jù)在庫表中分配的均衡性,盡可能保證數(shù)據(jù)流量在各個(gè)庫表中保持等量分配,避免熱點(diǎn)數(shù)據(jù)對于單庫造成壓力。
- 最大數(shù)據(jù)偏斜率:(數(shù)據(jù)量最大樣本 - 數(shù)據(jù)量最小樣本)/ 數(shù)據(jù)量最小樣本。一般來說,如果我們的最大數(shù)據(jù)偏斜率在 5%以內(nèi)是可以接受的。
如何分庫分表

垂直拆分:
- 垂直拆表
- 即大表拆小表,將一張表中數(shù)據(jù)不同”字段“分拆到多張表中,比如商品庫將商品基本信息、商品庫存、賣家信息等分拆到不同庫表中。
- 考慮因素有將不常用的,數(shù)據(jù)較大,長度較長(比如 text 類型字段)的拆分到“擴(kuò)展表“,表和表之間通過”主鍵外鍵“進(jìn)行關(guān)聯(lián)。
- 好處:降低表數(shù)據(jù)規(guī)模,提升查詢效率,也避免查詢時(shí)數(shù)據(jù)量太大造成的“跨頁”問題。
- 垂直拆庫
- 垂直拆庫則在垂直拆表的基礎(chǔ)上,將一個(gè)系統(tǒng)中的不同業(yè)務(wù)場景進(jìn)行拆分,比如訂單表、用戶表、商品表。
- 好處:降低單數(shù)據(jù)庫服務(wù)的壓力(物理存儲(chǔ)、內(nèi)存、IO 等)、降低單機(jī)故障的影響面
水平拆分:
- 操作:將總體數(shù)據(jù)按照某種維度(時(shí)間、用戶)等分拆到多個(gè)庫中或者表中,典型特征不同的庫和表結(jié)構(gòu)完全一下,如訂單按照(日期、用戶 ID、區(qū)域)分庫分表。
- 水平拆表
- 將數(shù)據(jù)按照某種維度拆分為多張表,但是由于多張表還是從屬于一個(gè)庫,其降低鎖粒度,一定程度提升查詢性能,但是仍然會(huì)有 IO 性能瓶頸。
- 水平拆庫
- 將數(shù)據(jù)按照某種維度分拆到多個(gè)庫中,降低單機(jī)單庫的壓力,提升讀寫性能。
常見水平拆分手段
range 分庫分表
顧名思義,該方案根據(jù)數(shù)據(jù)范圍劃分?jǐn)?shù)據(jù)的存放位置。
思路一:時(shí)間范圍分庫分表
舉個(gè)最簡單例子,我們可以把訂單表按照年份為單位,每年的數(shù)據(jù)存放在單獨(dú)的庫(或者表)中。
時(shí)下非常流行的分布式數(shù)據(jù)庫:TiDB 數(shù)據(jù)庫,針對 TiKV 中數(shù)據(jù)的打散,也是基于 Range 的方式進(jìn)行,將不同范圍內(nèi)的[StartKey,EndKey)分配到不同的 Region 上。
缺點(diǎn):
- 需要提前建庫或表。
- 數(shù)據(jù)熱點(diǎn)問題:當(dāng)前時(shí)間的數(shù)據(jù)會(huì)集中落在某個(gè)庫表。
- 分頁查詢問題:涉及到庫表中間分界線查詢較復(fù)雜。
例子:交易系統(tǒng)流水表則是按照天級別分表。
hash 分庫分表
hash 分表是使用最普遍的使用方式,其根據(jù)“主鍵”進(jìn)行 hash 計(jì)算數(shù)據(jù)存儲(chǔ)的庫表索引。原理可能大家都懂,但有時(shí)拍腦袋決定的分庫分表方案可能會(huì)導(dǎo)致嚴(yán)重問題。
思路一:獨(dú)立 hash
對于分庫分表,最常規(guī)的一種思路是通過主鍵計(jì)算 hash 值,然后 hash 值分別對庫數(shù)和表數(shù)進(jìn)行取余操作獲取到庫索引和表索引。比如:電商訂單表,按照用戶 ID 分配到 10 庫 100 表中。
JavaScript" tabindex="0" style="box-sizing: border-box; list-style: inherit; padding: 1em 1em 1em 3.8em; color: rgb(204, 204, 204); background-color: rgb(80, 85, 107); border-radius: 3px; overflow: auto; font-family: Consolas, Monaco, " andale="" ubuntu="" overflow-wrap:="" word-spacing:="" word-break:="" line-height:="" tab-size:="" hyphens:="" position:="" counter-reset:="" linenumber="">const ( // DbCnt 庫數(shù)量 DbCnt = 10 // TableCnt 表數(shù)量 TableCnt = 100)// GetTableIdx 根據(jù)用戶 ID 獲取分庫分表索引func GetTableIdx(userID int64) (int64, int64) { hash := hashCode(userID) return hash % DbCnt, hash % TableCnt}
上述是偽代碼實(shí)現(xiàn),大家可以先思考一下上述代碼可能會(huì)產(chǎn)生什么問題?
比如 1000? 1010?,1020 庫表索引是多少?
思考一下........
思考一下........
思考一下........
思考一下........
思考一下........
思考一下........
答:數(shù)據(jù)偏斜問題。

非互質(zhì)關(guān)系導(dǎo)致的數(shù)據(jù)偏斜問題證明:
假設(shè)分庫數(shù)分表數(shù)最大公約數(shù)為a,則分庫數(shù)表示為 m*a , 分表數(shù)為 n*a (m,n為正整數(shù)) 某條數(shù)據(jù)的hash規(guī)則計(jì)算的值為H, 若某條數(shù)據(jù)在庫D中,則H mod (m*a) == D 等價(jià)與 H=M*m*a+D (M為整數(shù)) 則表序號為 T = H % (n*a) = (M*m*a+D)%(n*a)如果D==0 則T= [(M*m)%n]*a
思路二:統(tǒng)一 hash
思路一中,由于庫和表的 hash 計(jì)算中存在公共因子,導(dǎo)致數(shù)據(jù)偏斜問題,那么換種思考方式:10 個(gè)庫 100 張表,一共 1000 張表,那么從 0 到 999 排序,根據(jù) hash 值對 1000 取余,得到[0,999]的索引,似乎就可以解決數(shù)據(jù)偏斜問題:
// GetTableIdx 根據(jù)用戶 ID 獲取分庫分表索引// 例子:1123011 -> 1,1func GetTableIdx(userID int64) (int64, int64) { hash := hashCode(userID) slot := DbCnt * TableCnt return hash % slot % DbCnt, hash % slot / DbCnt}
上面會(huì)帶來的問題?
比如 1123011 號用戶,擴(kuò)容前是 1 庫 1 表,擴(kuò)容后是 0 庫 11 表

擴(kuò)展性問題證明。
某條數(shù)據(jù)的hash規(guī)則計(jì)算的值為H,分庫數(shù)為D,分表數(shù)為T擴(kuò)容前: 分片序號K1 = H % (D*T),則H = M*DT + K1 ,且K1 一定是小于(D*T)D1 = K1 % DT1 = K1 / D擴(kuò)容后: 如果M為偶數(shù),即M= 2*NK2 = H% (2DT) = (2NDT+K1)%(2DT) = K1%(2DT) ,K1 一定小于(2DT),所以K2=K1D2 = K2%(2D) = K1 %(2D)T2 = K2/(2D) = K1 / (2D) 如果M為奇數(shù),即M = 2*N+1K2 = H%(2DT) = (2NDT +DT +K1)%(2DT) = (DT+K1)%(2DT) = DT + K1D2 = K2 %(2D) = (DT+K1) % (2D)T2 = K2 /(2D) = (DT+K1) / (2D)結(jié)論:擴(kuò)容后庫序號和表序號都變化
思路三:二次分片法
思路二中整體思路正確,只是最后計(jì)算庫序號和表序號的時(shí)候,使用了庫數(shù)量作為影響表序號的因子,導(dǎo)致擴(kuò)容時(shí)表序號偏移而無法進(jìn)行。事實(shí)上,我們只需要換種寫法,就能得出一個(gè)比較大眾化的分庫分表方案。
func GetTableIdx(userId int64){ //①算Hash hash:=hashCode(userId) //②分片序號 slot:=hash%(DbCnt*TableCnt) //③重新修改二次求值方案 dbIdx:=slot/TableCnt tblIdx:=slot%TableCnt return dbIdx,tblIdx}
從上述代碼中可以看出,其唯一不同是在計(jì)算庫索引和表索引時(shí),采用 TableCnt 作為基數(shù)(注:擴(kuò)容操作時(shí),一般采用庫個(gè)數(shù) 2 倍擴(kuò)容),這樣在擴(kuò)容時(shí),表個(gè)數(shù)不變,則表索引不會(huì)變。
可以做簡要的證明:
某條數(shù)據(jù)的hash規(guī)則計(jì)算的值為H,分庫數(shù)為D,分表數(shù)為T擴(kuò)容前: 分片序號K1 = H % (D*T),則H = M*DT + K1 ,且K1 一定是小于(D*T)D1 = K1 / TT1 = K1 % T擴(kuò)容后: 如果M為偶數(shù),即M= 2*NK2 = H% (2DT) = (2NDT+K1)%(2DT) = K1%(2DT) ,K1 一定小于(2DT),所以K2=K1D2 = K2/T = K1 /T = D1T2 = K2%T = K1 % T = T1如果M為奇數(shù),即M = 2*N+1K2 = H%(2DT) = (2NDT +DT +K1)%(2DT) = (DT+K1)%(2DT) = DT + K1D2 = K2 /T = (DT+K1) / T = D + K1/T = D + D1T2 = K2 %T = (DT+K1) % T = K1 %T = T1結(jié)論:M為偶數(shù)時(shí),擴(kuò)容前后庫序號和表序號都不變M為奇數(shù)時(shí),擴(kuò)容前后表序號不變,庫序號會(huì)變化。
思路四:基因法
由思路二啟發(fā),我們發(fā)現(xiàn)案例一不合理的主要原因,就是因?yàn)閹煨蛱柡捅硇蛱柕挠?jì)算邏輯中,有公約數(shù)這個(gè)因子在影響庫表的獨(dú)立性。那么我們是否可以換一種思路呢?我們使用相對獨(dú)立的 Hash 值來計(jì)算庫序號和表序號呢?
func GetTableIdx(userID int64)(int64,int64){ hash := hashCode(userID) return atoi(hash[0:4]) % DbCnt,atoi(hash[4:])%TableCnt}
這也是一種常用的方案,我們稱為基因法,即使用原分片鍵中的某些基因(例如前四位)作為庫的計(jì)算因子,而使用另外一些基因作為表的計(jì)算因子。
在使用基因法時(shí),要主要計(jì)算 hash 值的片段保持充分的隨機(jī)性,避免造成嚴(yán)重?cái)?shù)據(jù)偏斜問題。
思路五:關(guān)系表冗余
按照索引的思想,可以通過分片的鍵和庫表索引建立一張索引表,我們把這張索引表叫做“路由關(guān)系表”。每次查詢操作,先去路由表中查詢到數(shù)據(jù)所在的庫表索引,然后再到庫表中查詢詳細(xì)數(shù)據(jù)。同時(shí),對于寫入操作可以采用隨機(jī)選擇或者順序選擇一個(gè)庫表進(jìn)入寫入。
那么由于路由關(guān)系表的存在,我們在數(shù)據(jù)擴(kuò)容時(shí),無需遷移歷史數(shù)據(jù)。同時(shí),我們可以為每個(gè)庫表指定一個(gè)權(quán)限,通過權(quán)重的比例調(diào)整來調(diào)整每個(gè)庫表的寫入數(shù)據(jù)量。從而實(shí)現(xiàn)庫表數(shù)據(jù)偏斜率調(diào)整。
此種方案的缺點(diǎn)是每次查詢操作,需要先讀取一次路由關(guān)系表,所以請求耗時(shí)可能會(huì)有一定增加。本身由于寫索引表和寫庫表操作是不同庫表寫操作,需要引入分布式事務(wù)保證數(shù)據(jù)一致性,極端情況可能帶來數(shù)據(jù)的不一致。
且索引表本身沒有分庫分表,自身可能會(huì)存在性能瓶頸,可以通過存儲(chǔ)在 redis 進(jìn)行優(yōu)化處理。

思路六:分段索引關(guān)系表
思路五中,需要將全量數(shù)據(jù)存在到路由關(guān)系表中建立索引,再結(jié)合 range 分庫分表方案思想,其實(shí)有些場景下完全沒有必要全部數(shù)據(jù)建立索引,可以按照號段式建立區(qū)間索引,我們可以將分片鍵的區(qū)間對應(yīng)庫的關(guān)系通過關(guān)系表記錄下來,每次查詢操作,先去路由表中查詢到數(shù)據(jù)所在的庫表索引,然后再到庫表中查詢詳細(xì)數(shù)據(jù)。

思路七:一致性 Hash 法
一致性 Hash 算法也是一種比較流行的集群數(shù)據(jù)分區(qū)算法,比如 RedisCluster 即是通過一致性 Hash 算法,使用 16384 個(gè)虛擬槽節(jié)點(diǎn)進(jìn)行每個(gè)分片數(shù)據(jù)的管理。關(guān)于一致性 Hash 的具體原理這邊不再重復(fù)描述,讀者可以自行翻閱資料。
其思想和思路五有異曲同工之妙。
版權(quán)聲明:
本站所有文章和圖片均來自用戶分享和網(wǎng)絡(luò)收集,文章和圖片版權(quán)歸原作者及原出處所有,僅供學(xué)習(xí)與參考,請勿用于商業(yè)用途,如果損害了您的權(quán)利,請聯(lián)系網(wǎng)站客服處理。