分庫(kù)分表會(huì)帶來(lái)讀擴(kuò)散問(wèn)題?怎么解決?
今天這篇文章,其實(shí)也是我曾經(jīng)面試中遇到過(guò)的真題。
分庫(kù)分表大家可能聽(tīng)得多了,但讀擴(kuò)散問(wèn)題大家了解嗎?
這里涉及到幾個(gè)問(wèn)題。
分庫(kù)分表是什么?
讀擴(kuò)散問(wèn)題是什么?
分庫(kù)分表為什么會(huì)引發(fā)讀擴(kuò)散問(wèn)題?
怎么解決讀擴(kuò)散問(wèn)題?
能不能不要在評(píng)論區(qū)叫我刁毛?
不好意思,失態(tài)了。
這些問(wèn)題還是比較有意思的。
相信兄弟們也一定有機(jī)會(huì)遇到哈哈哈。
我們先從分庫(kù)分表的話(huà)題聊起吧。
分庫(kù)分表
我們平時(shí)做項(xiàng)目開(kāi)發(fā)。一開(kāi)始,通常都先用一張數(shù)據(jù)表,而一般來(lái)說(shuō)數(shù)據(jù)表寫(xiě)到2kw條數(shù)據(jù)之后,底層B+樹(shù)的層級(jí)結(jié)構(gòu)就可能會(huì)變高,不同層級(jí)的數(shù)據(jù)頁(yè)一般都放在磁盤(pán)里不同的地方,換言之,磁盤(pán)IO就會(huì)增多,帶來(lái)的便是查詢(xún)性能變差。如果對(duì)上面這句話(huà)有疑惑的話(huà),可以去看下我之前寫(xiě)的文章。
于是,當(dāng)我們單表需要管理的數(shù)據(jù)變得越來(lái)越多,就不得不考慮數(shù)據(jù)庫(kù)分表。而這里的分表,分為水平分表和垂直分表。
垂直分表的原理比較簡(jiǎn)單,一般就是把某幾列拆成一個(gè)新表,這樣單行數(shù)據(jù)就會(huì)變小,B+樹(shù)里的單個(gè)數(shù)據(jù)頁(yè)(固定16kb)內(nèi)能放入的行數(shù)就會(huì)變多,從而使單表能放入更多的數(shù)據(jù)。
垂直分表沒(méi)有太多可以說(shuō)的點(diǎn)。下面,我們重點(diǎn)說(shuō)說(shuō)最常見(jiàn)的水平分表。
水平分表有好幾種做法,但不管是哪種,本質(zhì)上都是將原來(lái)的 user 表,變成 user_0, user1, user2 .... uerN這樣的N多張小表。
從讀寫(xiě)一張user大表,變成讀寫(xiě) user_1 … userN 這樣的N張小表。
分表
每一張小表里,只保存一部分?jǐn)?shù)據(jù),但具體保存多少,這個(gè)自己定,一般就訂個(gè)500w~2kw。
那分表具體怎么做?
根據(jù)id范圍分表
我認(rèn)為最好用的,是根據(jù)id范圍進(jìn)行分表。
我們假設(shè)每張分表能放2kw行數(shù)據(jù)。那user0就放主鍵id為1~2kw的數(shù)據(jù)。user1就放id為2kw+1 ~ 4kw,user2就放id為4kw+1 ~ 6kw, userN就放 2N kw+1 ~ 2(N+1)kw。
根據(jù)id范圍分表
假設(shè)現(xiàn)在有條數(shù)據(jù),id=3kw,將這個(gè)3kw除2kw = 1.5,向下取整得到1,那就可以得到這條數(shù)據(jù)屬于user1表。于是去讀寫(xiě)user1表就行了。這就完成了數(shù)據(jù)的路由邏輯,我們把這部分邏輯封裝起來(lái),放在數(shù)據(jù)庫(kù)和業(yè)務(wù)代碼之間。
這樣。對(duì)于業(yè)務(wù)代碼來(lái)說(shuō),它只知道自己在讀寫(xiě)一張 user 表,根本不知道底下還分了那么多張小表。
對(duì)于數(shù)據(jù)庫(kù)來(lái)說(shuō),它并不知道自己被分表了,它只知道有那么幾張表,正好名字長(zhǎng)得比較像而已。
這還只是在一個(gè)數(shù)據(jù)庫(kù)里做分表,如果范圍再搞大點(diǎn),還能在多個(gè)數(shù)據(jù)庫(kù)里做分表,這就是所謂的分庫(kù)分表。
不管是單庫(kù)分表還是分庫(kù)分表,都可以通過(guò)這樣一個(gè)中間層邏輯做路由。
還真的就應(yīng)了那句話(huà),沒(méi)有什么是加中間層不能解決的。
如果有,就多加一層。
至于這個(gè)中間層的實(shí)現(xiàn)方式就更靈活了,它既可以像第三方orm庫(kù)那樣加在業(yè)務(wù)代碼中。
通過(guò)orm讀寫(xiě)分表
也可以在mysql和業(yè)務(wù)代碼之間加個(gè)proxy服務(wù)。
如果是通過(guò)第三方orm庫(kù)的方式來(lái)做的話(huà),那需要根據(jù)不同語(yǔ)言實(shí)現(xiàn)不同的代碼庫(kù),所以不少?gòu)S都選擇后者加個(gè)proxy的方式,這樣就不需要關(guān)心上游服務(wù)用的是什么語(yǔ)言。
通過(guò)proxy管理分表
根據(jù)id取模分表
這時(shí)候就有兄弟要提出問(wèn)題了,"我看很多方案都對(duì)id取模,你這個(gè)方案是不是不完整?"。
取模的方案也是很常見(jiàn)的。
比如一個(gè)id=31進(jìn)來(lái),我們一共分了5張表,分別是user0到user4。對(duì)31%5=1,取模得1,于是就能知道應(yīng)該讀寫(xiě)user1表。
根據(jù)id取模分表
優(yōu)點(diǎn)當(dāng)然是比較簡(jiǎn)單。而且讀寫(xiě)數(shù)據(jù)都可以很均勻的分?jǐn)偟矫總€(gè)分表上。
但缺點(diǎn)也比較明顯,如果想要擴(kuò)展表的個(gè)數(shù),比如從5張表變成8張表。那同樣還是id=31的數(shù)據(jù),31%8 = 7,就需要讀寫(xiě)user7這張表。跟原來(lái)就對(duì)不上了。
這就需要考慮數(shù)據(jù)遷移的問(wèn)題。很頭禿。
為了避免后續(xù)擴(kuò)展的問(wèn)題,我見(jiàn)過(guò)一些業(yè)務(wù)一開(kāi)始就將數(shù)據(jù)預(yù)估得很大,然后心一橫,分成100張表,一張表如果存?zhèn)€2kw條,那也能存20億數(shù)據(jù)了。
也不是說(shuō)這樣不行吧,就是這個(gè)業(yè)務(wù)直到最后放棄的時(shí)候,也就存了百萬(wàn)條數(shù)據(jù),每次打開(kāi)數(shù)據(jù)庫(kù)表能看到茫茫多的user_xx,就是不太舒服,專(zhuān)業(yè)點(diǎn),叫增加了程序員的心智負(fù)擔(dān)。
而上面一種方式,根據(jù)id范圍去分表,就能很好的解決這些問(wèn)題,數(shù)據(jù)少的時(shí)候,表也少,隨著數(shù)據(jù)增多,表會(huì)慢慢變多。而且這樣表還可以無(wú)限擴(kuò)展。
那是不是說(shuō)取模的做法就用不上了呢?
也不是。
將上面兩種方式結(jié)合起來(lái)
id取模的做法,最大的好處是,新寫(xiě)入的數(shù)據(jù)都是實(shí)實(shí)在在的分散到了多張表上。
而根據(jù)id范圍去做分表,因?yàn)閕d是遞增的,那新寫(xiě)入的數(shù)據(jù)一般都會(huì)落到某一張表上,如果你的業(yè)務(wù)場(chǎng)景寫(xiě)數(shù)據(jù)特別頻繁,那這張表就會(huì)出現(xiàn)寫(xiě)熱點(diǎn)的問(wèn)題。
這時(shí)候就可以將id取模和id范圍分表的方式結(jié)合起來(lái)。
我們可以在某個(gè)id范圍里,引入取模的功能。比如 以前 2kw~4kw是user1表,現(xiàn)在可以在這個(gè)范圍再分成5個(gè)表,也就是引入user1-0, user1-2到user1-4,在這5個(gè)表里取模。
舉個(gè)例子,id=3kw,根據(jù)范圍,會(huì)分到user1表,然后再進(jìn)行取模 3kw % 5 = 0,也就是讀寫(xiě)user1-0表。
這樣就可以將寫(xiě)單表分?jǐn)倿閷?xiě)多表。
這在分庫(kù)的場(chǎng)景下優(yōu)勢(shì)會(huì)更明顯,不同的庫(kù),可以把服務(wù)部署到不同的機(jī)器上,這樣各個(gè)機(jī)器的性能都能被用起來(lái)。
根據(jù)id范圍分表后再取模
讀擴(kuò)散問(wèn)題
我們上面提到的好幾種分表方式,都用了id這一列作為分表的依據(jù),這其實(shí)就是所謂的分片鍵。
實(shí)際上我們一般也是用的數(shù)據(jù)庫(kù)主鍵作為分片鍵。
這樣,理想情況下我們已知一個(gè)id,不管是根據(jù)哪種規(guī)則,我們都能很快定位到該讀哪個(gè)分表。
但很多情況下,我們的查詢(xún)又不是只查主鍵,如果我的數(shù)據(jù)庫(kù)表有一列name,并且加了個(gè)普通索引。
這樣我執(zhí)行下面的sql。
select * from user where name = "小白";
由于name并不是分片鍵,我們沒(méi)法定位到具體要到哪個(gè)分表上去執(zhí)行sql。
于是就會(huì)對(duì)所有分表都執(zhí)行上面的sql,當(dāng)然不會(huì)是串行執(zhí)行sql,一般都是并發(fā)執(zhí)行sql的。
如果我有100張表,就執(zhí)行100次sql。
如果我有200張表,就執(zhí)行200次sql。
隨著我的表越來(lái)越多,次數(shù)會(huì)越來(lái)越多,這就是所謂的讀擴(kuò)散問(wèn)題。
讀擴(kuò)散問(wèn)題
這是個(gè)比較有趣的問(wèn)題,它確實(shí)是個(gè)問(wèn)題,但大部分的業(yè)務(wù)不會(huì)去處理它,讀100次怎么了,數(shù)據(jù)增長(zhǎng)之后讀的次數(shù)會(huì)不斷增加又怎么了?但架不住我的業(yè)務(wù)不賺錢(qián)啊,也根本長(zhǎng)不了那么多數(shù)據(jù)啊。
話(huà)是這么說(shuō)沒(méi)錯(cuò),但面試官問(wèn)你的時(shí)候,你得知道怎么處理啊。
引入新表來(lái)做分表
問(wèn)題的核心在于,主鍵是分片鍵,而普通索引列并不分片。
那好辦,我們單獨(dú)建個(gè)新的分片表,這個(gè)新表里的列就只有舊表的主鍵id和普通索引列,而這次換普通索引列來(lái)做分片鍵。
通過(guò)新索引表解決讀擴(kuò)散問(wèn)題
這樣當(dāng)我們要查詢(xún)普通索引列時(shí),先到這個(gè)新的分片表里做一次查詢(xún),就能迅速定位到對(duì)應(yīng)的主鍵id,然后再拿主鍵id去舊的分片表里查一次數(shù)據(jù)。這樣就從原來(lái)漫無(wú)目的的全表擴(kuò)散查詢(xún),縮減為只查固定幾個(gè)表了。
舉個(gè)例子。比如我的表原本長(zhǎng)下面這樣,其中id列是主鍵,同時(shí)也是分片鍵,name列是非主鍵索引。為了簡(jiǎn)化,假設(shè)三條數(shù)據(jù)一張表。
此時(shí)分表里 id=1,4,6 的都有name="小白" 的數(shù)據(jù)。
當(dāng)我們執(zhí)行 select * from user where name = "小白"; 則需要并發(fā)查3張表,隨著表變多,查詢(xún)次數(shù)會(huì)變得更多。
舉例說(shuō)明讀擴(kuò)散問(wèn)題
但如果我們?yōu)閚ame列建個(gè)新表(nameX),以name為新的分片鍵。
這樣我們可以先執(zhí)行 select id from nameX where name = "小白";
再拿著結(jié)果里的ids去查詢(xún) select * from user where id in (ids); 這樣就算表變多了,也可以迅速定位到某幾張具體的表,減少了查詢(xún)次數(shù)。
舉例說(shuō)明通過(guò)新索引表解決讀擴(kuò)散問(wèn)題
但這個(gè)做法的缺點(diǎn)也比較明顯,你需要維護(hù)兩套表,并且普通索引列更新時(shí),要兩張表同時(shí)進(jìn)行更改。
有一定的開(kāi)發(fā)量。
有沒(méi)有更簡(jiǎn)單的方案?
使用其他更合適的存儲(chǔ)
我們常規(guī)的查詢(xún)是通過(guò)id主鍵去查詢(xún)對(duì)應(yīng)的name列。而像上面的方案,則通過(guò)引入一個(gè)新表,倒過(guò)來(lái),先用name查到對(duì)應(yīng)的id,再拿id去獲取具體的數(shù)據(jù)。這其實(shí)就像是建立了一個(gè)新的索引一樣,像這種,通過(guò)name列反查原數(shù)據(jù)的思想,其實(shí)就很類(lèi)似于倒排索引。
相當(dāng)于我們是利用了倒排索引的思路去解決分表下的數(shù)據(jù)查詢(xún)問(wèn)題。
回想下,其實(shí)我們的原始需求無(wú)非就是在大量數(shù)據(jù)的場(chǎng)景下依然能提供普通索引列或其他更多維度的查詢(xún)。
這種場(chǎng)合,更適合使用es,es天然分片,而且內(nèi)部利用倒排索引的形式來(lái)加速數(shù)據(jù)查詢(xún)。
哦?兄弟萌,又是它,倒排索引,又是個(gè)極小的細(xì)節(jié),做好筆記。
舉個(gè)例子,我同樣是一行數(shù)據(jù) id,name,age。在mysql里,你得根據(jù)id分片,如果要支持name和age的查詢(xún),為了防止讀擴(kuò)散,你得分別再建一個(gè)name的分片表和一個(gè)age的分片表。
而如果你用es,它會(huì)在它內(nèi)部以id分片鍵進(jìn)行分片,同時(shí)還能建一個(gè)name到id,和一個(gè)age到id的倒排索引。這是不是就跟上面做的事情沒(méi)啥區(qū)別。
而且將mysql接入es也非常簡(jiǎn)單,我們可以通過(guò)開(kāi)源工具 canal 監(jiān)聽(tīng)mysql的binlog日志變更,再將數(shù)據(jù)解析后寫(xiě)入es,這樣es就能提供近實(shí)時(shí)的查詢(xún)能力。
mysql同步es
覺(jué)得es+mysql還是繁瑣?有沒(méi)有其他更簡(jiǎn)潔的方案?
有。
別用mysql了,改用tidb吧,相信大家多少也聽(tīng)說(shuō)過(guò)這個(gè)名稱(chēng),這是個(gè)分布式數(shù)據(jù)庫(kù)。
它通過(guò)引入Range的概念進(jìn)行數(shù)據(jù)表分片,比如第一個(gè)分片表的id在0~2kw,第二個(gè)分片表的id在2kw~4kw。
哦?有沒(méi)有很熟悉,這不就是文章開(kāi)頭提到的根據(jù)id范圍進(jìn)行數(shù)據(jù)庫(kù)分表嗎?
它支持普通索引,并且普通索引也是分片的,這是不是又跟上面提到的倒排索引方案很類(lèi)似。
又是個(gè)極小的細(xì)節(jié)。
并且tidb跟mysql的語(yǔ)法幾乎一致,現(xiàn)在也有非常多現(xiàn)成的工具可以幫你把數(shù)據(jù)從mysql遷移到tidb。所以開(kāi)發(fā)成本并不高。
用tidb替換mysql
總結(jié)
- mysql在單表數(shù)據(jù)過(guò)大時(shí),查詢(xún)性能會(huì)變差,因此當(dāng)數(shù)據(jù)量變得巨大時(shí),需要考慮水平分表。
- 水平分表需要選定一個(gè)分片鍵,一般選擇主鍵,然后根據(jù)id進(jìn)行取模,或者根據(jù)id的范圍進(jìn)行分表。
- mysql水平分表后,對(duì)于非分片鍵字段的查詢(xún)會(huì)有讀擴(kuò)散的問(wèn)題,可以用普通索引列作分片鍵建一個(gè)新表,先查新表拿到id后再回到原表再查一次原表。這本質(zhì)上是借鑒了倒排索引的思路。
- 如果想要支持更多維度的查詢(xún),可以監(jiān)聽(tīng)mysql的binlog,將數(shù)據(jù)寫(xiě)入到es,提供近實(shí)時(shí)的查詢(xún)能力。
- 當(dāng)然,用tidb替換mysql也是個(gè)思路。tidb屬實(shí)是個(gè)好東西,不少?gòu)S都拿它換個(gè)皮貼個(gè)標(biāo),做成自己的自研數(shù)據(jù)庫(kù),非常推薦大家學(xué)習(xí)一波。
- 不要做過(guò)早的優(yōu)化,沒(méi)事別上來(lái)就分100個(gè)表,很多時(shí)候真用不上。
參考資料《圖解分庫(kù)分表》
https://mp.weixin.qq.com/s/OI5y4HMTuEZR1hoz9aOMxg,
最后當(dāng)年我還在某個(gè)游戲項(xiàng)目組里做開(kāi)發(fā)的時(shí)候,從企鵝那邊挖來(lái)的策劃信誓旦旦的說(shuō),我們要做的這款游戲老少皆宜,肯定是爆款。要做成全球同服。上線(xiàn)至少過(guò)億注冊(cè),十萬(wàn)人同時(shí)在線(xiàn)。要好好規(guī)劃和設(shè)計(jì)。