:如何构建超强伸缩性的游戏服务器而集容错、负载均衡和无限伸缩性于一身

附标题:如何构建超强伸缩性的游戏服务器而集容错、负载均衡和无限伸缩性于一身

原文: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代码  收藏代码
erl -mnesia extra_db_nodes \['db@apple','db@orange'\] -s mnesia start 

-s mnesia start相当于这样在erlang shell里启动Mnedia:
Java代码  收藏代码
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启动后立即自动被新的节点下载。零配置!

容错
通过添加廉价的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代码  收藏代码
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本身由如下代码决定:
Java代码  收藏代码
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:
Java代码  收藏代码
fix_pid(Pid) 
  when is_pid(Pid) -> 
    case util:is_process_alive(Pid) of 
    true -> 
      Pid; 
    _-> 
      none 
    end; 
 
fix_pid(Pid) -> 
    Pid. 

以及:
Java代码  收藏代码
-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(pid),但是不能告诉我进程是活着还是死了。
Erlang自带的erlang:is_process_alive(Pid)告诉我一个本地进程(运行在同一节点上)是活着还是死了,但没有检查远程节点是或者还是死了的is_process_alive变种。

还好,我可以使用Erlang rpc工具和node(pid)来在远程节点上调用is_process_alive()。
事实上,这跟在本地节点上一样工作,这样上面的代码就可以作为全局分布式进程检查器。

剩下的唯一的事情是在不同的登录条件上活动。
最简单的情况是玩家离线,我期待一个玩家进程,连接玩家到socket并更新player record。
Java代码  收藏代码
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}}. 

假如玩家登陆信息不匹配,我可以返回一个错误并增加错误登录次数。如果次数超过一个预定义的最大值,我就禁止该帐号:
Java代码  收藏代码
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(只是一个数字)找到玩家进程id,停止玩家进程,然后在数据库更新玩家record:
Java代码  收藏代码
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,例如从不同的机器重连接,我只需先注销再登录:
Java代码  收藏代码
login(Player, player_online, Args) -> 
  logout(Player#player.oid), 
  login(Player, player_offline, Args); 

如果玩家空闲时客户端重连接,我所需要做的只是在玩家record里替换socket进程id然后告诉玩家进程新的socket:
Java代码  收藏代码
login(Player, client_down, [_, _, SOcket]) -> 
  gen_server:cast(Player#player.pid, {'SOCKET', Socket}), 
  Player1 = Player#player { 
    socket = Socket 
  }, 
  {Player1, {ok, Player#player.pid}}; 

如果玩家在游戏中,这是我们运行上面的代码,然后告诉游戏重新发送时间历史:
Java代码  收藏代码
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 Distirbuted Named Process Groups工具来为游戏服务器分组。
该组自动对所有的节点全局可见。
新的游戏服务器进入游戏服务器后,当一个游戏服务器节点崩溃时它被自动删除。

这是寻找容量最大为MaxPlayers的游戏服务器的代码:
Java代码  收藏代码
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()返回一个随机的游戏服务器进程id,因为网关节点上不允许跑任何游戏服务器。
如果一个游戏服务器进程id返回,我询问游戏服务器的地址(host和port)和连接的玩家数量。
只要连接的玩家数量少于最大值,我返回游戏服务器地址给调用者,否则继续查找。

多出口电源插座中间件
OpenPoker是一个开源软件,我最近在将它推销给多个纸牌游戏厂商。
所有的厂商都有同样的伸缩性和容错的问题,即使做了多年开发。
有的最近刚刚完成服务器软件重写,而有的刚刚开始。
所有的厂商都严重依赖于它们的Java基础架构,可以理解,它们不想换Erlang。

看来有一个需求必须满足。我思考的越多,发现Erlang越适合提供高效的解决方案。
我把这个解决方案看作一个多出口电源插座。

你可以像写一个使用数据库后端的基于socket的服务器一样来写游戏服务器。
事实上,目前游戏服务器就是这样写的。
游戏服务器是标准的电源插头,游戏服务器的多个实例插入到电源插座中,而玩家从另一端流过。

你提供游戏服务器,而我提供伸缩性、负载均衡和容错。
我让玩家连接到电源插座并监控你的游戏服务器,必要时重启它们。
当一个游戏服务器崩溃时我将玩家切换到另一台游戏服务器,你可以往插座里插入任意多的游戏服务器。

电源插座中间件是一个黑盒子,它位于你的玩家和你的服务器之间,很可能不需要你改动任何代码。
你会得到伸缩性、负载均衡、容错等诸多益处而只需改动极少的一部分现有架构。

今天你就可以用Erlang写这个中间件,然后运行在一个内核调优过以支持大量TCP连接的Linux机器上,而将你的服务器放在一个防火墙后面。
即使你不这样做,我建议你马上仔细看看Erlang,想想如何使用它来简化你的超强多玩家服务器架构。而我会在这儿帮助你!

你可能感兴趣的:(负载均衡)