1. 程式人生 > >談談陌陌爭霸在資料庫方面踩過的坑( Redis 篇)

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

第一次事故出在 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 語言。所以到今天,我們還沒有把這套新機制部署到生產環境。