入職第一天,MySQL就崩了...
圖片來自 Pexels
入職的第一天我就經(jīng)歷了一次生產(chǎn)事故,運維同學(xué)告警說線上 MySQL 負(fù)載壓力大,直接就把主庫 MySQL 壓崩了(第一天這可不是好兆頭)。
運維同學(xué)緊急進(jìn)行了主從切換,在事后尋找導(dǎo)致生產(chǎn)事故的原因時,排查到是慢查詢導(dǎo)致 MySQL 雪崩的主要原因。
在導(dǎo)出慢查詢的 SQL 后,項目經(jīng)理直接說吧這個 MySQL 優(yōu)化的功能交給新來的吧!
我趕緊打開跳板機(jī)進(jìn)行查看,不看不知道一看嚇一跳:
單表的數(shù)據(jù)量已經(jīng)達(dá)到了 5 億級別!這尼瑪肯定是歷史問題一直堆積到現(xiàn)在才導(dǎo)致的啊,項目經(jīng)理直接就把這個坑甩給了我,我心中想,我難道試崗期都過不了么????
好在我身經(jīng)百戰(zhàn),趕快與項目經(jīng)理與老同事進(jìn)行溝通,了解業(yè)務(wù)場景,才發(fā)現(xiàn)導(dǎo)致現(xiàn)在的情況是這樣的。
我所在的公司是主要做 IM 社交系統(tǒng)的,這個 5 億級別的數(shù)據(jù)表是關(guān)注表,也是俗稱的粉絲表,在類似與某些大 V、或者是網(wǎng)紅,粉絲過百萬是非常常見的。
在 A 關(guān)注 B 后會產(chǎn)生一條記錄,B 關(guān)注 A 時也會產(chǎn)生一條記錄,時間積累久了才達(dá)到今天這樣的數(shù)據(jù)規(guī)模,項目經(jīng)理慢悠悠的對我說,這個優(yōu)化不用著急,先出方案吧!
我心中一萬個草泥馬經(jīng)過,這上來就給了一塊不好啃的骨頭,看來是要試試我能力的深淺啊。
按照我之前經(jīng)驗,單表在達(dá)到 500W 左右的數(shù)據(jù)就應(yīng)該考慮分表了,常見分表方案無非就是 hash 取模,或者 range 分區(qū)這兩種方法。
但是這次的數(shù)據(jù)分表與遷移過程難度在于兩方面:
- 數(shù)據(jù)平滑過度,在不停機(jī)的情況把單表數(shù)據(jù)逐步遷移。(老板說:敢宕機(jī)分分鐘損失幾千塊,KPI 直接給你扣成負(fù)的)
- 數(shù)據(jù)分區(qū),采用 hash 還是 range?(暫時不能使用一些分庫分表中間件,無奈)
首先說說 hash:
常規(guī)我們都是拿用戶 id 進(jìn)行取模,模到多少直接把數(shù)據(jù)塞進(jìn)去就行了,簡單粗暴。
但是假如說 user_id=128 與 user_id=257 再模 128 后都是對應(yīng) user_attention_1 這個表,他倆也恰好是網(wǎng)紅,旗下粉絲過百萬,那輕輕松松兩個人就能把數(shù)據(jù)表撐滿。
其他用戶再進(jìn)來數(shù)據(jù)的時候無疑 user_attention_1 這個表還會成為一張大表,這就是典型的數(shù)據(jù)熱點問題,這個方案可以 PASS。
有的同學(xué)說可以 user_id 和 fans_id 組合進(jìn)行取模進(jìn)行分配,我也考慮過這個問題。
雖然這樣子數(shù)據(jù)分配均勻了,但是會有一個致命的問題就是查詢問題(因為目前沒有做類似 MongoDB 與 DB2 這種高性能查詢 DB,也沒做數(shù)據(jù)同步,考慮到工作量還是查詢現(xiàn)有的分表內(nèi)的數(shù)據(jù))。
例如業(yè)務(wù)場景經(jīng)常用到的查詢就是我關(guān)注了那些人,那些人關(guān)注了我,所以我們的查詢代碼可能會是這樣寫的:
- //我關(guān)注了誰
- select * from user_attention where user_id = #{userId}
- //誰關(guān)注了我
- select * from user_attention where fans_id = #{userId}
在我們進(jìn)行 user_id 與 fans_id 組合后 hash 后,如果我想查詢我關(guān)注的人與誰關(guān)注我的時候,那我將檢索 128 張表才能得到結(jié)果。
這個也太惡心了,肯定不可取,并且考慮到以后擴(kuò)容至少也要影響一半數(shù)據(jù),實在不好用,這個方案 PASS。
接下來說說 range:
Range 看起來也很簡單,用戶 id 在一定的范圍時候就把他路由到一個表中。
例如用戶 id=128,那就在 [0,10000] 這個區(qū)間中對應(yīng)的是 user_attention_0 這個表,就直接把數(shù)據(jù)塞進(jìn)去就可以了。
但是這樣同樣也會產(chǎn)生熱點數(shù)據(jù)問題,看來簡單的水平分區(qū)已經(jīng)不能滿足,這個方案也可以 PASS 了,還是要另尋他徑啊。
經(jīng)過我日夜奮戰(zhàn),深思熟慮之后,給出了三個解決方案:
第一種方案:range+一致性 hash 環(huán)組合(hash 環(huán)節(jié)點 10000)
什么是 hash 環(huán)看這里:
想采用這個方案主要是因為:
- 擴(kuò)容簡單,影響范圍小,只涉及 hash 環(huán)上單個節(jié)點影響。
- 數(shù)據(jù)遷移簡單,每次擴(kuò)容只需吧新增的節(jié)點與后置節(jié)點進(jìn)行數(shù)據(jù)交互。
- 查詢范圍小,按照 range 與 hash 關(guān)系檢索部分表分區(qū)。
大概思路我們還是先按照 user_id 進(jìn)行大概范圍劃分,但是 range 之后我后面對應(yīng)的可能就不是一個表了,而是一個 hash 環(huán)。
在每個 range 區(qū)域后都對應(yīng)著自己一套的環(huán),我們可以根據(jù)實際情況進(jìn)行擴(kuò)容,比如在 [1,10000] 這個范圍內(nèi)只有 2 個大 V,那我們分三個表就夠了,預(yù)留 1500 萬的數(shù)據(jù)容量。
[10001,20000] 中有 4 個網(wǎng)紅和大 V,hash 環(huán)上就給出實際 4 張表,我們的用戶 id 可以順時針順序坐落到第一個物理表,數(shù)據(jù)進(jìn)行入庫。
凡事有利有弊,方案也要結(jié)合工時,實際可行性與技術(shù)評審之后才能決定,弊端咱也要列出來:
- 設(shè)計復(fù)雜,需要增加 range 區(qū)域與 hash 環(huán)關(guān)系。
- 系統(tǒng)內(nèi)修改波及較多,查詢關(guān)系復(fù)雜,多了一層路由表的概念,雖然盡量吧用戶數(shù)據(jù)分配到一個區(qū)之內(nèi),但是想查詢誰關(guān)注我,與我關(guān)注誰這樣的邏輯時還是復(fù)雜。
第二種方案:range+hash 取模(hash 模 300)
這個其實就比較好理解了,就是一個簡單的 range+hash 取模組合的形式,先 range 到一定的范圍后,在這個范圍內(nèi)進(jìn)行 hash 取模找到對應(yīng)的表進(jìn)行存儲。
這個方案比方案一簡單點,但是方案一存在的問題他也存在,并且他還有擴(kuò)容數(shù)據(jù)影響范圍廣的問題。
但是實現(xiàn)起來就簡單不少,從查詢方面看根據(jù)不同場景可以控制取模的大小范圍,根據(jù)實際情況每個分區(qū)的 hash 模采用不同的值。
最后一種方案:range userId 分區(qū)
這個方案是我覺得靠譜性與實施性可能最高的一種,看起來挺像第二種方案的,但是更具體了一點,首先會定義一個中間關(guān)系表 user_attention_routing。
我們會把用戶范圍與路由到哪個表做成關(guān)系,根據(jù)范圍區(qū)間進(jìn)行查找,結(jié)合現(xiàn)有數(shù)據(jù)當(dāng)某個大 V,或者網(wǎng)紅數(shù)據(jù)量比較大,我們就給他路由自成一表數(shù)據(jù)大概是這樣的。
例如 user_id=256 是個大 V,就把他單獨提出來讓他自成一表,在查詢范圍的時候優(yōu)先查是否有自己單獨對應(yīng)的路由表。
而其他那些零碎用戶還是路由到一個統(tǒng)一表內(nèi),這時候有的同學(xué)會說這樣子數(shù)據(jù)不都又不均勻了么。
我也曾這樣認(rèn)為,但是分到絕對的均勻基本不太可能,只能做到相對,盡量把某些大 V 分出去,不占用公共資源。
當(dāng)某個人突然成為大 V 后,再把這個人再單獨分出去,不斷演變這個過程,保證數(shù)據(jù)的平衡。
并且這樣子處理之后很多原來的關(guān)聯(lián)查詢其實改動不大了,只要在數(shù)據(jù)遷移后對原來的所有包含 user_attention 進(jìn)行動態(tài)的改造即可(使用個 mybatis 的攔截器就能搞定)。
PS:其實分析實際業(yè)務(wù)場景大部分的關(guān)注數(shù)據(jù)還是來源于那些零碎用戶的。
分表方案首先就這樣定了,接下來另一個問題就是查詢問題,上文說過很多業(yè)務(wù)查詢無非就是誰關(guān)注了我,我關(guān)注了誰這樣的場景,如果繼續(xù)使用之前的:
- //我關(guān)注了誰
- select * from user_attention where user_id = #{userId}
- //誰關(guān)注了我
- select * from user_attention where fans_id = #{userId}
這樣的方案,當(dāng)我要查詢我的粉絲有哪些時,這樣就悲劇了,我還是要檢索全表根據(jù) fansid 找到我所有的粉絲,因為表內(nèi)只記錄了我關(guān)注了誰這樣的數(shù)據(jù)。
考慮到這樣的問題,我決定重新設(shè)計數(shù)據(jù)存儲形式,使用空間換時間的思路,原來處理的方式是用戶在關(guān)注對方的時候產(chǎn)生一條記錄。
現(xiàn)在處理方式是用戶 A 在關(guān)注用戶 B 時寫入兩條數(shù)據(jù),通過字段區(qū)分關(guān)系,假如 user_attention 表是這樣的:
在用戶 1 關(guān)注 2 后產(chǎn)生兩條數(shù)據(jù),state(1 代表我關(guān)注了,0 代表我被關(guān)注了,2 代表咱倆互關(guān)),采用這樣的數(shù)據(jù)存儲方式后,我所有的查詢都可以從 user_id 進(jìn)行出發(fā)了,不在逆向去推 fans_id 這樣的方式。
數(shù)據(jù)庫索引設(shè)計上,考慮好 user_id、fans_id、state 與 user_id、state 這樣的結(jié)構(gòu)即可,是不是感覺很簡單,雖然數(shù)據(jù)量存儲變多了,但是查詢方便了好多。
分表和查詢問題解決了,最后就是要考慮數(shù)據(jù)遷移的過程了,這一步也非常重要。搞不好就要被扣掉自己的 KPI 了(步步為營啊)!
數(shù)據(jù)遷移最需要考慮的問題就是個時效性,遷移程序必不可少,如何生產(chǎn)環(huán)境正常跑著,遷移腳本線下跑著數(shù)據(jù)互不影響呢?
答案就是經(jīng)典套路數(shù)據(jù)雙寫,因為老的數(shù)據(jù)不是一下子就遷移到新表內(nèi)的,現(xiàn)在和 user_attention 產(chǎn)生的數(shù)據(jù)還是要保持的,在產(chǎn)生老表數(shù)據(jù)的同時,根據(jù)路由規(guī)則,直接存到新表內(nèi)一份。
線下的遷移程序多開幾臺服務(wù)慢慢跑唄,不過可要控制好數(shù)據(jù)量,別占滿 IO 影響生產(chǎn)環(huán)境,線下的模擬和演練也是必不可少的,誰都不能保證會不會出啥問題呢。
遷移腳本和線上做好 user_id 和 fans_id 的唯一索引就行,在某些極端情況下,數(shù)據(jù)會存在新表內(nèi)寫入數(shù)據(jù),但是老表內(nèi)數(shù)據(jù)還沒更新的可能這個做好版本號控制和日志記錄就可以了,這些都比較簡單。
當(dāng)新表數(shù)據(jù)和老表完全同步時我們就可以吧所有系統(tǒng)內(nèi)波及老表查詢的語句都改成新表查詢,驗證下有沒有問題,如果沒有問題最后就可以痛快的!
- truncate table user_attention;
干掉這個 5 億數(shù)據(jù)量的定時炸彈了。好了,今天分享就結(jié)束了,看來我不僅能挺過試崗期也能挺過試用期了,不說了下班回家抱娃去了??。
作者:TOM,一個二線城市的程序員
編輯:陶家龍
出處:轉(zhuǎn)載自公眾號 JavaTom