添置几个便宜的Linux系统到我的服务器组,OpenPoker可以要多大规模有多大规模。组合一打1U服务器系统可以轻松胜任五十万甚至一百万玩家同时在线。当然不仅仅是纸牌游戏,对于其他多人RPG网游(MMORPG)也是一样的。
我可以指派几个服务器做网关节点,另外几个做数据库节点访问存储介质上的数据,然后剩下的一些做游戏服务器。我还可以限制单台服务器最高接纳五千万家同时在线,所以任何一台当机,最多5千个玩家受影响。
另外要指出的是任何一台游戏服务器当机都不会有数据损毁因为所有Mnesia的数据访问操作都是由多个游戏,Mnesia节点实时备份的。
考虑到某些潜在错误,游戏客户端需要做一些辅助工作让玩家顺滑的重新连接到OpenPoker服务器集群。每当客户端发现网络错误,就会尝试连接网关节点,通过接力网络包得到一个新的游戏服务节点地址然后重新连接。这里需要点技巧因为不同的情况要不同对待:
OpenPoker划分如下需要重新连接的情况:
- 游戏服务器当机
- 客户端当机或者网络延迟超时
- 玩家换另外一个网络连接在线
- 玩家在游戏中切换另一个网络连接
最常见的就是客户端因为网络错误而断开连接。最不常见但是还是有可能的是同一个客户端在游戏中的时候从另一个电脑尝试连接。
每个OpenPoker游戏缓存发送给玩家的数据包,每次客户端重新连接都会收到自游戏开始的所有数据包然后再开始正常接受。OpenPoker使用TCP连接所以不用考虑数据包的发送顺序——所有数据包保证是按顺序收到的。
每个客户端连接由两个OpenPoker进程组成:套接字进程还有玩家进程。还有一个受限制的访客进程被使用直至玩家成功登陆,访客不能加入游戏。套接字进程虽网络中断而停止,但是玩家进程仍然保持活动。
玩家进程发送游戏数据包的时候可以侦测到已经中断的套接字进程,此时会进入自动运行状态或者暂停状态。登陆代码会在重新连接的时候同时参考套接字进程和玩家进程。用来侦测的代码如下:
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函数首先要做的是修复已失败的进程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包含运行进程的节点的Id. is_pid(Pid)返回参数是否为一个进程Id但是无法知道进程是否已中断。Erlang的内建函数erlang:is_process_alive(Pid)可以做到。is_process_alive也可以用来检查远程节点,用起来是没区别的。
更方便的是,我们可以用Erlang RPC功能,联合node(pid)来调用远程节点的is_process_alive()。用起来和访问本地节点一样,所以上面的代码实际上也是全局分布式进程检查。
最后剩的工作就是处理登陆的各种情况了。最直接的情况是玩家处于离线状态然后启动了一个玩家进程,连接玩家进程到套接字进程,然后更新玩家数据。
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}}. 如果登陆信息不正确就返回错误然后记录登陆尝试次数。如果尝试超过一定次数,可以用如下代码关闭账户:
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,然后停止玩家进程并更新数据库记录:
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.
如果注销不正常,可以分别针对各种重新连接条件处理。如果玩家在线却处于闲置状态,比如说停在大厅或者正旁观一个游戏(可能在喝着瓶百威,喂喂!,然后尝试从另一台电脑连接,那么程序先将其登出然后重新将其登入,就像从离线状态下登入一样:
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}), P layer1 = Player#player { socket = Socket }, {Player1, {ok, Player#player.pid}};
如果玩家在游戏中,那么除了运行上面那段以外,通知游戏重新发送过往事件。
login(Player, player_busy, Args) -> Temp = login(Player, client_down, Args), cardgame:cast(Player#player.game, {‘RESEND UPDATES‘, Player#player.pid}), Temp;
总而言之,包含着实时冗余数据库,智能重连的客户端,还有一些精巧的登陆代码的这一套组合方案可以提供高度的容错性,而且对于玩家来说,是透明的。
负载平衡
我可以用想多少就多少的服务器节点组建我的OpenPoker集群。也可以自由调配,比如说每个服务器节点5000个玩家,然后在整个集群中平摊工作负载。我可以在任何时候添加新的服务器节点,新节点自己会自动配置并开始接受新玩家。
网关节点控制着向OpenPoker集群里的所有活动节点平衡负载。网关节点的作用就是随机选择一个服务器节点,查询已连接玩家数,主机地址,端口等等。只要网关节点找到一个游戏服务器未达到负载最大值,它就把服务器的地址信息传递给客户端然后关闭连接。
很明显网关节点工作量不大,而且指向这个节点的连接都是瞬时的。你可以随便用个便宜机器做你的网关节点。
节点一般应该是一对一对的,这样如果一个失败,另一个可以马上替补。你可以采用Round-robin DNS来配置多个网关节点。
那么网关如何找到游戏服务器呢?
OpenPoker采用Erlang的分布式进程组(Distributed Named Process Groups来分组游戏服务器。所有节点都可以访问组列表,这一过程是自动的。新的游戏服务器只需加入服务器组。某个节点当机自动从组列表里剔除。
查找服务玩家最少的服务器的代码如下:
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.
pg2:get_closest_pid()返回一个随机的游戏服务器进程ID(网关节点上不运行任何游戏服务器)。然后向返回的服务器查询地址端口以及目前连接的玩家数。只要未足最大负载额就把地址返回给调用进程,否则继续查找。
多功能插座中间件
OpenPoker是一个开源软件,我最近也正在将其投向许多棋牌类运营商。所有商家都存在容错性和可伸缩性的问题,即使有些已经经过了长年的开发维护。有些已经重写了代码,而有些才刚刚起步。所有商家都在Java体系上大笔投入,所以他们不愿意换到Erlang也是可以理解的。
但是,对我来说这是一种商机。我越是深入研究,越发现Erlang更适合提供一个简单直接却又高效可靠的解决方案。我把这个解决方案看成一个多功能插座,就像你现在电源插头上连着的一样。
你的游戏服务器可以像简单的单一套接字服务器一样的写,只用一个数据库后台。实际上,可能比你现在的游戏服务器写得还要简单。你的游戏服务器就好比一个电源插头,多种电源插头接在我的插线板上,而玩家就从另一端流入。
你提供游戏服务,而我提供可伸缩性,负载平衡,还有容错性。我保持玩家连到插线板上并监视你的游戏服务器们,在需要的时候重启任何一个。我还可以在某个服务器当掉的情况下把玩家从一个服务器切换到另一个,而你可以随时插入新的服务器。
这么一个多功能插线板中间件就像一个黑匣子设置在玩家与服务器之间,而且你的游戏代码不需要做出任何修改。你可以享用这个方案带来的高伸缩性,负载平衡,可容错性等好处,与此同时节约投资并写仅仅修改一小部分体系结构。
你可以今天就开始写这个Erlang的中间件,在一个特别为TCP连接数做了优化的Linux机器上运行,把这台机器放到公众网上的同时保持你的游戏服务器群组在防火墙背后。就算你不打算用,我也建议你抽空看看Erlang考虑一下如何简化你的多人在线服务器架构。而且我随时愿意帮忙!