如何構建超強伸縮性的遊戲服務器而集容錯、負載均衡和無限伸縮性於一身

如何構建超強伸縮性的遊戲服務器而集容錯、負載均衡和無限伸縮性於一身 

原文:Writing Low-Pain Massively Scalable Multiplayer Servers 

介紹 
本文以我的OpenPoker項目爲例子,講述了一個構建超強伸縮性的在線多遊戲玩家系統。 
OpenPoker是一個超強多玩家紙牌服務器,具有容錯、負載均衡和無限伸縮性等特性。 
源代碼位於我的個人站點上,大概10,000行代碼,其中1/3是測試代碼。 

在OpenPoker最終版本敲定之前我做了大量調研,我嘗試了Delphi、Python、C#、C/C++和Scheme。我還用Common Lisp寫了紙牌引擎。 
雖然我花費了9個月的時間研究原型,但是最終重寫時只花了6個星期的時間。 
我認爲我所節約的大部分時間都得益於選擇Erlang作爲平臺。 

相比之下,舊版本的OpenPoker花費了一個4~5人的團隊9個月時間。 

Erlang是什麼東東? 
我建議你在繼續閱讀本文之前瀏覽下Erlang FAQ,這裏我給你一個簡單的總結... 

Erlang是一個函數式動態類型編程語言並自帶併發支持。它是由Ericsson特別爲控制開關、轉換協議等電信應用設計的。 
Erlang十分適合構建分佈式、軟實時的併發系統。 

由Erlang所寫的程序通常由成百上千的輕量級進程組成,這些進程通過消息傳遞來通訊。 
Erlang進程間的上下文切換通常比C程序線程的上下文切換要廉價一到兩個數量級。 

使用Erlang寫分佈式程序很簡單,因爲它的分佈式機制是透明的:程序不需要了解它們是否分佈。 

Erlang運行時環境是一個虛擬機,類似於Java虛擬機。這意味着在一個價格上編譯的代碼可以在任何地方運行。 
運行時系統也允許在一個運行着的系統上不間斷的更新代碼。 
如果你需要額外的性能提升,字節碼也可以編譯成本地代碼。 

請移步Erlang site,參考Getting started、Documentation和Exampes章節等資源。 

爲何選擇Erlang? 
構建在Erlang骨子裏的併發模型特別適合寫在線多玩家服務器。 

一個超強伸縮性的多玩家Erlang後端構建爲擁有不同“節點”的“集羣”,不同節點做不同的任務。 
一個Erlang節點是一個Erlang VM實例,你可以在你的桌面、筆記本電腦或服務器上上運行多個Erlang節點/VM。 
推薦一個CPU一個節點。 


Erlang節點會追蹤所有其他和它相連的節點。向集羣裏添加一個新節點所需要的只是將該新節點指向一個已有的節點。 
一旦這兩個節點建立連接,集羣裏所有其他的節點都會知曉這個新節點。 

Erlang進程使用一個進程id來相互發消息,進程id包含了節點在哪裏運行的信息。進程不需要知道其他進程在哪裏就可以通訊。 
連接在一起的Erlang節點集可以看作一個網格或者超級計算設備。 

超多玩家遊戲裏玩家、NPC和其他實體最好建模爲並行運行的進程,但是並行很難搞是衆所皆知的。Erlang讓並行變得簡單。 

Erlang的位語法∞讓它在處理結構封裝/拆解的能力上比Perl和Python都要強大。這讓Erlang特別適合處理二進制網絡協議。 

OpenPoker架構 
OpenPoker裏的任何東西都是進程。玩家、機器人、遊戲等等多是進程。 
對於每個連接到OpenPoker的客戶端都有一個玩家“代理”來處理網絡消息。 
根據玩家是否登錄來決定部分消息忽略,而另一部分消息則發送給處理紙牌遊戲邏輯的進程。 

紙牌遊戲進程是一個狀態機,包含了遊戲每一階段的狀態。 
這可以讓我們將紙牌遊戲邏輯當作堆積木,只需將狀態機構建塊放在一起就可以添加新的紙牌遊戲。 
如果你想了解更多的話可以看看cardgame.erl的start方法。 

紙牌遊戲狀態機根據遊戲狀態來決定不同的消息是否通過。 
同時也使用一個單獨的遊戲進程來處理所有遊戲共有的一些東西,如跟蹤玩家、pot和限制等等。 
當在我的筆記本電腦上模擬27,000個紙牌遊戲時我發現我擁有大約136,000個玩家以及總共接近800,000個進程。

下面我將以OpenPoker爲例子,專注於講述怎樣基於Erlang讓實現伸縮性、容錯和負載均衡變簡單。 
我的方式不是特別針對紙牌遊戲。同樣的方式可以用在其他地方。 

伸縮性 
我通過多層架構來實現伸縮性和負載均衡。 
第一層是網關節點。 
遊戲服務器節點組成第二層。 
Mnesia“master”節點可以認爲是第三層。 

Mnesia是Erlang實時分佈式數據庫。Mnesia FAQ有一個很詳細的解釋。Mnesia基本上是一個快速的、可備份的、位於內存中的數據庫。 
Erlang裏沒有對象,但是Mnesia可以認爲是面向對象的,因爲它可以存儲任何Erlang數據。 

有兩種類型的Mnesia節點:寫到硬盤的節點和不寫到硬盤的節點。除了這些節點,所有其他的Mnesia節點將數據保存在內存中。 
在OpenPoker裏Mnesia master節點會將數據寫入硬盤。網關和遊戲服務器從Mnesia master節點獲得數據庫並啓動,它們只是內存節點。 

當啓動Mnesia時,你可以給Erlang VM和解釋器一些命令行參數來告訴Mnesia master數據庫在哪裏。 
當一個新的本地Mnesia節點與master Mnesia節點建立連接之後,新節點變成master節點集羣的一部分。 

假設master節點位於apple和orange節點上,添加一個新的網關、遊戲服務器等等。OpenPoker集羣簡單的如下所示: 
Java代碼  收藏代碼
  1. erl -mnesia extra_db_nodes \['db@apple','db@orange'\] -s mnesia start  

-s mnesia start相當於這樣在erlang shell裏啓動Mnedia: 
Java代碼  收藏代碼
  1. erl -mnesia extra_db_nodes \['db@apple','db@orange'\]  
  2. Erlang (BEAM) emulator version 5.4.8 [source] [hipe] [threads:0]  
  3.   
  4. Eshell V5.4.8 (abort with ^G)  
  5. 1> mnesia:start().  
  6. ok  

OpenPoker在Mnesia表裏保存配置信息,並且這些信息在Mnesia啓動後立即自動被新的節點下載。零配置! 

容錯 
通過添加廉價的Linux機器到我的服務器集羣,OpenPoker讓我隨心所欲的變大。 
將幾架1U的服務器放在一起,這樣你就可以輕易的處理500,000甚至1,000,000的在線玩家。這對MMORPG也是一樣。 

我讓一些機器運行網關節點,另一些運行數據庫master來寫數據庫事務到硬盤,讓其他的機器運行遊戲服務器。 
我限制遊戲服務器接受最多5000個併發的玩家,這樣當遊戲服務器崩潰時最多影響5000個玩家。 

值得注意的是,當遊戲服務器崩潰時沒有任何信息丟失,因爲所有的Mnesia數據庫事務都是實時備份到其他運行Mnesia以及遊戲服務器的節點上的。 

爲了預防出錯,遊戲客戶端必須提供一些援助來平穩的重連接OpenPoker集羣。 
一旦客戶端發現一個網絡錯誤,它應該連接網關,接受一個新的遊戲服務器地址,然後重新連接新的遊戲服務器。 
下面發生的事情需要一定技巧,因爲不同類型的重連接場景需要不同的處理。 

OpenPoker會處理如下幾種重連接的場景: 
1,遊戲服務器崩潰 
2,客戶端崩潰或者由於網絡原因超時 
3,玩家在線並且在一個不同的連接上 
4,玩家在線並且在一個不同的連接上並在一個遊戲中 

最常見的場景是一個客戶端由於網絡出錯而重新連接。 
比較少見但仍然可能的場景是客戶端已經在一臺機器上玩遊戲,而此時從另一臺機器上重連接。 

每個發送給玩家的OpenPoker遊戲緩衝包和每個重連接的客戶端將首先接受所有的遊戲包,因爲遊戲不是像通常那樣正常啓動然後接受包。 
OpenPoker使用TCP連接,這樣我不需要擔心包的順序——包會按正確的順序到達。 

每個客戶端連接由兩個OpenPoker進程來表現:socket進程和真正的玩家進程。 
先使用一個功能受限的visitor進程,直到玩家登錄。例如visitor不能參加遊戲。 
在客戶端斷開連接後,socket進程死掉,而玩家進程仍然活着。 

當玩家進程嘗試發送一個遊戲包時可以通知一個死掉的socket,並讓它自己進入auto-play模式或者掛起。 
在重新連接時登錄代碼將檢查死掉的socket和活着的玩家進程的結合。代碼如下: 
Java代碼  收藏代碼
  1. login({atomic, [Player]}, [_Nick, Pass|_] = Args)  
  2.   when is_record(Player, player) ->  
  3.     Player1 = Player#player {  
  4.       socket = fix_pid(Player#player.socket),  
  5.       pid = fix_pid(Player#player.pid)  
  6.     },  
  7.     Condition = check_player(Player1, [Pass],  
  8.       [  
  9.         fun is_account_disabled/2,  
  10.         fun is_bad_password/2,  
  11.         fun is_player_busy/2,  
  12.         fun is_player_online/2,  
  13.         fun is_client_down/2,  
  14.         fun is_offline/2  
  15.       ]),  
  16.     ...  

condition本身由如下代碼決定: 
Java代碼  收藏代碼
  1. is_player_busy(Player, _) ->  
  2.   {Online, _} = is_player_online(Player, []),  
  3.   Playing = Player#player.game /= none,  
  4.   {Online and Playing, player_busy}.  
  5.   
  6. is_player_online(Player, _) ->  
  7.   SocketAlive = Player#player.socket /= none,  
  8.   PlayerAlive = Player#player.pid /= none,  
  9.   {SocketAlive and PlayerAlive, player_online}.  
  10.   
  11. is_client_down(Player, _) ->  
  12.   SocketDown = Player#player.socket == none,  
  13.   PlayerAlive = Player#player.pid /= none,  
  14.   {SocketDown and PlayerAlive, client_down}.  
  15.   
  16. is_offline(Player, _) ->  
  17.   SocketDown = Player#player.socket == none,  
  18.   PlayerDown = Player#player.pid == none,  
  19.   {SocketDown and PlayerDown, player_offline}.  

注意login方法的第一件事是修復死掉的進程id: 
Java代碼  收藏代碼
  1. fix_pid(Pid)  
  2.   when is_pid(Pid) ->  
  3.     case util:is_process_alive(Pid) of  
  4.     true ->  
  5.       Pid;  
  6.     _->  
  7.       none  
  8.     end;  
  9.   
  10. fix_pid(Pid) ->  
  11.     Pid.  

以及: 
Java代碼  收藏代碼
  1. -module(util).  
  2.   
  3. -export([is_process_alive/1]).  
  4.   
  5. is_process_alive(Pid)  
  6.   when is_pid(Pid) ->  
  7.     rpc:call(node(Pid), erlang, is_process_alive, [Pid]).  

Erlang裏一個進程id包括正在運行的進程的節點的id。 
is_pid(Pid)告訴我它的參數是否是一個進程id(pid),但是不能告訴我進程是活着還是死了。 
Erlang自帶的erlang:is_process_alive(Pid)告訴我一個本地進程(運行在同一節點上)是活着還是死了,但沒有檢查遠程節點是或者還是死了的is_process_alive變種。 

還好,我可以使用Erlang rpc工具和node(pid)來在遠程節點上調用is_process_alive()。 
事實上,這跟在本地節點上一樣工作,這樣上面的代碼就可以作爲全局分佈式進程檢查器。 

剩下的唯一的事情是在不同的登錄條件上活動。 
最簡單的情況是玩家離線,我期待一個玩家進程,連接玩家到socket並更新player record。 
Java代碼  收藏代碼
  1. login(Player, player_offline, [Nick, _, Socket]) ->  
  2.   {ok, Pid} = player:start(Nick),  
  3.   OID = gen_server:call(Pid, 'ID'),  
  4.   gen_server:cast(Pid, {'SOCKET', Socket}),  
  5.   Player1 = Player#player {  
  6.     oid = OID,  
  7.     pid = Pid,  
  8.     socket = Socket  
  9.   },  
  10.   {Player1, {ok, Pid}}.  

假如玩家登陸信息不匹配,我可以返回一個錯誤並增加錯誤登錄次數。如果次數超過一個預定義的最大值,我就禁止該帳號: 
Java代碼  收藏代碼
  1. login(Player, bad_password, _) ->  
  2.   N = Player#player.login_errors + 1,  
  3.   {atomic, MaxLoginErrors} =  
  4.   db:get(cluster_config, 0, max_login_errors),  
  5.   if  
  6.   N > MaxLoginErrors ->  
  7.     Player1 = Player#player {  
  8.       disabled = true  
  9.     },  
  10.     {Player1, {error, ?ERR_ACCOUNT_DISABLED}};  
  11.   true ->  
  12.     Player1 = Player#player {  
  13.       login_errors =N  
  14.     },  
  15.     {Player1, {error, ?ERR_BAD_LOGIN}}  
  16.   end;  
  17.   
  18. login(Player, account_disabled, _) ->  
  19.     {Player, {error, ?ERR_ACCOUNT_DISABLED}};  

註銷玩家包括使用Object ID(只是一個數字)找到玩家進程id,停止玩家進程,然後在數據庫更新玩家record: 
Java代碼  收藏代碼
  1. logout(OID) ->  
  2.   case db:find(player, OID) of  
  3.   {atomic, [Player]} ->  
  4.     player:stop(Player#player.pid),  
  5.     {atomic, ok} = db:set(player, OID,  
  6.       [{pid, none},  
  7.       {socket, none}];  
  8.   _->  
  9.     oops  
  10.   end.  

這樣我就可以完成多種重連接condition,例如從不同的機器重連接,我只需先註銷再登錄: 
Java代碼  收藏代碼
  1. login(Player, player_online, Args) ->  
  2.   logout(Player#player.oid),  
  3.   login(Player, player_offline, Args);  

如果玩家空閒時客戶端重連接,我所需要做的只是在玩家record裏替換socket進程id然後告訴玩家進程新的socket: 
Java代碼  收藏代碼
  1. login(Player, client_down, [_, _, SOcket]) ->  
  2.   gen_server:cast(Player#player.pid, {'SOCKET', Socket}),  
  3.   Player1 = Player#player {  
  4.     socket = Socket  
  5.   },  
  6.   {Player1, {ok, Player#player.pid}};  

如果玩家在遊戲中,這是我們運行上面的代碼,然後告訴遊戲重新發送時間歷史: 
Java代碼  收藏代碼
  1. login(Player, player_busy, Args) ->  
  2.   Temp = login(Player, client_down, Args),  
  3.   cardgame:cast(Player#player.game,  
  4.     {'RESEND UPDATES', Player#player.pid}),  
  5.   Temp;  

總體來說,一個實時備份數據庫,一個知道重新建立連接到不同的遊戲服務器的客戶端和一些有技巧的登錄代碼運行我提供一個高級容錯系統並且對玩家透明。 

負載均衡 
我可以構建自己的OpenPoker集羣,遊戲服務器數量大小隨心所欲。 
我希望每臺遊戲服務器分配5000個玩家,然後在集羣的活動遊戲服務器間分散負載。 
我可以在任何時間添加一個新的遊戲服務器,並且它們將自動賦予自己接受新玩家的能力。 

網關節點分散玩家負載到OpenPoker集羣裏活動的遊戲服務器。 
網關節點的工作是選擇一個隨機的遊戲服務器,詢問它所連接的玩家數量和它的地址、主機和端口號。 
一旦網關找到一個遊戲服務器並且連接的玩家數量少於最大值,它將返回該遊戲服務器的地址到連接的客戶端,然後關閉連接。 

網關上絕對沒有壓力,網關的連接都非常短。你可以使用非常廉價的機器來做網關節點。 

節點一般都成雙成對出現,這樣一個節點崩潰後還有另一個繼續工作。你可能需要一個類似於Round-robin DNS的機制來保證不只一個單獨的網關節點。 

網關怎麼知曉遊戲服務器? 

OpenPoker使用Erlang Distirbuted Named Process Groups工具來爲遊戲服務器分組。 
該組自動對所有的節點全局可見。 
新的遊戲服務器進入遊戲服務器後,當一個遊戲服務器節點崩潰時它被自動刪除。 

這是尋找容量最大爲MaxPlayers的遊戲服務器的代碼: 
Java代碼  收藏代碼
  1. find_server(MaxPlayers) ->  
  2.   case pg2:get_closest_pid(?GAME_SERVER) of  
  3.   Pid when is_pid(Pid) ->  
  4.     {Time, {Host, Port}} = timer:tc(gen_server, call, [Pid, 'WHERE']),  
  5.     Coutn = gen_server:call(Pid, 'USER COUNT'),  
  6.     if  
  7.       Count < MaxPlayers ->  
  8.         io:format("~s:~w ~w players~n", [Host, Port, Count]),  
  9.         {Host, Port};  
  10.       true ->  
  11.         io:format("~s:~w is full...~n", [Host, Port]),  
  12.         find_server(MaxPlayers)  
  13.     end;  
  14.   Any ->  
  15.     Any  
  16.   end.  

pg2:get_closest_pid()返回一個隨機的遊戲服務器進程id,因爲網關節點上不允許跑任何遊戲服務器。 
如果一個遊戲服務器進程id返回,我詢問遊戲服務器的地址(host和port)和連接的玩家數量。 
只要連接的玩家數量少於最大值,我返回遊戲服務器地址給調用者,否則繼續查找。 

多出口電源插座中間件 
OpenPoker是一個開源軟件,我最近在將它推銷給多個紙牌遊戲廠商。 
所有的廠商都有同樣的伸縮性和容錯的問題,即使做了多年開發。 
有的最近剛剛完成服務器軟件重寫,而有的剛剛開始。 
所有的廠商都嚴重依賴於它們的Java基礎架構,可以理解,它們不想換Erlang。 

看來有一個需求必須滿足。我思考的越多,發現Erlang越適合提供高效的解決方案。 
我把這個解決方案看作一個多出口電源插座。 

你可以像寫一個使用數據庫後端的基於socket的服務器一樣來寫遊戲服務器。 
事實上,目前遊戲服務器就是這樣寫的。 
遊戲服務器是標準的電源插頭,遊戲服務器的多個實例插入到電源插座中,而玩家從另一端流過。 

你提供遊戲服務器,而我提供伸縮性、負載均衡和容錯。 
我讓玩家連接到電源插座並監控你的遊戲服務器,必要時重啓它們。 
當一個遊戲服務器崩潰時我將玩家切換到另一臺遊戲服務器,你可以往插座裏插入任意多的遊戲服務器。 

電源插座中間件是一個黑盒子,它位於你的玩家和你的服務器之間,很可能不需要你改動任何代碼。 
你會得到伸縮性、負載均衡、容錯等諸多益處而只需改動極少的一部分現有架構。 

今天你就可以用Erlang寫這個中間件,然後運行在一個內核調優過以支持大量TCP連接的Linux機器上,而將你的服務器放在一個防火牆後面。 
即使你不這樣做,我建議你馬上仔細看看Erlang,想想如何使用它來簡化你的超強多玩家服務器架構。而我會在這兒幫助你! 
發佈了15 篇原創文章 · 獲贊 3 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章