- 論壇徽章:
- 0
|
簡介:
本文以我的
OpenPoker
項目為例介紹另一種構(gòu)建大規(guī)模多人在線系統(tǒng)的方案。
OpenPoker
是一個大型多人撲克網(wǎng)游,內(nèi)建支持了容錯能力,負載平衡和無限制的規(guī)模大小。OpenPoker的源代碼遵循GPL協(xié)議可以從我的網(wǎng)站下載,大約包含一萬行代碼,有三分之一是用來測試的。
在Openpoker最終版出臺之前,我花了很大精力設(shè)計參考,嘗試過Delphi,
Python, C#,C/C++還有Scheme。我甚至還用Common
Lisp完成了一個可運行的Poker引擎。雖然我花了9個多月研究設(shè)計,最終代碼編寫卻只用了6個星期,這最后的高效率要歸功于選擇了
Erlang
作為編寫平臺。
根據(jù)比較,
老版本的OpenPoker
需要4~5個人的小組9個月時間完成。原班人馬還另外完成了一個Windows版的客戶端,就算把這個開發(fā)時間的一半(1個半月)算進去,也比預(yù)期的18個月少得多,就當今游戲開發(fā)的客觀環(huán)境,如此可觀的時間節(jié)省不可小看!
什么是Erlang
我建議你先讀一下
Erlang FAQ
,不過我在這里盡量概括一下:
Erlang是一個結(jié)構(gòu)化,動態(tài)類型編程語言,內(nèi)建并行計算支持。最初是由愛立信專門為通信應(yīng)用設(shè)計的,比如控制交換機或者變換協(xié)議等,因此非常適合于構(gòu)建分布式,實時軟并行計算系統(tǒng)。
使用Erlang編寫出的應(yīng)用運行時通常由成千上萬個輕量級進程組成,并通過消息傳遞相互通訊。進程間上下文切換對于Erlang來說僅僅只是一兩個環(huán)節(jié),比起C程序的線程切換要高效得多得多了。
使用Erlang來編寫分布式應(yīng)用要簡單的多,因為它的分布式機制是透明的:對于程序來說并不知道自己是在分布式運行。
Erlang運行時環(huán)境是一個虛擬機,有點像Java虛擬機,這樣代碼一經(jīng)編譯,同樣可以隨處運行。它的運行時系統(tǒng)甚至允許代碼在不被中斷的情況下更新。另外如果你需要更高效的話,字節(jié)代碼也可以編譯成本地代碼運行。
請參考
Erlang網(wǎng)站
上的
教程
、
文檔
、
范例
等精彩資源。
為什么選擇Erlang
內(nèi)建的并行計算模型使得Erlang非常適合編寫多人在線服務(wù)器。
具有良好伸縮性的大型多人后臺系統(tǒng)用Erlang是這樣構(gòu)建的:由很多被分配不同任務(wù)的“節(jié)點(Node)”組成的“集群(Cluster)”。一個Erlang節(jié)點就是一個Erlang虛擬機的實例,你可以在一臺機器(服務(wù)器,臺式機或者筆記本電腦
![]()
上運行多個節(jié)點。我推薦一塊CPU一個節(jié)點。
Erlang節(jié)點自動跟蹤所有連接著的其他節(jié)點。要添加一個節(jié)點你僅僅需要把它指向任何一個已建節(jié)點就可以了。只要這兩個節(jié)點建立了連接,所有其他的節(jié)點馬上就會感應(yīng)到新加入的節(jié)點。
Erlang進程使用進程ID向其他進程傳遞報文,進程ID包含著運行此進程的節(jié)點的信息。因此進程不需要理會正在與其交流的其他進程實際在何處運行。一組相互連接的Erlang節(jié)點可以看作是一個網(wǎng)格計算體或者一臺超級計算機。
將大型多人在線游戲里的玩家,NPC以及其他個體抽象為很多并行運行的進程是最理想的,但是通常并行運算的實現(xiàn)讓人十分頭疼。Erlang天生就是簡化并行計算的實現(xiàn)。
Erlang語法里的比特操作讓二進制操作變得異常簡單,極大發(fā)揮了Perl和Python的打包/解包結(jié)構(gòu)。使得Erlang非常適合操作二進制網(wǎng)絡(luò)通訊協(xié)議。
OpenPoker的體系結(jié)構(gòu)
OpenPoker里的一切的一切都是進程。玩家,機器人,游戲,抬面等等等等,都是一個個進程。每一個連接到OpenPoker的客戶端都有一個扮演“代理”角色的玩家進程用來處理網(wǎng)絡(luò)消息。取決于玩家是否登陸,某些消息被忽略而有些被傳遞到處理游戲邏輯的進程。
紙牌游戲進程是一個狀態(tài)機宿主:由多種游戲狀態(tài)的各種狀態(tài)機模塊組成。這樣我的紙牌游戲進程可以向樂高積木一樣隨意添磚加瓦——只要加入狀態(tài)機就可以添加新的紙牌游戲。此方案可以參考我寫的初始函數(shù)(在cardgame.erl里
![]()
紙牌游戲狀態(tài)機根據(jù)目前游戲的狀態(tài)接受不同的消息。而且我用一個獨立的進城來處理通用信息,比如跟蹤玩家狀態(tài),抬面情況,各種限制等等。在我的筆記本電腦上模擬27,000個撲克游戲,會產(chǎn)生136,000個玩家和大約800,000個進程。
這說明了為什么我極力專注于使用OpenPoker為例討論Erlang如何輕松的實現(xiàn)可伸縮性,容錯性,和負載平衡。此方案不僅僅局限于撲克紙牌游戲。相同的機制完全可以勝任其他類型大規(guī)?缮炜s多人在線后臺系統(tǒng),便宜簡單一點兒也不郁悶!
可伸縮性
我使用多層體系實現(xiàn)高伸縮性和負載平衡。第一層是網(wǎng)關(guān)節(jié)點。第二層是游戲服務(wù)端節(jié)點,最后一層是Mnesia主節(jié)點。
Mnesia是Erlang的實時分布式數(shù)據(jù)庫系統(tǒng)。
Mnesia FAQ
解釋得很詳細,它是一個高速,重復(fù)性,內(nèi)存駐留的數(shù)據(jù)庫。Erlang本身沒有對象支持但是Mnesia可以被看作是面向?qū)ο蟮囊驗樗尜A所有Erlang數(shù)據(jù)。
有兩種Mnesia節(jié)點:訪問磁盤的和不訪問磁盤的。無論怎樣,所有Mnesia節(jié)點在內(nèi)存中存儲數(shù)據(jù)。OpenPoker里的Mnesia節(jié)點是用來訪問磁盤的。網(wǎng)關(guān)和游戲服務(wù)層只操作內(nèi)存,啟動后從Mnesia訪問數(shù)據(jù)庫。
Erlang虛擬機有一套很方便的命令行參數(shù)來通知Mnesia主數(shù)據(jù)庫存在哪里。任何新的Mnesia節(jié)點只要和主節(jié)點建立了連接,新的節(jié)點馬上成為集群的一部分。
假設(shè)主節(jié)點位于apple和orange兩臺主機上,那么添加新的網(wǎng)關(guān)節(jié)點,游戲服務(wù)節(jié)點等等到你的OpenPoker集群僅僅需要如下命令行啟動:
erl -mnesia extra_db_nodes \['db@apple','db@orange'\] -s mnesia start-s mnesia start 也可以用Erlang控制臺啟動Mnesia: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數(shù)據(jù)表里保存配置信息,此信息在Mnesia啟動的時候自動被新節(jié)點下載。完全零配置!
容錯性
添置幾個便宜的Linux系統(tǒng)到我的服務(wù)器組,OpenPoker可以要多大規(guī)模有多大規(guī)模。組合一打1U服務(wù)器系統(tǒng)可以輕松勝任五十萬甚至一百萬玩家同時在線。當然不僅僅是紙牌游戲,對于其他多人RPG網(wǎng)游(
MMORPG)
也是一樣的。
我可以指派幾個服務(wù)器做網(wǎng)關(guān)節(jié)點,另外幾個做數(shù)據(jù)庫節(jié)點訪問存儲介質(zhì)上的數(shù)據(jù),然后剩下的一些做游戲服務(wù)器。我還可以限制單臺服務(wù)器最高接納五千萬家同時在線,所以任何一臺當機,最多5千個玩家受影響。
另外要指出的是任何一臺游戲服務(wù)器當機都不會有數(shù)據(jù)損毀因為所有Mnesia的數(shù)據(jù)訪問操作都是由多個游戲,Mnesia節(jié)點實時備份的。
考慮到某些潛在錯誤,游戲客戶端需要做一些輔助工作讓玩家順滑的重新連接到OpenPoker服務(wù)器集群。每當客戶端發(fā)現(xiàn)網(wǎng)絡(luò)錯誤,就會嘗試連接網(wǎng)關(guān)節(jié)點,通過接力網(wǎng)絡(luò)包得到一個新的游戲服務(wù)節(jié)點地址然后重新連接。這里需要點技巧因為不同的情況要不同對待:
OpenPoker劃分如下需要重新連接的情況:
游戲服務(wù)器當機
客戶端當機或者網(wǎng)絡(luò)延遲超時
玩家換另外一個網(wǎng)絡(luò)連接在線
玩家在游戲中切換另一個網(wǎng)絡(luò)連接
最常見的就是客戶端因為網(wǎng)絡(luò)錯誤而斷開連接。最不常見但是還是有可能的是同一個客戶端在游戲中的時候從另一個電腦嘗試連接。
每個OpenPoker游戲緩存發(fā)送給玩家的數(shù)據(jù)包,每次客戶端重新連接都會收到自游戲開始的所有數(shù)據(jù)包然后再開始正常接受。OpenPoker使用TCP連接所以不用考慮數(shù)據(jù)包的發(fā)送順序——所有數(shù)據(jù)包保證是按順序收到的。
每個客戶端連接由兩個OpenPoker進程組成:套接字進程還有玩家進程。還有一個受限制的訪客進程被使用直至玩家成功登陸,訪客不能加入游戲。套接字進程雖網(wǎng)絡(luò)中斷而停止,但是玩家進程仍然保持活動。
玩家進程發(fā)送游戲數(shù)據(jù)包的時候可以偵測到已經(jīng)中斷的套接字進程,此時會進入自動運行狀態(tài)或者暫停狀態(tài)。登陸代碼會在重新連接的時候同時參考套接字進程和玩家進程。用來偵測的代碼如下:
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
]),
...
其中的各個條件是這么寫的:
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函數(shù)首先要做的是修復(fù)已失敗的進程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里的進程ID包含運行進程的節(jié)點的Id.
is_pid(Pid)返回參數(shù)是否為一個進程Id但是無法知道進程是否已中斷。Erlang的內(nèi)建函數(shù)erlang:is_process_alive(Pid)可以做到。is_process_alive也可以用來檢查遠程節(jié)點,用起來是沒區(qū)別的。
更方便的是,我們可以用
Erlang RPC
功能,聯(lián)合node(pid)來調(diào)用遠程節(jié)點的is_process_alive()。用起來和訪問本地節(jié)點一樣,所以上面的代碼實際上也是全局分布式進程檢查。
最后剩的工作就是處理登陸的各種情況了。最直接的情況是玩家處于離線狀態(tài)然后啟動了一個玩家進程,連接玩家進程到套接字進程,然后更新玩家數(shù)據(jù)。
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}}.
如果登陸信息不正確就返回錯誤然后記錄登陸嘗試次數(shù)。如果嘗試超過一定次數(shù),可以用如下代碼關(guān)閉賬戶:
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}};
注銷用戶時,先用ObjectID找到玩家進程ID,然后停止玩家進程并更新數(shù)據(jù)庫記錄:
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.
如果注銷不正常,可以分別針對各種重新連接條件處理。如果玩家在線卻處于閑置狀態(tài),比如說停在大廳或者正旁觀一個游戲(可能在喝著瓶百威,喂喂!
![]()
,然后嘗試從另一臺電腦連接,那么程序先將其登出然后重新將其登入,就像從離線狀態(tài)下登入一樣:
login(Player, player_online, Args) ->
logout(Player#player.oid),
login(Player, player_offline, Args);
如果玩家正在閑置而客戶端斷開連接了,那么只需要在記錄里替換他的套接字進程地址然后通知玩家進程新的套接字:
login(Player, client_down, [_, _, Socket]) ->
gen_server:cast(Player#player.pid, {'SOCKET', Socket}),
Player1 = Player#player {
socket = Socket
},
{Player1, {ok, Player#player.pid}};
如果玩家在游戲中,那么除了運行上面那段以外,通知游戲重新發(fā)送過往事件。
login(Player, player_busy, Args) ->
Temp = login(Player, client_down, Args),
cardgame:cast(Player#player.game,
{'RESEND UPDATES', Player#player.pid}),
Temp;
總而言之,包含著實時冗余數(shù)據(jù)庫,智能重連的客戶端,還有一些精巧的登陸代碼的這一套組合方案可以提供高度的容錯性,而且對于玩家來說,是透明的。
負載平衡
我可以用想多少就多少的服務(wù)器節(jié)點組建我的OpenPoker集群。也可以自由調(diào)配,比如說每個服務(wù)器節(jié)點5000個玩家,然后在整個集群中平攤工作負載。我可以在任何時候添加新的服務(wù)器節(jié)點,新節(jié)點自己會自動配置并開始接受新玩家。
網(wǎng)關(guān)節(jié)點控制著向OpenPoker集群里的所有活動節(jié)點平衡負載。網(wǎng)關(guān)節(jié)點的作用就是隨機選擇一個服務(wù)器節(jié)點,查詢已連接玩家數(shù),主機地址,端口等等。只要網(wǎng)關(guān)節(jié)點找到一個游戲服務(wù)器未達到負載最大值,它就把服務(wù)器的地址信息傳遞給客戶端然后關(guān)閉連接。
很明顯網(wǎng)關(guān)節(jié)點工作量不大,而且指向這個節(jié)點的連接都是瞬時的。你可以隨便用個便宜機器做你的網(wǎng)關(guān)節(jié)點。
節(jié)點一般應(yīng)該是一對一對的,這樣如果一個失敗,另一個可以馬上替補。你可以采用
Round-robin DNS
來配置多個網(wǎng)關(guān)節(jié)點。
那么網(wǎng)關(guān)如何找到游戲服務(wù)器呢?
OpenPoker采用Erlang的分布式進程組(
Distributed Named Process Groups
![]()
來分組游戲服務(wù)器。所有節(jié)點都可以訪問組列表,這一過程是自動的。新的游戲服務(wù)器只需加入服務(wù)器組。某個節(jié)點當機自動從組列表里剔除。
查找服務(wù)玩家最少的服務(wù)器的代碼如下:
find_server(MaxPlayers) ->
case pg2:get_closest_pid(?GAME_SERVERS) of
Pid when is_pid(Pid) ->
{Time, {Host, Port}} = timer:tc(gen_server, call, [Pid, 'WHERE']),
Count = gen_server:call(Pid, 'USER COUNT'),
if
Count
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()返回一個隨機的游戲服務(wù)器進程ID(網(wǎng)關(guān)節(jié)點上不運行任何游戲服務(wù)器)。然后向返回的服務(wù)器查詢地址端口以及目前連接的玩家數(shù)。只要未足最大負載額就把地址返回給調(diào)用進程,否則繼續(xù)查找。
多功能插座中間件
OpenPoker是一個開源軟件,我最近也正在將其投向許多棋牌類運營商。所有商家都存在容錯性和可伸縮性的問題,即使有些已經(jīng)經(jīng)過了長年的開發(fā)維護。有些已經(jīng)重寫了代碼,而有些才剛剛起步。所有商家都在Java體系上大筆投入,所以他們不愿意換到Erlang也是可以理解的。
但是,對我來說這是一種商機。我越是深入研究,越發(fā)現(xiàn)Erlang更適合提供一個簡單直接卻又高效可靠的解決方案。我把這個解決方案看成一個多功能插座,就像你現(xiàn)在電源插頭上連著的一樣。
你的游戲服務(wù)器可以像簡單的單一套接字服務(wù)器一樣的寫,只用一個數(shù)據(jù)庫后臺。實際上,可能比你現(xiàn)在的游戲服務(wù)器寫得還要簡單。你的游戲服務(wù)器就好比一個電源插頭,多種電源插頭接在我的插線板上,而玩家就從另一端流入。
你提供游戲服務(wù),而我提供可伸縮性,負載平衡,還有容錯性。我保持玩家連到插線板上并監(jiān)視你的游戲服務(wù)器們,在需要的時候重啟任何一個。我還可以在某個服務(wù)器當?shù)舻那闆r下把玩家從一個服務(wù)器切換到另一個,而你可以隨時插入新的服務(wù)器。
這么一個多功能插線板中間件就像一個黑匣子設(shè)置在玩家與服務(wù)器之間,而且你的游戲代碼不需要做出任何修改。你可以享用這個方案帶來的高伸縮性,負載平衡,可容錯性等好處,與此同時節(jié)約投資并寫僅僅修改一小部分體系結(jié)構(gòu)。
你可以今天就開始寫這個Erlang的中間件,在一個特別為TCP連接數(shù)做了優(yōu)化的Linux機器上運行,把這臺機器放到公眾網(wǎng)上的同時保持你的游戲服務(wù)器群組在防火墻背后。就算你不打算用,我也建議你抽空看看Erlang考慮一下如何簡化你的多人在線服務(wù)器架構(gòu)。而且我隨時愿意幫忙!
Mail this
Printer friendly
本文來自ChinaUnix博客,如果查看原文請點:http://blog.chinaunix.net/u/4614/showart_164418.html |
|