談?wù)勀澳盃?zhēng)霸在數(shù)據(jù)庫(kù)方面踩過的坑(Redis篇)
注:陌陌爭(zhēng)霸的數(shù)據(jù)庫(kù)部分我沒有參與具體設(shè)計(jì),只是參與了一些討論和提出一些意見。 在出現(xiàn)問題的時(shí)候,也都是由肥龍、曉靖、Aply 同學(xué)判斷研究解決的。所以我對(duì) Redis 的判斷大多也從他們的討論中聽來,加上自己的一些猜測(cè),并沒有去仔細(xì)閱讀 Redis 文檔和閱讀 Redis 代碼。
雖然我們最終都解決了問題,但本文中說描述的技術(shù)細(xì)節(jié)還是很有可能與事實(shí)相悖,請(qǐng)閱讀的同學(xué)自行甄別。
在陌陌爭(zhēng)霸之前,我們并沒有大規(guī)模使用過 Redis 。只是直覺上感覺 Redis 很適合我們的架構(gòu):我們這個(gè)游戲不依賴數(shù)據(jù)庫(kù)幫我們處理任何數(shù)據(jù),總的數(shù)據(jù)量雖然較大,但增長(zhǎng)速度有限。由于單臺(tái)服務(wù)機(jī)處理能力有限,而游戲又不能分服, 玩家在任何時(shí)間地點(diǎn)登陸,都只會(huì)看到一個(gè)世界。
所以我們需要有一個(gè)數(shù)據(jù)中心獨(dú)立于游戲系統(tǒng)。而這個(gè)數(shù)據(jù)中心只負(fù)責(zé)數(shù)據(jù)中轉(zhuǎn)和數(shù)據(jù)落地就可以了。Redis 看起來就是最佳選擇,游戲系統(tǒng)對(duì)它只有按玩家 ID 索引出玩家的數(shù)據(jù)這一個(gè)需求。
我們將數(shù)據(jù)中心分為 32 個(gè)庫(kù),按玩家 ID 分開。不同的玩家之間數(shù)據(jù)是完全獨(dú)立的。在設(shè)計(jì)時(shí),我堅(jiān)決反對(duì)了從一個(gè)單點(diǎn)訪問數(shù)據(jù)中心的做法,堅(jiān)持每個(gè)游戲服務(wù)器節(jié)點(diǎn)都要多每個(gè)數(shù)據(jù)倉(cāng)庫(kù)直接連接。因?yàn)樵谶@里制造一個(gè)單點(diǎn)毫無必要。
根據(jù)我們事前對(duì)游戲數(shù)據(jù)量的估算,前期我們只需要把 32 個(gè)數(shù)據(jù)倉(cāng)庫(kù)部署到 4 臺(tái)物理機(jī)上即可,每臺(tái)機(jī)器上啟動(dòng) 8 個(gè) Redis 進(jìn)程。一開始我們使用 64G 內(nèi)存的機(jī)器,后來增加到了 96G 內(nèi)存。實(shí)測(cè)每個(gè) Redis 服務(wù)會(huì)占到 4~5 G 內(nèi)存,看起來是綽綽有余的。
由于我們僅僅是從文檔上了解的 Redis 數(shù)據(jù)落地機(jī)制,不清楚會(huì)踏上什么坑,為了保險(xiǎn)起見,還配備了 4 臺(tái)物理機(jī)做為從機(jī),對(duì)主機(jī)進(jìn)行數(shù)據(jù)同步備份。
Redis 支持兩種 BGSAVE 的策略,一種是快照方式,在發(fā)起落地指令時(shí),fork 出一個(gè)進(jìn)程把整個(gè)內(nèi)存 dump 到硬盤上;另一種喚作 AOF 方式,把所有對(duì)數(shù)據(jù)庫(kù)的寫操作記錄下來。我們的游戲不適合用 AOF 方式,因?yàn)槲覀兊膶懭氩僮鲗?shí)在的太頻繁了,且數(shù)據(jù)量巨大。
第一次事故出在 2 月 3 日,新年假期還沒有過去。由于整個(gè)假期都相安無事,運(yùn)維也相對(duì)懈怠。
中午的時(shí)候,有一臺(tái)數(shù)據(jù)服務(wù)主機(jī)無法被游戲服務(wù)器訪問到,影響了部分用戶登陸。在線嘗試修復(fù)連接無果,只好開始了長(zhǎng)達(dá) 2 個(gè)小時(shí)的停機(jī)維護(hù)。
在維護(hù)期間,初步確定了問題。是由于上午一臺(tái)從機(jī)的內(nèi)存耗盡,導(dǎo)致了從機(jī)的數(shù)據(jù)庫(kù)服務(wù)重啟。在從機(jī)重新對(duì)主機(jī)連接,8 個(gè) Redis 同時(shí)發(fā)送 SYNC 的沖擊下,把主機(jī)擊毀了。
這里存在兩個(gè)問題,我們需要分別討論:
問題一:從機(jī)的硬件配置和主機(jī)是相同的,為什么從機(jī)會(huì)先出現(xiàn)內(nèi)存不足。
問題二:為何重新進(jìn)行 SYNC 操作會(huì)導(dǎo)致主機(jī)過載。
問題一當(dāng)時(shí)我們沒有深究,因?yàn)槲覀儧]有估算準(zhǔn)確過年期間用戶增長(zhǎng)的速度,而正確部署數(shù)據(jù)庫(kù)。數(shù)據(jù)庫(kù)的內(nèi)存需求增加到了一個(gè)臨界點(diǎn),所以感覺內(nèi)存不足 的意外發(fā)生在主機(jī)還是從機(jī)都是很有可能的。從機(jī)先掛掉或許只是碰巧而已(現(xiàn)在反思恐怕不是這樣, 冷備腳本很可能是罪魁禍?zhǔn)祝T缙谖覀兪嵌〞r(shí)輪流 BGSAVE 的,當(dāng)數(shù)據(jù)量增長(zhǎng)時(shí),應(yīng)該適當(dāng)調(diào)大 BGSAVE 間隔,避免同一臺(tái)物理機(jī)上的 redis 服務(wù)同時(shí)做 BGSAVE ,而導(dǎo)致 fork 多個(gè)進(jìn)程需要消耗太多內(nèi)存。由于過年期間都回家過年去了,這件事情也被忽略了。
問題二是因?yàn)槲覀儗?duì)主從同步的機(jī)制了解不足:
仔細(xì)想想,如果你來實(shí)現(xiàn)同步會(huì)怎么做?由于達(dá)到同步狀態(tài)需要一定的時(shí)間。同步最好不要干涉正常服務(wù),那么保證同步的一致性用鎖肯定是不好的。所以 Redis 在同步時(shí)也觸發(fā)了 fork 來保證從機(jī)連上來發(fā)出 SYNC 后,能夠順利到達(dá)一個(gè)正確的同步點(diǎn)。當(dāng)我們的從機(jī)重啟后,8 個(gè) slave redis 同時(shí)開啟同步,等于瞬間在主機(jī)上 fork 出 8 個(gè) redis 進(jìn)程,這使得主機(jī) redis 進(jìn)程進(jìn)入交換分區(qū)的概率大大提高了。
在這次事故后,我們?nèi)∠?slave 機(jī)。因?yàn)檫@使系統(tǒng)部署更復(fù)雜了,增加了許多不穩(wěn)定因素,且未必提高了數(shù)據(jù)安全性。同時(shí),我們改進(jìn)了 bgsave 的機(jī)制,不再用定時(shí)器觸發(fā),而是由一個(gè)腳本去保證同一臺(tái)物理機(jī)上的多個(gè) redis 的 bgsave 可以輪流進(jìn)行。另外,以前在從機(jī)上做冷備的機(jī)制也移到了主機(jī)上。好在我們可以用腳本控制冷備的時(shí)間,以及錯(cuò)開 BGSAVE 的 IO 高峰期。
第二次事故最出現(xiàn)在最近( 2 月 27 日)。
我們已經(jīng)多次調(diào)整了 Redis 數(shù)據(jù)庫(kù)的部署,保證數(shù)據(jù)服務(wù)器有足夠的內(nèi)存。但還是出了次事故。事故最終的發(fā)生還是因?yàn)閮?nèi)存不足而導(dǎo)致某個(gè) Redis 進(jìn)程使用了交換分區(qū)而處理能力大大下降。在大量數(shù)據(jù)擁入的情況下,發(fā)生了雪崩效應(yīng):曉靖在原來控制 BGSAVE 的腳本中加了行保底規(guī)則,如果 30 分鐘沒有收到 BGSAVE 指令,就強(qiáng)制執(zhí)行一次保障數(shù)據(jù)最終可以落地(對(duì)這條規(guī)則我個(gè)人是有異議的)。結(jié)果數(shù)據(jù)服務(wù)器在對(duì)外部失去響應(yīng)之后的半小時(shí),多個(gè) redis 服務(wù)同時(shí)進(jìn)入 BGSAVE 狀態(tài),吃光了內(nèi)存。
花了一天時(shí)間追查事故的元兇。我們發(fā)現(xiàn)是冷備機(jī)制惹的禍。我們會(huì)定期把 redis 數(shù)據(jù)庫(kù)文件復(fù)制一份打包備份。而操作系統(tǒng)在拷貝文件時(shí),似乎利用了大量的內(nèi)存做文件 cache 而沒有及時(shí)釋放。這導(dǎo)致在一次 BGSAVE 發(fā)生的時(shí)候,系統(tǒng)內(nèi)存使用量大大超過了我們?cè)阮A(yù)期的上限。
這次我們調(diào)整了操作系統(tǒng)的內(nèi)核參數(shù),關(guān)掉了 cache ,暫時(shí)解決了問題。
經(jīng)過這次事故之后,我反思了數(shù)據(jù)落地策略。我覺得定期做 BGSAVE 似乎并不是好的方案。至少它是浪費(fèi)的。因?yàn)槊看?BGSAVE 都會(huì)把所有的數(shù)據(jù)存盤,而實(shí)際上,內(nèi)存數(shù)據(jù)庫(kù)中大量的數(shù)據(jù)是沒有變更過的。一目前 10 到 20 分鐘的保存周期,數(shù)據(jù)變更的只有這個(gè)時(shí)間段內(nèi)上線的玩家以及他們攻擊過的玩家(每 20 分鐘大約發(fā)生 1 到 2 次攻擊),這個(gè)數(shù)字遠(yuǎn)遠(yuǎn)少于全部玩家數(shù)量。
我希望可以只備份變更的數(shù)據(jù),但又不希望用內(nèi)建的 AOF 機(jī)制,因?yàn)?AOF 會(huì)不斷追加同一份數(shù)據(jù),導(dǎo)致硬盤空間太快增長(zhǎng)。
我們也不希望給游戲服務(wù)和數(shù)據(jù)庫(kù)服務(wù)之間增加一個(gè)中間層,這白白犧牲了讀性能,而讀性能是整個(gè)系統(tǒng)中至關(guān)重要的。僅僅對(duì)寫指令做轉(zhuǎn)發(fā)也是不可靠的。因?yàn)槭ズ妥x指令的時(shí)序,有可能使數(shù)據(jù)版本錯(cuò)亂。
如果在游戲服務(wù)器要寫數(shù)據(jù)時(shí)同時(shí)向 Redis 和另一個(gè)數(shù)據(jù)落地服務(wù)同時(shí)各發(fā)一份數(shù)據(jù)怎樣?首先,我們需要增加版本機(jī)制,保證能識(shí)別出不同位置收到的寫操作的先后(我記得在狂刃中,就發(fā)生過數(shù)據(jù)版本錯(cuò) 亂的 Bug );其次,這會(huì)使游戲服務(wù)器和數(shù)據(jù)服務(wù)器間的寫帶寬加倍。
最后我想了一個(gè)簡(jiǎn)單的方法:在數(shù)據(jù)服務(wù)器的物理機(jī)上啟動(dòng)一個(gè)監(jiān)護(hù)服務(wù)。當(dāng)游戲服務(wù)器向數(shù)據(jù)服務(wù)推送數(shù)據(jù)并確認(rèn)成功后,再把這組數(shù)據(jù)的 ID 同時(shí)發(fā)送給這個(gè)監(jiān)護(hù)服務(wù)。它再?gòu)?Redis 中把數(shù)據(jù)讀回來,并保存在本地。
因?yàn)檫@個(gè)監(jiān)護(hù)服務(wù)和 Redis 1 比 1 配置在同一臺(tái)機(jī)器上,而硬盤寫速度是大于網(wǎng)絡(luò)帶寬的,它一定不會(huì)過載。至于 Redis ,就成了一個(gè)純粹的內(nèi)存數(shù)據(jù)庫(kù),不再運(yùn)行 BGSAVE 。
這個(gè)監(jiān)護(hù)進(jìn)程同時(shí)也做數(shù)據(jù)落地。對(duì)于數(shù)據(jù)落地,我選擇的是 unqlite ,幾行代碼就可以做好它的 Lua 封裝。它的數(shù)據(jù)庫(kù)文件只有一個(gè),更方便做冷備。當(dāng)然 levelDB 也是個(gè)不錯(cuò)的選擇,如果它是用 C 而不是 C++ 實(shí)現(xiàn)的話,我會(huì)考慮后者的。
和游戲服務(wù)器的對(duì)接,我在數(shù)據(jù)庫(kù)機(jī)器上啟動(dòng)了一個(gè)獨(dú)立的 skynet 進(jìn)程,監(jiān)聽同步 ID 的請(qǐng)求。因?yàn)樗恍枰幚砗芎?jiǎn)單幾個(gè) Redis 操作,我特地手寫了 Redis 指令。最終這個(gè)服務(wù) 只有一個(gè) lua 腳本 ,其實(shí)它是由三個(gè) skynet 服務(wù)構(gòu)成的,一個(gè)監(jiān)聽外部端口,一個(gè)處理連接上的 Redis 同步指令,一個(gè)單點(diǎn)寫入數(shù)據(jù)到 unqlite 。為了使得數(shù)據(jù)恢復(fù)高效,我特地在保存玩家數(shù)據(jù)的時(shí)候,把恢復(fù)用的 Redis 指令拼好。這樣一旦需要恢復(fù),只用從 unqlite 中讀出玩家數(shù)據(jù),直接發(fā)送給 Redis 即可。
有了這個(gè)東西,就一并把 Redis 中的冷熱數(shù)據(jù)解決了。長(zhǎng)期不登陸的玩家,我們可以定期從 Redis 中清掉,萬一這個(gè)玩家登陸回來,只需要讓它幫忙恢復(fù)。
曉靖不喜歡我依賴 skynet 的實(shí)現(xiàn)。他一開始想用 python 實(shí)現(xiàn)一個(gè)同樣的東西,后來他又對(duì) Go 語言產(chǎn)生了興趣,想借這個(gè)需求玩一下 Go 語言。所以到今天,我們還沒有把這套新機(jī)制部署到生產(chǎn)環(huán)境。
原文地址。51CTO獲作者授權(quán)轉(zhuǎn)載。