遊戲服務端的高併發和高可用

水風發表於2021-01-18
遊戲服務端的高併發和高可用

作者/水風 本文首發知乎
https://zhuanlan.zhihu.com/p/342953318

用通俗的方法來描述一個好的服務端架構,最基礎也是最重要的就兩點:支援百萬玩家同時線上,不出問題。這兩點也就分別對應了高併發和高可用。

上篇文章介紹的是遊戲伺服器的一些優化方法:《某百萬DAU遊戲的服務端優化工作》。

這篇文章系統的介紹遊戲服務端中的高併發和高可用。

高併發和高可用是一個相輔相成的工作,當我們支援百萬玩家同時線上時卻無法保證伺服器穩定可用,那高併發支援就無從談起;而如果當玩家數量較多時伺服器就常常出問題,那也不能稱為高可用。

1、水平擴充套件

水平擴充套件時高併發和高可用的基礎,通過支援水平擴充套件,我們理論上可以通過增加機器獲得無限的承載上限,從而支援高併發;在此基礎上,若某個程式出現異常,其他程式可以替代其提供服務,從而實現了高可用。

以下圖為例,對於不支援水平擴充套件的架構,遊戲伺服器中只有一個戰鬥程式為所有的玩家提供戰鬥服務,這裡存在兩個問題:1.一個程式最多隻能使用一個一臺機器的計算資源,存在效能上限。2.若這個程式或者所在機器/網路發生異常,那麼整個系統就不可用了。

遊戲服務端的高併發和高可用
不支援水平擴充套件

水平擴充套件有兩種常見的實現模型:

大廳服和所有的戰鬥程式進行全連線,需要訪問戰鬥服務時去管理器中查詢服務所在的程式地址,然後直接去訪問程式。(左圖)

在戰鬥程式前面掛一個路由,路由記錄每個戰鬥所在的戰鬥程式,相關請求會轉發到對應的程式。(右圖)

遊戲服務端的高併發和高可用
水平擴充套件的兩種模型

1.1 有狀態 vs. 無狀態

從程式記憶體中是否儲存狀態的角度可以將服務分為有狀態和無狀態:

有狀態服務:程式記憶體中儲存狀態,比如戰鬥服務將戰鬥資訊(玩家角色狀態、小怪狀態等)儲存在記憶體中,玩家操作或者戰鬥邏輯會改變戰鬥資訊。由於遊戲中狀態比較複雜,業務改變狀態頻率比較高,遊戲大部分的業務都是用有狀態服務的方式提供。

無狀態服務:服務只處理流程,不儲存資料,一般資料會儲存到後端db中,這種服務的邏輯一般會有很多db操作。這種型別的服務在網際網路web行業用的比較多,遊戲中常見的比如充值、登陸等。(無狀態服務在執行一個流程處理中可能會有一些臨時變數申請記憶體)

無狀態服務本身不儲存狀態,所以程式crash也不會丟失資訊。此外,下文將介紹,由於使用隨機分配的路由方式,無狀態服務對異常的容忍更加好,所以,從高可用角度,無狀態更加好。

但是,由於無狀態不儲存狀態,所有狀態操作都是資料庫操作,就造成了開發成本更高(程式碼寫起來更復雜)、資料庫壓力更大,所以無狀態並不適合所有服務,一般對於狀態簡單明確的服務,可以優先使用無狀態,比如好友服務。

1.2 路由策略

對於有狀態和無狀態服務,他們使用的路由方式也不同。

對於無狀態服務,一般使用隨機分配的路由方式。隨機分配的路由方式有很大的好處,如果某個程式crash了或者網路出現了故障,我們只需要把這個程式從路由中去掉,對後續的請求不會有影響,只會影響此程式當前正在處理的邏輯。

有狀態服務的路由需要明確每個請求給哪個程式處理,給其他程式其他程式因為沒有相關狀態資訊也無法處理。比如上文提到的戰鬥服務,路由根據戰鬥ID將相關請求發給對應戰鬥所在的程式中才能處理。路由一般使用取模或者一致性雜湊,一般會優先使用一致性雜湊而不是取模,防止故障引起抖動。

1.3 舉個例子

下圖是某遊戲架構的簡化版模型,真實的伺服器比這個複雜很多,這裡主要是為了舉個例子。

遊戲服務端的高併發和高可用

叢集可以分為三類:支援水平擴充套件的有狀態服務、支援水平擴充套件的無狀態服務、不支援水平擴充套件的單點服務。

其中,支援水平擴充套件的有狀態服務和無狀態服務的程式數量能佔到專案的90%,單點服務很少。在這種架構情況下,我們遊戲的承載上限瓶頸在於單點服務,而單點服務邏輯相對比較簡單,承載上限很高。此外,支援水平擴充套件的服務程式出現異常只會影響此程式所服務的玩家,具有較高的可用性。

有狀態服務

在我們遊戲的伺服器叢集中,三分之二左右的程式是處理玩家個人邏輯的程式(玩家叢集,很多遊戲專案叫大廳伺服器)。每個程式處理一部分玩家的業務邏輯,通過shading將玩家分配在不同的玩家程式中。

可以通過增加個人邏輯程式數量提升伺服器承載量,我們支援不停服增加或減少程式即動態擴容縮容。,這些程式之間就是平等的,不同程式之間沒有強依賴關係。當一個程式crash時,他不會影響其他程式的玩家。

除了玩家程式,還有戰鬥程式、家族程式等類似程式可以這麼設計。

上面提到的都是有狀態服務,我們需要記錄每個玩家/戰鬥/家族在哪個程式中,此外,若程式出現異常,雖然不會影響其他玩家/戰鬥/家族,但當前程式中玩家/戰鬥/家族都會不可用,而且會丟失一些資料。

無狀態服務

我們將部分服務使用無狀態實現,比如登陸、支付、好友、部分排行榜等。由於無狀態服務具相對於有狀態對異常更友好、動態擴容縮容模型 更簡單,因此有對於一個新的服務我們優先考慮使用無狀態,若狀態較複雜才考慮使用有狀態服務實現。

單點服務

遊戲服務中難免出現一些單點服務,比如玩家管理器、叢集管理器、家族管理器等,這類服務不具擴充套件能力,是遊戲伺服器的承載瓶頸。此外,也不具有高可用性,如果出現異常會導致導致整個遊戲叢集不可用。

單點服務邏輯普遍簡單(複雜邏輯我們都要支援水平擴容),效能承載普遍較高。比如,我們遊戲中目前評估的同時線上保守估計應該在50w,此時我們認為我們的一些單點服務會出現滿載,導致遊戲無法繼續擴容。

此外,單點服務數量較少,出現異常的可能性很低。我們遊戲上線近兩年,我們也只遇到了兩次機器當機,影響的都是非單點程式,沒有影響整體的遊戲叢集可用性。

當然,單點服務也可以改為支援水平擴充套件的,只是工作量的問題。理論是來說,是能完全消除單點的,只是對於大部分專案來說價效比不高意義不大。

2、高併發

水平擴充套件的方案是支援高併發的主要手段(也叫可伸縮Scalability),上文已經介紹過了。

下文主要介紹除了水平擴充套件高併發的其他方案,以及需要注意的點。

2.1 垂直擴充套件和效能優化

要提升承載能力,一般有兩個方案:

水平擴充套件:通過增加機器數量提升承載能力。

垂直擴充套件:通過增加機器配置提升承載能力。讓一個機器/執行緒可以承載更多的玩家。

垂直擴充套件在某些場景也有有用。一般來說,對於上文我們提到的單點,如果不好消除或者消除成本很高,可以通過垂直擴充套件把這個邏輯放在高配機器上,提升單點邏輯的承載。

此外,我們常常是對戰鬥服進行效能優化,比如使用C++寫高消耗模組,但對於大廳服一般不將其作為提升承載的首要手段。這個我們不深入討論,一方面這個總會有上限,難有質變,另一方面不同遊戲優化方案千差萬別,都是程式碼級別的優化。

服務端優化和垂直擴充套件的目標類似,就是讓一臺機器能承載更多的玩家/邏輯。

2.2 消除系統單點和邏輯單點

上文介紹的消除單點主要是系統單點,也就是用多個程式而不是一個程式提供服務。

消除系統單點的前提是消除邏輯單點。

舉個例子:我們投放一個武器時,要給這個武器生成一個全服唯一的ID以標識此武器。這個ID可以使用一個自增的ID,此時就造成邏輯單點。

對於這種情況,如果遊戲中生成武器的頻率很低,那麼這種方案也可以,但如果武器生成頻率很高,因為遊戲中所有邏輯都需要去一個地方去申請這個ID,那就可能產生瓶頸。對於這種情況,我們一般可以使用uuid代替自增ID。(這個場景也常見於DB中的自增列,所以一般建議少使用自增)

2.3 資料庫承載

當玩家線上量達到一定的量級以後,往往對後端的資料庫造成很大的壓力。

一般來說,資料庫本身具有水平擴充套件能力,加上分庫分表等方案,提高承載能力比較容易。但設計資料庫結構時也需要考慮索引、shadkey等問題,不然嚴重影響資料庫效能。此外,要考慮資料庫併發讀寫能力,比如mongo中的MMAPv1儲存引擎是collection級別鎖,而WiredTiger儲存引擎是doc級別鎖,兩者的併發能力差別極大。

遊戲邏輯普遍比較複雜,資料讀寫量很大,如果每次玩家資訊變更都去讀寫資料庫,會造成較大的資料庫壓力。因此,遊戲的玩家服務一般都是有狀態服務,玩家上線時將資料從資料庫讀到記憶體中,線上期間讀寫資料都是直接操作記憶體,下線時或隔段時間去落地到資料庫。這種方案大大降低了資料庫讀寫操作,對資料庫壓力會小很多。

而一些資料讀寫操作頻率較低的服務,可以考慮將服務做成無狀態然後每次讀寫都去運算元據庫。

2.4 多叢集和跨叢集

當遊戲服務端達到一定的規模後,往往需要分叢集部署,分叢集解決的場景:

單叢集的承載具有上限,比如skynet只支援256個程式。

多區服需求,每個叢集對應遊戲的一個區服。如果遊戲支援多區服並且完全隔離沒有跨服通訊,實現高併發會容易很多。

全球通服,某些叢集希望部署到玩家所在地區。比如美國玩家所用的戰鬥服部署在美國,東南亞的戰鬥服部署在東南亞,而他們共用大廳服部署在某地。

多叢集中需要解決的一個問題是跨叢集通訊問題,叢集內一般是程式間全連線,但叢集間如果程式全連線會造成拓撲混亂連線數量爆炸的問題,因此叢集間通訊一般使用訊息匯流排,所有的叢集通過訊息匯流排進行通訊。

遊戲服務端的高併發和高可用

2.5 臨時的高併發

在遊戲業務場景中,玩家的線上和時間、活動等關係很大,不同的時間線上數量可能有幾倍幾十倍的差別。

對於預期內的高流量,可以通過提前做好擴容來進行承載,參考《忍三的服務端優化》中的“動態擴容和縮容”。

對於非預期的瞬間高併發,可以通過排隊系統將流量卡在系統外,動態擴容後再慢慢的進入遊戲中。

2.6 戰鬥場景中的高併發

遊戲還有一個特殊的高併發場景,就是MMO的大規模玩家在某場景聚集,比如國戰。

這種場景沒有完美的解決方案,只能儘量的提高承載量,常見的提高方案有:

將一個場景切分為cell,不同cell放到不同程式。比如bigworld/kbengine和最新的SpatialOS。

提升單程式承載能力。比如邏輯優化、垂直擴充套件、使用C++寫遊戲邏輯等,C++和python比起來效能有數量級的差別。

服務降級:簡化遊戲邏輯,比如國戰時一般只要玩家覺得場面熱鬧就差不多了,很多戰鬥邏輯其實都簡化掉了。

分服/分線/分副本:業務上讓玩家隔離。

我之前有篇文章《遊戲的數值系統的實現和演化》寫過我在之前一款遊戲中做的戰鬥服務端效能優化。

3、高可用

高可用追求系統在執行過程中儘量少的出現系統服務不可用的情況。

評價指標是服務在一個週期內的可用時間(SLA, Service Level Agreement),計算公式為服務可用性=(服務週期總分鐘數 - 服務不可用分鐘數)/服務週期總分鐘數×100%。

一般從兩個維度進行評價:1.系統的完全可用:所有服務對於所有使用者都是可用的。2.系統的整體可用:部分服務或者部分使用者不可用,但系統整體可用。
高可用的目標是爭取系統的完全可用,保證整體可用。

大叢集下的異常

由於機器故障、網路卡頓或斷線等客觀存在的小概率異常情況,服務端也需要考慮這些問題,尤其是在大叢集場景下,小概率事件累加變成了大概率事件,因此在大叢集伺服器場景下,高可用是我們必須要考慮的問題。

高可用,其實就是對各種異常狀況的隔離和處理,不讓小概率異常事件影響遊戲的整體服務。

常見的異常有以下幾種:

機器/程式/網路異常:阿里雲機器的可用性承諾是99.975%,大概是每臺機器保證一年的不可用時間在1小時以內。若叢集使用一百臺機器,理論上來說最差的情況是每三天就有一臺機器一小時不可用。當然,真實的可用性比阿里雲承諾的好很多,我們遊戲大概有100臺ECS機器,一年中有兩臺機器因為機器故障自動重啟,並沒有出現過持續的不可用。

Saas服務/DB異常:因為我們大數量使用了阿里雲的mysql、redis等雲服務,這些服務本身也有可用性問題,導致主從切換等。我們遊戲最常遇到的問題是因為redis主從切換造成網路閃斷需要網路重連。

業務BUG:對於業務BUG,儘量有辦法減少bug的影響,不讓某些小BUG導致系統整體不可用。

突發效能熱點:玩家的正常行為或異常行為導致的業務圖發繁忙,主要是上文所說的高併發問題。此外,對於某些玩家的異常行為(比如外掛\DDOS攻擊),也要保證不會影響系統整體可用。我記得很早之前(傳奇年代),有些外掛能直接讓伺服器重啟。

3.1 基於水平擴充套件實現高可用

上文中我們提到了水平擴充套件可以提高併發承載量,同時可以提高可用性,但側重點不同。對於高併發,水平擴充套件表示我們可以通過增加機器/程式提高承載量。對於高可用,是說當機器/程式出現異常或者崩潰時,不會影響叢集的整體可用。

在上文水平擴充套件中已經介紹,對於支援水平擴充套件的服務,有狀態服務出現異常只會影響到此程式所提供的服務,其他程式正常執行;對於無狀態服務,影響更小,只會影響到正在執行的流程。

當然,這需要我們寫一些處理邏輯,包括:

異常監控:通過異常監控,可以快速發現異常,一般使用心跳或者訊息超時機制。

異常處理:比如一個訊息超時後如何處理,是重試還是忽略。如果一個程式不可用,我們需要將此程式踢出叢集。

服務恢復:對於無狀態,直接重啟即可。對於有狀態,可以將狀態遷移到其他程式提供服務。服務恢復有很多坑(有狀態服務更多),常見的恢復方案比如將所服務的玩家直接踢下線,然後重新登陸。

服務降級:服務降級常見的排隊系統、關閉指定功能等。

如何實現上述邏輯其實挺複雜的,這裡不詳細介紹了。

服務隔離和灰度釋出

開發過程中,我們應該將大功能儘量的拆成一個個小的服務,每個服務只負責一小塊功能。Skynet也提供了比較好的Service模式,不同的Service可以放在一個程式中,也可以放在不同的程式中。

前文已經介紹服務隔離和灰度釋出,也是為了將高風險的服務進行隔離,讓它即使出現了問題也不要影響到系統的整體可用。

3.2 主從複製

對於有狀態服務,支援水平擴充套件的程式可以做到一個程式出現異常不影響其他程式提供服務,但這個程式crash了會導致這個程式提供的服務不可用,並且造成記憶體中的資料丟失等問題。

為了解決這個,常見的方案是主從複製。主從複製在資料庫中非常常見,是保證資料庫高可用性的常見方案。

主從複製就是在主節點(master)後面掛一個或者多個從節點(slave),主節點實時的將狀態/資料複製到從節點。平時是主節點提供服務,當主節點出現問題時,從節點變成主節點,繼續提供服務。因為主節點近乎事實的將資料複製到從節點,可以近似保證資料不丟失。

因此,如果想進一步的提升有狀態服務或者單點服務的可用性,可以使用主從複製的方案。

遊戲伺服器使用此方案寫業務邏輯的較少,有些叢集管理節點(非業務邏輯)會使用此方案。

此外,因為常見的db(mysql/mongo/redis)都自帶主從複製,所以無狀態服務其實也是將狀態讓db幫我們管理,從而獲得db主從複製帶來的資料不會丟失的能力。

3.3 雲服務的異常處理

除了ECS機器,我們大量使用了阿里雲的各類SAAS服務,比如redis/Mysql/Mongo等DB,也有類似於ELK的日誌服務等。

這些服務大部分都支援主從切換等高可用方案,但我們需要考慮當他們進行主從切換時對我們系統產生的影響。

在Mysql和Redis中,當發生主從切換或gateproxy當機,會導致網路連線斷線,因此,我們必須在邏輯中處理網路中斷並重連。在網路斷線重連階段,必然導致某些db請求失敗,我們也需要處理這種異常問題。

在資料落地場景中,需要判斷每次db請求是否成功,若不成功進行重試並且要保證請求的冪等性以防止請求多次執行。

4、高併發和高可用的目標

為了實現高併發和高可用本身具有較大的開發成本,在大部分專案中人力資源也不是無限的。所以大家在做相關工作的時候,也要過度設計,綜合考慮業務需求、承載預期和開發成本。

其實我說的開發成本不僅是說程式開發量更大,更多的是越複雜的系統越容易出現問題,如果沒有足夠的人力去測試、維護和迭代,還不如用更簡單的方式實現,反而出問題的概率更低。

當然,如果你的專案組是王者榮耀和和平精英,高併發和高可用要求非常高,人力近乎無限,請忽略此段。

百萬同時線上

在遊戲行業中,一般將百萬同時線上作為遊戲服務端架構的併發目標,百萬的數量級也是絕大部分遊戲(除了王者和吃雞)的上限。

所以,在遊戲前期架構設計、規劃和壓測中,我們可以按照百萬同時線上作為基準去預估不同的系統所需要的承載量,達到這個承載量就可以了。

比如我們遊戲,雖然也有不少單點和效能瓶頸,但根據我們的預估,即使這些單點存在,我們也能通過加機器支援到100w的同時線上。那麼,這些單點和瓶頸就在我們的預期範圍內,我們就不會進一步去優化了。

如果哪天我們遊戲大火需要支援千萬的同時線上了,理論上也可以繼續消除單點和優化效能瓶頸,但成本會大幅度增加。

高可用 != 完全可用

我們追求伺服器叢集的高可用,並不是要求對於所有的異常都能容災,那是不可能的,也是不現實的。

按照skynet的思想,若某節點出現異常沒有及時響應,所有訪問它的請求都會堵塞而不會timeout,若節點掛了,會直接報trace。相當於skynet把叢集看為一個整體,沒有做容錯機制,若某個核心節點掛了,就應該叢集整體不可用。

我上文說過,整體上我認可skynet的思想,可以有效地降低業務開發時的思想負擔。在這個基礎上,對於某些常見的異常,應該儘量降低影響,避免雪崩效應。

技術以外的高併發和高可用

高併發和高可用,和非技術也有較大的關係,比如運維能力、硬體情況,人員素質、管理水平等。

要解決高併發,需要部署大規模叢集,就會對對運維能力提出較高的要求。在使用者量小叢集小的情況下,人工運維是可以接受的,但是隨著叢集規模和複雜性的增加,人工運維會變得越來越難,必須要工具化和自動化。

而很多遊戲系統出現的問題,其實是由運維工作、流程和規範等問題導致的,比如戰雙上線後運營誤發福利。

所以,為了獲得高併發和高可用,運維工具、運維流程和規範一定要做好,不要人力運維,要做到運維的工具化和自動化。此外,監控、報警、人員的快速反應也是大規模系統穩定性執行的必要條件。



相關文章