`
vanadiumlin
  • 浏览: 505675 次
  • 性别: Icon_minigender_1
  • 来自: 广州
社区版块
存档分类
最新评论

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

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

原文: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,想想如何使用它来简化你的超强多玩家服务器架构。而我会在这儿帮助你!
分享到:
评论

相关推荐

    Linux服务器负载均衡的研究与实现.pdf

    本文主要介绍了负载均衡的基本概念和原理,并基于LVS技术,在Linux平台设计实现了一个高性能、高可伸缩性、高可用性、高性价比的负载均衡系统。该系统可以有效地增强服务的性能和可用性,具有很高的伸缩性和性价比。...

    实用负载均衡技术:

    它通过提高系统的可伸缩性和容错性,为用户提供更稳定、响应更快的服务体验。 负载均衡技术通常分为硬件负载均衡和软件负载均衡两种类型。硬件负载均衡器是专门的物理设备,如F5的BIG-IP,它们提供专业级的负载分配...

    大型网站架构系列:负载均衡详解

    负载均衡是指在多台服务器之间分配网络负载的技术,其主要目标是避免单点故障,提高系统容错性,确保用户请求能够快速且无中断地得到处理。此外,通过合理分配工作负载,还可以充分利用硬件资源,防止过载,提升整体...

    服务器负载均衡

    服务器负载均衡是网络服务领域中的关键技术,用于在多台服务器之间有效地分配网络流量,以确保系统性能、可用性和可扩展性。它通过将接收到的请求分发到多个服务器,防止任何单一服务器过载,从而提高了整体服务的...

    服务器负载均衡实战攻略

    负载均衡主要应用于Web服务器、FTP服务器和其他关键任务服务器,以提高其可用性和可伸缩性。根据实现方式,负载均衡可分为软件负载均衡和硬件负载均衡。 1. 软件负载均衡:通过在服务器操作系统上安装额外软件实现...

    负载均衡技术基础知识

    负载均衡技术是一种网络架构策略,旨在优化系统性能和可用性,通过分散网络流量,确保没有单一设备承受过重的工作负载。该技术广泛应用于Web服务器、FTP服务器和其他关键任务服务器,以提高Internet服务器程序的可...

    互联网创业核心技术:构建可伸缩的Web应用(带书签,完整版)

    可伸缩性是指系统在增加资源(如服务器、存储或网络带宽)时,能够提升其性能和服务能力的能力。对于Web应用来说,这意味着随着流量增加,系统能够平滑地处理更多请求,而不是崩溃或者响应变慢。 1. **分布式系统**...

    服务器负载均衡技术研究.docx

    在 Web 应用程序领域,负载均衡技术可以将用户请求分配到多个服务器上,提高系统的可伸缩性和可靠性。 未来展望: 对于未来展望,服务器负载均衡技术的研究和发展将朝着更加智能化的方向发展。研究者们正在探索新...

    apache+tomcat集负载均衡

    Apache 和 Tomcat 集群负载均衡是一种常见的高可用性和可扩展性解决方案,它通过将请求分散到多个服务器上,确保即使单个服务器出现问题,整个系统仍能保持正常运行,并且能够处理更多的并发请求。 首先,我们需要...

    互联网创业核心技术:构建可伸缩的Web应用.part1

    本书面向互联网创业公司工程师,讲述构建可伸缩web系统的相关知识。

    企业级web负载均衡完美架构

    在构建企业级Web服务时,负载均衡是至关重要的一个环节,它能确保系统的高可用性、可伸缩性和性能优化。本话题将深入探讨“企业级Web负载均衡完美架构”的核心概念、技术选型以及实践策略。 负载均衡的首要目标是...

    Java集群与负载均衡

    3. **负载均衡策略**:静态负载均衡(基于预先配置的规则),动态负载均衡(根据服务器实时状态调整)和自适应负载均衡(根据请求类型和服务器性能动态分配)。 4. **Java中的负载均衡**:Java应用可以通过使用内置...

    Linux负载均衡集群

    【Linux负载均衡集群】是指通过特定的技术手段,将多台Linux服务器组成一个集群,使得客户端的请求能够均匀地分布到各个服务器上,从而提高服务的可用性和处理性能。在这个场景中,Apache作为Web服务器,被配置为...

    云平台技术选型之一:负载均衡

    在云计算领域中,负载均衡是构建可伸缩、可靠和高可用云服务的关键技术之一。负载均衡用于提高系统性能和稳定性,它通过合理分配流量至后端多个服务器,有效避免单点故障,提升系统整体的处理能力。负载均衡主要分为...

    lvs负载均衡集群详解

    通过LVS,可以实现服务器的负载均衡,提高系统的性能和可用性,降低系统的成本和风险。 Ipvsadm命令是LVS负载均衡集群的管理命令,通过Ipvsadm命令,可以实现负载均衡集群的配置、管理和监控。 LVS负载均衡集群的...

    集群与负载均衡的介绍

    5. **高可伸缩性和容错性**:支持通过“热插拔”方式添加新的节点,提高了系统的可扩展性和容错能力。 6. **报警支持**:提供服务器和超载报警机制,帮助管理员及时发现并解决问题。 #### 五、产品指标 - **型号**...

    LVS集群与负载均衡

    LVS(Linux Virtual Server)集群是一种用于构建高可用性、高性能和可伸缩性的网络服务的架构。它主要通过IP负载均衡技术在多个服务器之间分配网络或应用程序的负载,以实现对网络服务请求的合理调度,保证服务的高...

    负载均衡

    负载均衡是一种计算机网络服务,它的目的是分散网络流量,提高系统的响应速度和整体处理能力,同时也能增强服务的可用性和可靠性。在IT行业中,负载均衡通常应用于Web服务器、数据库服务器和其他处理大量请求的服务...

Global site tag (gtag.js) - Google Analytics