談談陌陌爭霸在數據庫方面踩過的坑( Redis 篇)


注:陌陌爭霸的數據庫部分我沒有參與具體設計,只是參與了一些討論和提出一些意見。在出現問題的時候,也都是由肥龍、曉靖、Aply 同學判斷研究解決的。所以我對 Redis 的判斷大多也從他們的討論中聽來,加上自己的一些猜測,並沒有去仔細閱讀 Redis 文檔和閱讀 Redis 代碼。雖然我們最終都解決了問題,但本文中說描述的技術細節還是很有可能與事實相悖,請閱讀的同學自行甄別。

在陌陌爭霸之前,我們並沒有大規模使用過 Redis 。只是直覺上感覺 Redis 很適合我們的架構:我們這個遊戲不依賴數據庫幫我們處理任何數據,總的數據量雖然較大,但增長速度有限。由於單臺服務機處理能力有限,而遊戲又不能分服,玩家在任何時間地點登陸,都只會看到一個世界。所以我們需要有一個數據中心獨立於遊戲系統。而這個數據中心只負責數據中轉和數據落地就可以了。Redis 看起來就是最佳選擇,遊戲系統對它只有按玩家 ID 索引出玩家的數據這一個需求。

我們將數據中心分爲 32 個庫,按玩家 ID 分開。不同的玩家之間數據是完全獨立的。在設計時,我堅決反對了從一個單點訪問數據中心的做法,堅持每個遊戲服務器節點都要多每個數據倉庫直接連接。因爲在這裏製造一個單點毫無必要。

根據我們事前對遊戲數據量的估算,前期我們只需要把 32 個數據倉庫部署到 4 臺物理機上即可,每臺機器上啓動 8 個 Redis 進程。一開始我們使用 64G 內存的機器,後來增加到了 96G 內存。實測每個 Redis 服務會佔到 4~5 G 內存,看起來是綽綽有餘的。

由於我們僅僅是從文檔上了解的 Redis 數據落地機制,不清楚會踏上什麼坑,爲了保險起見,還配備了 4 臺物理機做爲從機,對主機進行數據同步備份。

Redis 支持兩種 BGSAVE 的策略,一種是快照方式,在發起落地指令時,fork 出一個進程把整個內存 dump 到硬盤上;另一種喚作 AOF 方式,把所有對數據庫的寫操作記錄下來。我們的遊戲不適合用 AOF 方式,因爲我們的寫入操作實在的太頻繁了,且數據量巨大。


第一次事故出在 2 月 3 日,新年假期還沒有過去。由於整個假期都相安無事,運維也相對懈怠。

中午的時候,有一臺數據服務主機無法被遊戲服務器訪問到,影響了部分用戶登陸。在線嘗試修復連接無果,只好開始了長達 2 個小時的停機維護。

在維護期間,初步確定了問題。是由於上午一臺從機的內存耗盡,導致了從機的數據庫服務重啓。在從機重新對主機連接,8 個 Redis 同時發送 SYNC 的衝擊下,把主機擊毀了。

這裏存在兩個問題,我們需要分別討論:

問題一:從機的硬件配置和主機是相同的,爲什麼從機會先出現內存不足。

問題二:爲何重新進行 SYNC 操作會導致主機過載。

問題一當時我們沒有深究,因爲我們沒有估算準確過年期間用戶增長的速度,而正確部署數據庫。數據庫的內存需求增加到了一個臨界點,所以感覺內存不足的意外發生在主機還是從機都是很有可能的。從機先掛掉或許只是碰巧而已(現在反思恐怕不是這樣, 冷備腳本很可能是罪魁禍首)。早期我們是定時輪流 BGSAVE 的,當數據量增長時,應該適當調大 BGSAVE 間隔,避免同一臺物理機上的 redis 服務同時做 BGSAVE ,而導致 fork 多個進程需要消耗太多內存。由於過年期間都回家過年去了,這件事情也被忽略了。

問題二是因爲我們對主從同步的機制瞭解不足:

仔細想想,如果你來實現同步會怎麼做?由於達到同步狀態需要一定的時間。同步最好不要干涉正常服務,那麼保證同步的一致性用鎖肯定是不好的。所以 Redis 在同步時也觸發了 fork 來保證從機連上來發出 SYNC 後,能夠順利到達一個正確的同步點。當我們的從機重啓後,8 個 slave redis 同時開啓同步,等於瞬間在主機上 fork 出 8 個 redis 進程,這使得主機 redis 進程進入交換分區的概率大大提高了。

在這次事故後,我們取消了 slave 機。因爲這使系統部署更復雜了,增加了許多不穩定因素,且未必提高了數據安全性。同時,我們改進了 bgsave 的機制,不再用定時器觸發,而是由一個腳本去保證同一臺物理機上的多個 redis 的 bgsave 可以輪流進行。另外,以前在從機上做冷備的機制也移到了主機上。好在我們可以用腳本控制冷備的時間,以及錯開 BGSAVE 的 IO 高峯期。

第二次事故最出現在最近( 2 月 27 日)。

我們已經多次調整了 Redis 數據庫的部署,保證數據服務器有足夠的內存。但還是出了次事故。事故最終的發生還是因爲內存不足而導致某個 Redis 進程使用了交換分區而處理能力大大下降。在大量數據擁入的情況下,發生了雪崩效應:曉靖在原來控制 BGSAVE 的腳本中加了行保底規則,如果 30 分鐘沒有收到 BGSAVE 指令,就強制執行一次保障數據最終可以落地(對這條規則我個人是有異議的)。結果數據服務器在對外部失去響應之後的半小時,多個 redis 服務同時進入 BGSAVE 狀態,吃光了內存。

花了一天時間追查事故的元兇。我們發現是冷備機制惹的禍。我們會定期把 redis 數據庫文件複製一份打包備份。而操作系統在拷貝文件時,似乎利用了大量的內存做文件 cache 而沒有及時釋放。這導致在一次 BGSAVE 發生的時候,系統內存使用量大大超過了我們原先預期的上限。

這次我們調整了操作系統的內核參數,關掉了 cache ,暫時解決了問題。


經過這次事故之後,我反思了數據落地策略。我覺得定期做 BGSAVE 似乎並不是好的方案。至少它是浪費的。因爲每次 BGSAVE 都會把所有的數據存盤,而實際上,內存數據庫中大量的數據是沒有變更過的。一目前 10 到 20 分鐘的保存週期,數據變更的只有這個時間段內上線的玩家以及他們攻擊過的玩家(每 20 分鐘大約發生 1 到 2 次攻擊),這個數字遠遠少於全部玩家數量。

我希望可以只備份變更的數據,但又不希望用內建的 AOF 機制,因爲 AOF 會不斷追加同一份數據,導致硬盤空間太快增長。

我們也不希望給遊戲服務和數據庫服務之間增加一箇中間層,這白白犧牲了讀性能,而讀性能是整個系統中至關重要的。僅僅對寫指令做轉發也是不可靠的。因爲失去和讀指令的時序,有可能使數據版本錯亂。

如果在遊戲服務器要寫數據時同時向 Redis 和另一個數據落地服務同時各發一份數據怎樣?首先,我們需要增加版本機制,保證能識別出不同位置收到的寫操作的先後(我記得在狂刃中,就發生過數據版本錯亂的 Bug );其次,這會使遊戲服務器和數據服務器間的寫帶寬加倍。

最後我想了一個簡單的方法:在數據服務器的物理機上啓動一個監護服務。當遊戲服務器向數據服務推送數據並確認成功後,再把這組數據的 ID 同時發送給這個監護服務。它再從 Redis 中把數據讀回來,並保存在本地。

因爲這個監護服務和 Redis 1 比 1 配置在同一臺機器上,而硬盤寫速度是大於網絡帶寬的,它一定不會過載。至於 Redis ,就成了一個純粹的內存數據庫,不再運行 BGSAVE 。

這個監護進程同時也做數據落地。對於數據落地,我選擇的是 unqlite ,幾行代碼就可以做好它的 Lua 封裝。它的數據庫文件只有一個,更方便做冷備。當然 levelDB 也是個不錯的選擇,如果它是用 C 而不是 C++ 實現的話,我會考慮後者的。

和遊戲服務器的對接,我在數據庫機器上啓動了一個獨立的 skynet 進程,監聽同步 ID 的請求。因爲它只需要處理很簡單幾個 Redis 操作,我特地手寫了 Redis 指令。最終這個服務 只有一個 lua 腳本 ,其實它是由三個 skynet 服務構成的,一個監聽外部端口,一個處理連接上的 Redis 同步指令,一個單點寫入數據到 unqlite 。爲了使得數據恢復高效,我特地在保存玩家數據的時候,把恢復用的 Redis 指令拼好。這樣一旦需要恢復,只用從 unqlite 中讀出玩家數據,直接發送給 Redis 即可。

有了這個東西,就一併把 Redis 中的冷熱數據解決了。長期不登陸的玩家,我們可以定期從 Redis 中清掉,萬一這個玩家登陸回來,只需要讓它幫忙恢復。

曉靖不喜歡我依賴 skynet 的實現。他一開始想用 python 實現一個同樣的東西,後來他又對 Go 語言產生了興趣,想借這個需求玩一下 Go 語言。所以到今天,我們還沒有把這套新機制部署到生產環境。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章