Saturday, August 28, 2010

轻松实现可伸缩性,容错性,和负 载平衡的大规模多人在线系统

本文以我的OpenPoker项目为例介绍另一种构建大规模多人在线系统的方案。OpenPoker是一个大型多人扑克网游,内建支持了容错能力,负载平衡和无限制的规模大小。OpenPoker的源代码遵循GPL协议可以从我的网站下载,大约包含一万行代码,有三分之一是用来测试的。

在Openpoker最终版出台之前,我花了很大精力设计参考,尝试过Delphi, Python, C#,C/C++还有Scheme。我甚至还用Common Lisp完成了一个可运行的Poker引擎。虽然我花了9个多月研究设计,最终代码编写却只用了6个星期,这最后的高效率要归功于选择了Erlang作为编写平台。

根据比较,老版本的OpenPoker需要4~5个人的小组9个月时间完成。原班人马还另外完成了一个Windows版的客户端,就算把这个开发时间的一半(1个半月)算进去,也比预期的18个月少得多,就当今游戏开发的客观环境,如此可观的时间节省不可小看!

什么是Erlang

我建议你先读一下Erlang FAQ,不过我在这里尽量概括一下:

Erlang是一个结构化,动态类型编程语言,内建并行计算支持。最初是由爱立信专门为通信应用设计的,比如控制交换机或者变换协议等,因此非常适合于构建分布式,实时软并行计算系统。

使用Erlang编写出的应用运行时通常由成千上万个轻量级进程组成,并通过消息传递相互通讯。进程间上下文切换对于Erlang来说仅仅只是一两个环节,比起C程序的线程切换要高效得多得多了。

使用Erlang来编写分布式应用要简单的多,因为它的分布式机制是透明的:对于程序来说并不知道自己是在分布式运行。

Erlang运行时环境是一个虚拟机,有点像Java虚拟机,这样代码一经编译,同样可以随处运行。它的运行时系统甚至允许代码在不被中断的情况下更新。另外如果你需要更高效的话,字节代码也可以编译成本地代码运行。

请参考Erlang网站上的教程文档范例等精彩资源。

为什么选择Erlang

内建的并行计算模型使得Erlang非常适合编写多人在线服务器。

具有良好伸缩性的大型多人后台系统用Erlang是这样构建的:由很多被分配不同任务的“节点(Node)”组成的“集群(Cluster)”。一个Erlang节点就是一个Erlang虚拟机的实例,你可以在一台机器(服务器,台式机或者笔记本电脑上运行多个节点。我推荐一块CPU一个节点。

Erlang节点自动跟踪所有连接着的其他节点。要添加一个节点你仅仅需要把它指向任何一个已建节点就可以了。只要这两个节点建立了连接,所有其他的节点马上就会感应到新加入的节点。

Erlang进程使用进程ID向其他进程传递报文,进程ID包含着运行此进程的节点的信息。因此进程不需要理会正在与其交流的其他进程实际在何处运行。一组相互连接的Erlang节点可以看作是一个网格计算体或者一台超级计算机。

将大型多人在线游戏里的玩家,NPC以及其他个体抽象为很多并行运行的进程是最理想的,但是通常并行运算的实现让人十分头疼。Erlang天生就是简化并行计算的实现。

Erlang语法里的比特操作让二进制操作变得异常简单,极大发挥了Perl和Python的打包/解包结构。使得Erlang非常适合操作二进制网络通讯协议。

OpenPoker的体系结构

OpenPoker里的一切的一切都是进程。玩家,机器人,游戏,抬面等等等等,都是一个个进程。每一个连接到OpenPoker的客户端都有一个扮演“代理”角色的玩家进程用来处理网络消息。取决于玩家是否登陆,某些消息被忽略而有些被传递到处理游戏逻辑的进程。

纸牌游戏进程是一个状态机宿主:由多种游戏状态的各种状态机模块组成。这样我的纸牌游戏进程可以向乐高积木一样随意添砖加瓦——只要加入状态机就可以添加新的纸牌游戏。此方案可以参考我写的初始函数(在cardgame.erl里

纸牌游戏状态机根据目前游戏的状态接受不同的消息。而且我用一个独立的进城来处理通用信息,比如跟踪玩家状态,抬面情况,各种限制等等。在我的笔记本电脑上模拟27,000个扑克游戏,会产生136,000个玩家和大约800,000个进程。

这说明了为什么我极力专注于使用OpenPoker为例讨论Erlang如何轻松的实现可伸缩性,容错性,和负载平衡。此方案不仅仅局限于扑克纸牌游戏。相同的机制完全可以胜任其他类型大规模可伸缩多人在线后台系统,便宜简单一点儿也不郁闷!

可伸缩性

我使用多层体系实现高伸缩性和负载平衡。第一层是网关节点。第二层是游戏服务端节点,最后一层是Mnesia主节点。

Mnesia是Erlang的实时分布式数据库系统。Mnesia FAQ解释得很详细,它是一个高速,重复性,内存驻留的数据库。Erlang本身没有对象支持但是Mnesia可以被看作是面向对象的因为它存贮所有Erlang数据。

有两种Mnesia节点:访问磁盘的和不访问磁盘的。无论怎样,所有Mnesia节点在内存中存储数据。OpenPoker里的Mnesia节点是用来访问磁盘的。网关和游戏服务层只操作内存,启动后从Mnesia访问数据库。

Erlang虚拟机有一套很方便的命令行参数来通知Mnesia主数据库存在哪里。任何新的Mnesia节点只要和主节点建立了连接,新的节点马上成为集群的一部分。

假设主节点位于apple和orange两台主机上,那么添加新的网关节点,游戏服务节点等等到你的OpenPoker集群仅仅需要如下命令行启动:

[cc lang="bash"]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[/cc]

OpenPoker在Mnesia数据表里保存配置信息,此信息在Mnesia启动的时候自动被新节点下载。完全零配置!

容错性

添置几个便宜的Linux系统到我的服务器组,OpenPoker可以要多大规模有多大规模。组合一打1U服务器系统可以轻松胜任五十万甚至一百万玩家同时在线。当然不仅仅是纸牌游戏,对于其他多人RPG网游(MMORPG)也是一样的。
我可以指派几个服务器做网关节点,另外几个做数据库节点访问存储介质上的数据,然后剩下的一些做游戏服务器。我还可以限制单台服务器最高接纳五千万家同时在线,所以任何一台当机,最多5千个玩家受影响。

另外要指出的是任何一台游戏服务器当机都不会有数据损毁因为所有Mnesia的数据访问操作都是由多个游戏,Mnesia节点实时备份的。

考虑到某些潜在错误,游戏客户端需要做一些辅助工作让玩家顺滑的重新连接到OpenPoker服务器集群。每当客户端发现网络错误,就会尝试连接网关节点,通过接力网络包得到一个新的游戏服务节点地址然后重新连接。这里需要点技巧因为不同的情况要不同对待:

OpenPoker划分如下需要重新连接的情况:


  1. 游戏服务器当机

  2. 客户端当机或者网络延迟超时

  3. 玩家换另外一个网络连接在线

  4. 玩家在游戏中切换另一个网络连接



最常见的就是客户端因为网络错误而断开连接。最不常见但是还是有可能的是同一个客户端在游戏中的时候从另一个电脑尝试连接。

每个OpenPoker游戏缓存发送给玩家的数据包,每次客户端重新连接都会收到自游戏开始的所有数据包然后再开始正常接受。OpenPoker使用TCP连接所以不用考虑数据包的发送顺序——所有数据包保证是按顺序收到的。

每个客户端连接由两个OpenPoker进程组成:套接字进程还有玩家进程。还有一个受限制的访客进程被使用直至玩家成功登陆,访客不能加入游戏。套接字进程虽网络中断而停止,但是玩家进程仍然保持活动。

玩家进程发送游戏数据包的时候可以侦测到已经中断的套接字进程,此时会进入自动运行状态或者暂停状态。登陆代码会在重新连接的时候同时参考套接字进程和玩家进程。用来侦测的代码如下:

[cc lang="erlang"]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
]),
... [/cc]
其中的各个条件是这么写的:

[cc lang="erlang"]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}.[/cc]
要注意login函数首先要做的是修复已失败的进程ID。这样简化了处理过程,代码如下:

[cc lang="erlang"]fix_pid(Pid)
when is_pid(Pid) ->
case util:is_process_alive(Pid) of
true ->
Pid;
_ ->
none
end;

fix_pid(Pid) ->
Pid.[/cc]
和:

[cc lang="erlang"]-module(util).

-export([is_process_alive/1]).

is_process_alive(Pid)
when is_pid(Pid) ->
rpc:call(node(Pid), erlang, is_process_alive, [Pid]).[/cc]

Erlang里的进程ID包含运行进程的节点的Id. [cci lang="erlang"]is_pid(Pid)[/cci]返回参数是否为一个进程Id但是无法知道进程是否已中断。Erlang的内建函数[cci lang="erlang"]erlang:is_process_alive(Pid)[/cci]可以做到。[cci lang="erlang"]is_process_alive[/cci]也可以用来检查远程节点,用起来是没区别的。

更方便的是,我们可以用Erlang RPC功能,联合[cci lang="erlang"]node(pid)[/cci]来调用远程节点的[cci lang="erlang"]is_process_alive()[/cci]。用起来和访问本地节点一样,所以上面的代码实际上也是全局分布式进程检查。

最后剩的工作就是处理登陆的各种情况了。最直接的情况是玩家处于离线状态然后启动了一个玩家进程,连接玩家进程到套接字进程,然后更新玩家数据。

[cc lang="erlang"]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}}.[/cc]

如果登陆信息不正确就返回错误然后记录登陆尝试次数。如果尝试超过一定次数,可以用如下代码关闭账户:
[cc lang="erlang"]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}};[/cc]
注销用户时,先用ObjectID找到玩家进程ID,然后停止玩家进程并更新数据库记录:

[cc lang="erlang"]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.[/cc]
如果注销不正常,可以分别针对各种重新连接条件处理。如果玩家在线却处于闲置状态,比如说停在大厅或者正旁观一个游戏(可能在喝着瓶百威,喂喂!,然后尝试从另一台电脑连接,那么程序先将其登出然后重新将其登入,就像从离线状态下登入一样:

[cc lang="erlang"]login(Player, player_online, Args) ->
logout(Player#player.oid),
login(Player, player_offline, Args);[/cc]

如果玩家正在闲置而客户端断开连接了,那么只需要在记录里替换他的套接字进程地址然后通知玩家进程新的套接字:

[cc lang="erlang"]login(Player, client_down, [_, _, Socket]) ->
gen_server:cast(Player#player.pid, {'SOCKET', Socket}),
Player1 = Player#player {
socket = Socket
},
{Player1, {ok, Player#player.pid}};[/cc]

如果玩家在游戏中,那么除了运行上面那段以外,通知游戏重新发送过往事件。

[cc lang="erlang"]login(Player, player_busy, Args) ->
Temp = login(Player, client_down, Args),
cardgame:cast(Player#player.game,
{'RESEND UPDATES', Player#player.pid}),
Temp;[/cc]

总而言之,包含着实时冗余数据库,智能重连的客户端,还有一些精巧的登陆代码的这一套组合方案可以提供高度的容错性,而且对于玩家来说,是透明的。

负载平衡

我可以用想多少就多少的服务器节点组建我的OpenPoker集群。也可以自由调配,比如说每个服务器节点5000个玩家,然后在整个集群中平摊工作负载。我可以在任何时候添加新的服务器节点,新节点自己会自动配置并开始接受新玩家。

网关节点控制着向OpenPoker集群里的所有活动节点平衡负载。网关节点的作用就是随机选择一个服务器节点,查询已连接玩家数,主机地址,端口等等。只要网关节点找到一个游戏服务器未达到负载最大值,它就把服务器的地址信息传递给客户端然后关闭连接。

很明显网关节点工作量不大,而且指向这个节点的连接都是瞬时的。你可以随便用个便宜机器做你的网关节点。

节点一般应该是一对一对的,这样如果一个失败,另一个可以马上替补。你可以采用Round-robin DNS来配置多个网关节点。

那么网关如何找到游戏服务器呢?

OpenPoker采用Erlang的分布式进程组(Distributed Named Process Groups来分组游戏服务器。所有节点都可以访问组列表,这一过程是自动的。新的游戏服务器只需加入服务器组。某个节点当机自动从组列表里剔除。

查找服务玩家最少的服务器的代码如下:

[cc lang="erlang"]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 < MaxPlayers ->
io:format("~sw: ~w players~n", [Host, Port, Count]),
{Host, Port};
true ->
io:format("~sw is full...~n", [Host, Port]),
find_server(MaxPlayers)
end;
Any ->
Any
end.[/cc]

[cci lang="erlang"]pg2:get_closest_pid()[/cci]返回一个随机的游戏服务器进程ID(网关节点上不运行任何游戏服务器)。然后向返回的服务器查询地址端口以及目前连接的玩家数。只要未足最大负载额就把地址返回给调用进程,否则继续查找。

多功能插座中间件

OpenPoker是一个开源软件,我最近也正在将其投向许多棋牌类运营商。所有商家都存在容错性和可伸缩性的问题,即使有些已经经过了长年的开发维护。有些已经重写了代码,而有些才刚刚起步。所有商家都在Java体系上大笔投入,所以他们不愿意换到Erlang也是可以理解的。

但是,对我来说这是一种商机。我越是深入研究,越发现Erlang更适合提供一个简单直接却又高效可靠的解决方案。我把这个解决方案看成一个多功能插座,就像你现在电源插头上连着的一样。

你的游戏服务器可以像简单的单一套接字服务器一样的写,只用一个数据库后台。实际上,可能比你现在的游戏服务器写得还要简单。你的游戏服务器就好比一个电源插头,多种电源插头接在我的插线板上,而玩家就从另一端流入。

你提供游戏服务,而我提供可伸缩性,负载平衡,还有容错性。我保持玩家连到插线板上并监视你的游戏服务器们,在需要的时候重启任何一个。我还可以在某个服务器当掉的情况下把玩家从一个服务器切换到另一个,而你可以随时插入新的服务器。

这么一个多功能插线板中间件就像一个黑匣子设置在玩家与服务器之间,而且你的游戏代码不需要做出任何修改。你可以享用这个方案带来的高伸缩性,负载平衡,可容错性等好处,与此同时节约投资并写仅仅修改一小部分体系结构。

你可以今天就开始写这个Erlang的中间件,在一个特别为TCP连接数做了优化的Linux机器上运行,把这台机器放到公众网上的同时保持你的游戏服务器群组在防火墙背后。就算你不打算用,我也建议你抽空看看Erlang考虑一下如何简化你的多人在线服务器架构。而且我随时愿意帮忙!

No comments:

Post a Comment