本文以我的OpenPoker項(xiàng)目為例子,講述了一個(gè)構(gòu)建超強(qiáng)伸縮性的在線多游戲玩家系統(tǒng)。
OpenPoker是一個(gè)超強(qiáng)多玩家紙牌服務(wù)器,具有容錯(cuò)、負(fù)載均衡和無(wú)限伸縮性等特性。
源代碼位于我的個(gè)人站點(diǎn)上,大概10,000行代碼,其中1/3是測(cè)試代碼。
在OpenPoker最終版本敲定之前我做了大量調(diào)研,我嘗試了Delphi、Python、C#、C/C++和Scheme。我還用Common Lisp寫了紙牌引擎。
雖然我花費(fèi)了9個(gè)月的時(shí)間研究原型,但是最終重寫時(shí)只花了6個(gè)星期的時(shí)間。
我認(rèn)為我所節(jié)約的大部分時(shí)間都得益于選擇Erlang作為平臺(tái)。
相比之下,舊版本的OpenPoker花費(fèi)了一個(gè)4~5人的團(tuán)隊(duì)9個(gè)月時(shí)間。
Erlang是什么東東?
我建議你在繼續(xù)閱讀本文之前瀏覽下Erlang FAQ,這里我給你一個(gè)簡(jiǎn)單的總結(jié)...
Erlang是一個(gè)函數(shù)式動(dòng)態(tài)類型編程語(yǔ)言并自帶并發(fā)支持。它是由Ericsson特別為控制開關(guān)、轉(zhuǎn)換協(xié)議等電信應(yīng)用設(shè)計(jì)的。
Erlang十分適合構(gòu)建分布式、軟實(shí)時(shí)的并發(fā)系統(tǒng)。
由Erlang所寫的程序通常由成百上千的輕量級(jí)進(jìn)程組成,這些進(jìn)程通過消息傳遞來(lái)通訊。
Erlang進(jìn)程間的上下文切換通常比C程序線程的上下文切換要廉價(jià)一到兩個(gè)數(shù)量級(jí)。
使用Erlang寫分布式程序很簡(jiǎn)單,因?yàn)樗姆植际綑C(jī)制是透明的:程序不需要了解它們是否分布。
Erlang運(yùn)行時(shí)環(huán)境是一個(gè)虛擬機(jī),類似于Java虛擬機(jī)。這意味著在一個(gè)價(jià)格上編譯的代碼可以在任何地方運(yùn)行。
運(yùn)行時(shí)系統(tǒng)也允許在一個(gè)運(yùn)行著的系統(tǒng)上不間斷的更新代碼。
如果你需要額外的性能提升,字節(jié)碼也可以編譯成本地代碼。
請(qǐng)移步Erlang site,參考Getting started、Documentation和Exampes章節(jié)等資源。
為何選擇Erlang?
構(gòu)建在Erlang骨子里的并發(fā)模型特別適合寫在線多玩家服務(wù)器。
一個(gè)超強(qiáng)伸縮性的多玩家Erlang后端構(gòu)建為擁有不同“節(jié)點(diǎn)”的“集群”,不同節(jié)點(diǎn)做不同的任務(wù)。
一個(gè)Erlang節(jié)點(diǎn)是一個(gè)Erlang VM實(shí)例,你可以在你的桌面、筆記本電腦或服務(wù)器上上運(yùn)行多個(gè)Erlang節(jié)點(diǎn)/VM。
推薦一個(gè)CPU一個(gè)節(jié)點(diǎn)。
Erlang節(jié)點(diǎn)會(huì)追蹤所有其他和它相連的節(jié)點(diǎn)。向集群里添加一個(gè)新節(jié)點(diǎn)所需要的只是將該新節(jié)點(diǎn)指向一個(gè)已有的節(jié)點(diǎn)。
一旦這兩個(gè)節(jié)點(diǎn)建立連接,集群里所有其他的節(jié)點(diǎn)都會(huì)知曉這個(gè)新節(jié)點(diǎn)。
Erlang進(jìn)程使用一個(gè)進(jìn)程id來(lái)相互發(fā)消息,進(jìn)程id包含了節(jié)點(diǎn)在哪里運(yùn)行的信息。進(jìn)程不需要知道其他進(jìn)程在哪里就可以通訊。
連接在一起的Erlang節(jié)點(diǎn)集可以看作一個(gè)網(wǎng)格或者超級(jí)計(jì)算設(shè)備。
超多玩家游戲里玩家、NPC和其他實(shí)體最好建模為并行運(yùn)行的進(jìn)程,但是并行很難搞是眾所皆知的。Erlang讓并行變得簡(jiǎn)單。
Erlang的位語(yǔ)法∞讓它在處理結(jié)構(gòu)封裝/拆解的能力上比Perl和Python都要強(qiáng)大。這讓Erlang特別適合處理二進(jìn)制網(wǎng)絡(luò)協(xié)議。
OpenPoker架構(gòu)
OpenPoker里的任何東西都是進(jìn)程。玩家、機(jī)器人、游戲等等多是進(jìn)程。
對(duì)于每個(gè)連接到OpenPoker的客戶端都有一個(gè)玩家“代理”來(lái)處理網(wǎng)絡(luò)消息。
根據(jù)玩家是否登錄來(lái)決定部分消息忽略,而另一部分消息則發(fā)送給處理紙牌游戲邏輯的進(jìn)程。
紙牌游戲進(jìn)程是一個(gè)狀態(tài)機(jī),包含了游戲每一階段的狀態(tài)。
這可以讓我們將紙牌游戲邏輯當(dāng)作堆積木,只需將狀態(tài)機(jī)構(gòu)建塊放在一起就可以添加新的紙牌游戲。
如果你想了解更多的話可以看看cardgame.erl的start方法。
紙牌游戲狀態(tài)機(jī)根據(jù)游戲狀態(tài)來(lái)決定不同的消息是否通過。
同時(shí)也使用一個(gè)單獨(dú)的游戲進(jìn)程來(lái)處理所有游戲共有的一些東西,如跟蹤玩家、pot和限制等等。
當(dāng)在我的筆記本電腦上模擬27,000個(gè)紙牌游戲時(shí)我發(fā)現(xiàn)我擁有大約136,000個(gè)玩家以及總共接近800,000個(gè)進(jìn)程。
下面我將以O(shè)penPoker為例子,專注于講述怎樣基于Erlang讓實(shí)現(xiàn)伸縮性、容錯(cuò)和負(fù)載均衡變簡(jiǎn)單。
我的方式不是特別針對(duì)紙牌游戲。同樣的方式可以用在其他地方。
伸縮性
我通過多層架構(gòu)來(lái)實(shí)現(xiàn)伸縮性和負(fù)載均衡。
第一層是網(wǎng)關(guān)節(jié)點(diǎn)。
游戲服務(wù)器節(jié)點(diǎn)組成第二層。
Mnesia“master”節(jié)點(diǎn)可以認(rèn)為是第三層。
Mnesia是Erlang實(shí)時(shí)分布式數(shù)據(jù)庫(kù)。Mnesia FAQ有一個(gè)很詳細(xì)的解釋。Mnesia基本上是一個(gè)快速的、可備份的、位于內(nèi)存中的數(shù)據(jù)庫(kù)。
Erlang里沒有對(duì)象,但是Mnesia可以認(rèn)為是面向?qū)ο蟮,因(yàn)樗梢源鎯?chǔ)任何Erlang數(shù)據(jù)。
有兩種類型的Mnesia節(jié)點(diǎn):寫到硬盤的節(jié)點(diǎn)和不寫到硬盤的節(jié)點(diǎn)。除了這些節(jié)點(diǎn),所有其他的Mnesia節(jié)點(diǎn)將數(shù)據(jù)保存在內(nèi)存中。
在OpenPoker里Mnesia master節(jié)點(diǎn)會(huì)將數(shù)據(jù)寫入硬盤。網(wǎng)關(guān)和游戲服務(wù)器從Mnesia master節(jié)點(diǎn)獲得數(shù)據(jù)庫(kù)并啟動(dòng),它們只是內(nèi)存節(jié)點(diǎn)。
當(dāng)啟動(dòng)Mnesia時(shí),你可以給Erlang VM和解釋器一些命令行參數(shù)來(lái)告訴Mnesia master數(shù)據(jù)庫(kù)在哪里。
當(dāng)一個(gè)新的本地Mnesia節(jié)點(diǎn)與master Mnesia節(jié)點(diǎn)建立連接之后,新節(jié)點(diǎn)變成master節(jié)點(diǎn)集群的一部分。
假設(shè)master節(jié)點(diǎn)位于apple和orange節(jié)點(diǎn)上,添加一個(gè)新的網(wǎng)關(guān)、游戲服務(wù)器等等。OpenPoker集群簡(jiǎn)單的如下所示:
- erl -mnesia extra_db_nodes \['db@apple','db@orange'\] -s mnesia start
-s mnesia start相當(dāng)于這樣在erlang shell里啟動(dòng)Mnedia:
- erl -mnesia extra_db_nodes \['db@apple','db@orange'\]
- Erlang (BEAM) emulator version 5.4.8 [source] [hipe] [threads:0]
- Eshell V5.4.8 (abort with ^G)
- 1> mnesia:start().
- ok
OpenPoker在Mnesia表里保存配置信息,并且這些信息在Mnesia啟動(dòng)后立即自動(dòng)被新的節(jié)點(diǎn)下載。零配置!
容錯(cuò)
通過添加廉價(jià)的Linux機(jī)器到我的服務(wù)器集群,OpenPoker讓我隨心所欲的變大。
將幾架1U的服務(wù)器放在一起,這樣你就可以輕易的處理500,000甚至1,000,000的在線玩家。這對(duì)MMORPG也是一樣。
我讓一些機(jī)器運(yùn)行網(wǎng)關(guān)節(jié)點(diǎn),另一些運(yùn)行數(shù)據(jù)庫(kù)master來(lái)寫數(shù)據(jù)庫(kù)事務(wù)到硬盤,讓其他的機(jī)器運(yùn)行游戲服務(wù)器。
我限制游戲服務(wù)器接受最多5000個(gè)并發(fā)的玩家,這樣當(dāng)游戲服務(wù)器崩潰時(shí)最多影響5000個(gè)玩家。
值得注意的是,當(dāng)游戲服務(wù)器崩潰時(shí)沒有任何信息丟失,因?yàn)樗械腗nesia數(shù)據(jù)庫(kù)事務(wù)都是實(shí)時(shí)備份到其他運(yùn)行Mnesia以及游戲服務(wù)器的節(jié)點(diǎn)上的。
為了預(yù)防出錯(cuò),游戲客戶端必須提供一些援助來(lái)平穩(wěn)的重連接OpenPoker集群。
一旦客戶端發(fā)現(xiàn)一個(gè)網(wǎng)絡(luò)錯(cuò)誤,它應(yīng)該連接網(wǎng)關(guān),接受一個(gè)新的游戲服務(wù)器地址,然后重新連接新的游戲服務(wù)器。
下面發(fā)生的事情需要一定技巧,因?yàn)椴煌愋偷闹剡B接場(chǎng)景需要不同的處理。
OpenPoker會(huì)處理如下幾種重連接的場(chǎng)景:
1,游戲服務(wù)器崩潰
2,客戶端崩潰或者由于網(wǎng)絡(luò)原因超時(shí)
3,玩家在線并且在一個(gè)不同的連接上
4,玩家在線并且在一個(gè)不同的連接上并在一個(gè)游戲中
最常見的場(chǎng)景是一個(gè)客戶端由于網(wǎng)絡(luò)出錯(cuò)而重新連接。
比較少見但仍然可能的場(chǎng)景是客戶端已經(jīng)在一臺(tái)機(jī)器上玩游戲,而此時(shí)從另一臺(tái)機(jī)器上重連接。
每個(gè)發(fā)送給玩家的OpenPoker游戲緩沖包和每個(gè)重連接的客戶端將首先接受所有的游戲包,因?yàn)橛螒虿皇窍裢ǔD菢诱?dòng)然后接受包。
OpenPoker使用TCP連接,這樣我不需要擔(dān)心包的順序——包會(huì)按正確的順序到達(dá)。
每個(gè)客戶端連接由兩個(gè)OpenPoker進(jìn)程來(lái)表現(xiàn):socket進(jìn)程和真正的玩家進(jìn)程。
先使用一個(gè)功能受限的visitor進(jìn)程,直到玩家登錄。例如visitor不能參加游戲。
在客戶端斷開連接后,socket進(jìn)程死掉,而玩家進(jìn)程仍然活著。
當(dāng)玩家進(jìn)程嘗試發(fā)送一個(gè)游戲包時(shí)可以通知一個(gè)死掉的socket,并讓它自己進(jìn)入auto-play模式或者掛起。
在重新連接時(shí)登錄代碼將檢查死掉的socket和活著的玩家進(jìn)程的結(jié)合。代碼如下:
- login({atomic, [Player]}, [_Nick, Pass|_] = Args)
- when is_record(Player, player) ->
- Player1 = Player#player {
- socket = fix_pid(Player#player.socket),
- pid = fix_pid(Player#player.pid)
- },
- Condition = check_player(Player1, [Pass],
- [
- fun is_account_disabled/2,
- fun is_bad_password/2,
- fun is_player_busy/2,
- fun is_player_online/2,
- fun is_client_down/2,
- fun is_offline/2
- ]),
- ...
condition本身由如下代碼決定:
- is_player_busy(Player, _) ->
- {Online, _} = is_player_online(Player, []),
- Playing = Player#player.game /= none,
- {Online and Playing, player_busy}.
- is_player_online(Player, _) ->
- SocketAlive = Player#player.socket /= none,
- PlayerAlive = Player#player.pid /= none,
- {SocketAlive and PlayerAlive, player_online}.
- is_client_down(Player, _) ->
- SocketDown = Player#player.socket == none,
- PlayerAlive = Player#player.pid /= none,
- {SocketDown and PlayerAlive, client_down}.
- is_offline(Player, _) ->
- SocketDown = Player#player.socket == none,
- PlayerDown = Player#player.pid == none,
- {SocketDown and PlayerDown, player_offline}.
注意login方法的第一件事是修復(fù)死掉的進(jìn)程id:
- fix_pid(Pid)
- when is_pid(Pid) ->
- case util:is_process_alive(Pid) of
- true ->
- Pid;
- _->
- none
- end;
- fix_pid(Pid) ->
- Pid.
以及:
- -module(util).
- -export([is_process_alive/1]).
- is_process_alive(Pid)
- when is_pid(Pid) ->
- rpc:call(node(Pid), erlang, is_process_alive, [Pid]).
Erlang里一個(gè)進(jìn)程id包括正在運(yùn)行的進(jìn)程的節(jié)點(diǎn)的id。
is_pid(Pid)告訴我它的參數(shù)是否是一個(gè)進(jìn)程id(pid),但是不能告訴我進(jìn)程是活著還是死了。
Erlang自帶的erlang:is_process_alive(Pid)告訴我一個(gè)本地進(jìn)程(運(yùn)行在同一節(jié)點(diǎn)上)是活著還是死了,但沒有檢查遠(yuǎn)程節(jié)點(diǎn)是或者還是死了的is_process_alive變種。
還好,我可以使用Erlang rpc工具和node(pid)來(lái)在遠(yuǎn)程節(jié)點(diǎn)上調(diào)用is_process_alive()。
事實(shí)上,這跟在本地節(jié)點(diǎn)上一樣工作,這樣上面的代碼就可以作為全局分布式進(jìn)程檢查器。
剩下的唯一的事情是在不同的登錄條件上活動(dòng)。
最簡(jiǎn)單的情況是玩家離線,我期待一個(gè)玩家進(jìn)程,連接玩家到socket并更新player record。
- login(Player, player_offline, [Nick, _, Socket]) ->
- {ok, Pid} = player:start(Nick),
- OID = gen_server:call(Pid, 'ID'),
- gen_server:cast(Pid, {'SOCKET', Socket}),
- Player1 = Player#player {
- oid = OID,
- pid = Pid,
- socket = Socket
- },
- {Player1, {ok, Pid}}.
假如玩家登陸信息不匹配,我可以返回一個(gè)錯(cuò)誤并增加錯(cuò)誤登錄次數(shù)。如果次數(shù)超過一個(gè)預(yù)定義的最大值,我就禁止該帳號(hào):
- login(Player, bad_password, _) ->
- N = Player#player.login_errors + 1,
- {atomic, MaxLoginErrors} =
- db:get(cluster_config, 0, max_login_errors),
- if
- N > MaxLoginErrors ->
- Player1 = Player#player {
- disabled = true
- },
- {Player1, {error, ?ERR_ACCOUNT_DISABLED}};
- true ->
- Player1 = Player#player {
- login_errors =N
- },
- {Player1, {error, ?ERR_BAD_LOGIN}}
- end;
- login(Player, account_disabled, _) ->
- {Player, {error, ?ERR_ACCOUNT_DISABLED}};
注銷玩家包括使用Object ID(只是一個(gè)數(shù)字)找到玩家進(jìn)程id,停止玩家進(jìn)程,然后在數(shù)據(jù)庫(kù)更新玩家record:
- logout(OID) ->
- case db:find(player, OID) of
- {atomic, [Player]} ->
- player:stop(Player#player.pid),
- {atomic, ok} = db:set(player, OID,
- [{pid, none},
- {socket, none}];
- _->
- oops
- end.
這樣我就可以完成多種重連接condition,例如從不同的機(jī)器重連接,我只需先注銷再登錄:
- login(Player, player_online, Args) ->
- logout(Player#player.oid),
- login(Player, player_offline, Args);
如果玩家空閑時(shí)客戶端重連接,我所需要做的只是在玩家record里替換socket進(jìn)程id然后告訴玩家進(jìn)程新的socket:
- login(Player, client_down, [_, _, SOcket]) ->
- gen_server:cast(Player#player.pid, {'SOCKET', Socket}),
- Player1 = Player#player {
- socket = Socket
- },
- {Player1, {ok, Player#player.pid}};
如果玩家在游戲中,這是我們運(yùn)行上面的代碼,然后告訴游戲重新發(fā)送時(shí)間歷史:
- login(Player, player_busy, Args) ->
- Temp = login(Player, client_down, Args),
- cardgame:cast(Player#player.game,
- {'RESEND UPDATES', Player#player.pid}),
- Temp;
總體來(lái)說,一個(gè)實(shí)時(shí)備份數(shù)據(jù)庫(kù),一個(gè)知道重新建立連接到不同的游戲服務(wù)器的客戶端和一些有技巧的登錄代碼運(yùn)行我提供一個(gè)高級(jí)容錯(cuò)系統(tǒng)并且對(duì)玩家透明。
負(fù)載均衡
我可以構(gòu)建自己的OpenPoker集群,游戲服務(wù)器數(shù)量大小隨心所欲。
我希望每臺(tái)游戲服務(wù)器分配5000個(gè)玩家,然后在集群的活動(dòng)游戲服務(wù)器間分散負(fù)載。
我可以在任何時(shí)間添加一個(gè)新的游戲服務(wù)器,并且它們將自動(dòng)賦予自己接受新玩家的能力。
網(wǎng)關(guān)節(jié)點(diǎn)分散玩家負(fù)載到OpenPoker集群里活動(dòng)的游戲服務(wù)器。
網(wǎng)關(guān)節(jié)點(diǎn)的工作是選擇一個(gè)隨機(jī)的游戲服務(wù)器,詢問它所連接的玩家數(shù)量和它的地址、主機(jī)和端口號(hào)。
一旦網(wǎng)關(guān)找到一個(gè)游戲服務(wù)器并且連接的玩家數(shù)量少于最大值,它將返回該游戲服務(wù)器的地址到連接的客戶端,然后關(guān)閉連接。
網(wǎng)關(guān)上絕對(duì)沒有壓力,網(wǎng)關(guān)的連接都非常短。你可以使用非常廉價(jià)的機(jī)器來(lái)做網(wǎng)關(guān)節(jié)點(diǎn)。
節(jié)點(diǎn)一般都成雙成對(duì)出現(xiàn),這樣一個(gè)節(jié)點(diǎn)崩潰后還有另一個(gè)繼續(xù)工作。你可能需要一個(gè)類似于Round-robin DNS的機(jī)制來(lái)保證不只一個(gè)單獨(dú)的網(wǎng)關(guān)節(jié)點(diǎn)。
網(wǎng)關(guān)怎么知曉游戲服務(wù)器?
OpenPoker使用Erlang Distirbuted Named Process Groups工具來(lái)為游戲服務(wù)器分組。
該組自動(dòng)對(duì)所有的節(jié)點(diǎn)全局可見。
新的游戲服務(wù)器進(jìn)入游戲服務(wù)器后,當(dāng)一個(gè)游戲服務(wù)器節(jié)點(diǎn)崩潰時(shí)它被自動(dòng)刪除。
這是尋找容量最大為MaxPlayers的游戲服務(wù)器的代碼:
- find_server(MaxPlayers) ->
- case pg2:get_closest_pid(?GAME_SERVER) of
- Pid when is_pid(Pid) ->
- {Time, {Host, Port}} = timer:tc(gen_server, call, [Pid, 'WHERE']),
- Coutn = gen_server:call(Pid, 'USER COUNT'),
- if
- Count < MaxPlayers ->
- io:format("~s:~w ~w players~n", [Host, Port, Count]),
- {Host, Port};
- true ->
- io:format("~s:~w is full...~n", [Host, Port]),
- find_server(MaxPlayers)
- end;
- Any ->
- Any
- end.
pg2:get_closest_pid()返回一個(gè)隨機(jī)的游戲服務(wù)器進(jìn)程id,因?yàn)榫W(wǎng)關(guān)節(jié)點(diǎn)上不允許跑任何游戲服務(wù)器。
如果一個(gè)游戲服務(wù)器進(jìn)程id返回,我詢問游戲服務(wù)器的地址(host和port)和連接的玩家數(shù)量。
只要連接的玩家數(shù)量少于最大值,我返回游戲服務(wù)器地址給調(diào)用者,否則繼續(xù)查找。
多出口電源插座中間件
OpenPoker是一個(gè)開源軟件,我最近在將它推銷給多個(gè)紙牌游戲廠商。
所有的廠商都有同樣的伸縮性和容錯(cuò)的問題,即使做了多年開發(fā)。
有的最近剛剛完成服務(wù)器軟件重寫,而有的剛剛開始。
所有的廠商都嚴(yán)重依賴于它們的Java基礎(chǔ)架構(gòu),可以理解,它們不想換Erlang。
看來(lái)有一個(gè)需求必須滿足。我思考的越多,發(fā)現(xiàn)Erlang越適合提供高效的解決方案。
我把這個(gè)解決方案看作一個(gè)多出口電源插座。
你可以像寫一個(gè)使用數(shù)據(jù)庫(kù)后端的基于socket的服務(wù)器一樣來(lái)寫游戲服務(wù)器。
事實(shí)上,目前游戲服務(wù)器就是這樣寫的。
游戲服務(wù)器是標(biāo)準(zhǔn)的電源插頭,游戲服務(wù)器的多個(gè)實(shí)例插入到電源插座中,而玩家從另一端流過。
你提供游戲服務(wù)器,而我提供伸縮性、負(fù)載均衡和容錯(cuò)。
我讓玩家連接到電源插座并監(jiān)控你的游戲服務(wù)器,必要時(shí)重啟它們。
當(dāng)一個(gè)游戲服務(wù)器崩潰時(shí)我將玩家切換到另一臺(tái)游戲服務(wù)器,你可以往插座里插入任意多的游戲服務(wù)器。
電源插座中間件是一個(gè)黑盒子,它位于你的玩家和你的服務(wù)器之間,很可能不需要你改動(dòng)任何代碼。
你會(huì)得到伸縮性、負(fù)載均衡、容錯(cuò)等諸多益處而只需改動(dòng)極少的一部分現(xiàn)有架構(gòu)。
今天你就可以用Erlang寫這個(gè)中間件,然后運(yùn)行在一個(gè)內(nèi)核調(diào)優(yōu)過以支持大量TCP連接的Linux機(jī)器上,而將你的服務(wù)器放在一個(gè)防火墻后面。
即使你不這樣做,我建議你馬上仔細(xì)看看Erlang,想想如何使用它來(lái)簡(jiǎn)化你的超強(qiáng)多玩家服務(wù)器架構(gòu)。而我會(huì)在這兒幫助你!
安徽新華電腦學(xué)校專業(yè)職業(yè)規(guī)劃師為你提供更多幫助【在線咨詢】