`
hideto
  • 浏览: 2675077 次
  • 性别: 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集群简单的如下所示:
erl -mnesia extra_db_nodes \['db@apple','db@orange'\] -s mnesia start

-s mnesia start相当于这样在erlang shell里启动Mnedia:
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和活着的玩家进程的结合。代码如下:
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本身由如下代码决定:
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(pid),但是不能告诉我进程是活着还是死了。
Erlang自带的erlang:is_process_alive(Pid)告诉我一个本地进程(运行在同一节点上)是活着还是死了,但没有检查远程节点是或者还是死了的is_process_alive变种。

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

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

注销玩家包括使用Object ID(只是一个数字)找到玩家进程id,停止玩家进程,然后在数据库更新玩家record:
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,例如从不同的机器重连接,我只需先注销再登录:
login(Player, player_online, Args) ->
  logout(Player#player.oid),
  login(Player, player_offline, Args);

如果玩家空闲时客户端重连接,我所需要做的只是在玩家record里替换socket进程id然后告诉玩家进程新的socket:
login(Player, client_down, [_, _, SOcket]) ->
  gen_server:cast(Player#player.pid, {'SOCKET', Socket}),
  Player1 = 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 Distirbuted Named Process Groups工具来为游戏服务器分组。
该组自动对所有的节点全局可见。
新的游戏服务器进入游戏服务器后,当一个游戏服务器节点崩溃时它被自动删除。

这是寻找容量最大为MaxPlayers的游戏服务器的代码:
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,想想如何使用它来简化你的超强多玩家服务器架构。而我会在这儿帮助你!
分享到:
评论
2 楼 yuky1327 2011-10-11  
对于数据量大,计算频繁的游戏,再多的线程都解决不了~
1 楼 wuqing1999 2011-05-28  
这样的服务器设计对于一个纸牌游戏而言太复杂了,我觉得,还是线程比较方便,不要用什么框架

相关推荐

    基于Akka的高性能可伸缩的JAVA网络游戏服务器 简单的单服务器开发与集群开发的切换 使用Actor处理高并发 易于测试

    基于Akka的高性能可伸缩的JAVA网络游戏服务器。简单的单服务器开发与集群开发的切换。使用Actor处理高并发。易于测试。服务的插件管理。高性能,可伸缩的Java Tcp服务器架构,1.Avalon基于Akka构建的服务器核心。...

    基于java游戏服务器框架,它是高可伸缩、分布式多进程的游戏服务器框架。.zip

    Java游戏服务器框架是一种用于开发高性能、高可伸缩性、分布式多进程的游戏后端系统的技术解决方案。这样的框架设计目标是确保游戏服务器能够处理大量并发玩家,同时提供稳定、低延迟的服务,以支持实时性强的在线...

    分布式系统可伸缩性研究综述_陈斌.pdf

    在分布式系统中,可伸缩性通常通过两种主要方式实现:一是增加硬件资源,如增加服务器数量,以分摊工作负载;二是优化软件架构和任务分配机制,确保工作负载的均衡分布。分布式资源管理系统在此过程中起着至关重要的...

    08-分布式游戏服务器开发.rar

    分布式游戏服务器开发是现代大型在线游戏背后的核心技术之一,它涉及到多台服务器的协同工作,以处理成千上万玩家的交互、游戏逻辑、数据存储和网络通信等问题。本资料包"08-分布式游戏服务器开发.rar"可能包含了...

    VB.NET可伸缩性技术手册

    VB.NET可伸缩性技术手册VB.NET可伸缩性技术手册VB.NET可伸缩性技术手册VB.NET可伸缩性技术手册VB.NET可伸缩性技术手册VB.NET可伸缩性技术手册VB.NET可伸缩性技术手册VB.NET可伸缩性技术手册VB.NET可伸缩性技术手册VB...

    windows可伸缩性范例

    Windows操作系统作为广泛应用的桌面和服务器平台,同样面临着如何提供高效、灵活的可伸缩性解决方案的挑战。本篇将深入探讨"Windows可伸缩性范例"的相关知识点,通过以下五个方面来展开讨论: 1. **资源管理**:...

    网络游戏-具有基于内容的信息转向器用于可伸缩性的级联网络装置.zip

    标题中的“网络游戏-具有基于内容的信息转向器用于可伸缩性的级联网络装置”揭示了本文档主要讨论的是网络游戏领域中的一种技术方案,涉及到信息转向和级联网络装置的可伸缩性。这个主题涵盖了互联网技术和游戏...

    Go-GoWorld–用Golang写一个分布式可扩展可热更的游戏服务器

    2. **动态伸缩**:利用云服务的弹性伸缩功能,根据玩家负载自动增减服务器资源。 3. **接口设计**:提供清晰的API接口,使得新功能的接入更为方便。 四、热更新技术 1. **代码热替换**:通过Golang的plugin机制或...

    高性能服务器网络可伸缩性.doc

    高性能服务器网络可伸缩性.doc

    可伸缩的服务器(iocp)

    1.伸缩性I/O模型 IOCP 动态投递套接字I/O请求 2.对非SOCKET类型数据处理 3. 释放IOCP_INFO对象的工作都在workThread中执行,只要关闭socket就行 4.固定死了二个处理工作线程,这是缺点 改成与CPU个数有关的话,又和...

    高性能服务器网络可伸缩性.doc编程资料

    高性能服务器网络可伸缩性.doc

    基于SELECTEVENT模型的可伸缩服务器

    在IT领域,构建可伸缩的服务器系统是至关重要的,因为它们能够处理不断变化的工作负载,确保服务的稳定性和高效性。基于SELECTEVENT模型的可伸缩服务器是一种设计策略,它利用了操作系统提供的I/O复用机制来实现这一...

    网络游戏-游戏服务器远程共享系统及方法.zip

    5. **游戏逻辑**:服务器端负责执行游戏规则,如判定玩家行为合法性、计算战斗结果、同步游戏世界状态等。这部分需要严谨的算法设计,避免出现漏洞和不公平现象。 6. **安全性**:游戏服务器需要防止各种攻击,如...

    网络游戏-一种大型分布式网络游戏服务器系统.zip

    网络游戏是当今数字娱乐产业的重要组成部分,其背后的技术支撑——大型分布式网络游戏服务器系统是实现游戏流畅运行、玩家互动和大规模世界的关键。本资料“网络游戏-一种大型分布式网络游戏服务器系统”将深入探讨...

    基于due分布式游戏服务器框架实现的麻将游戏服务器.zip

    分布式服务器框架是通过多个服务器协同工作,以处理大量玩家并发请求的一种架构。这种架构可以将负载分散,提高系统的可用性和扩展性。due框架便是其中的一种,它专为网络游戏设计,强调简单易用和高性能。 due框架...

    网络游戏-数据传输通道建立系统、网络存储设备、服务器及方法.zip

    服务器架构可能采用主从复制、分布式集群等方式,以提高服务的可用性和可伸缩性。 最后,服务器及方法涉及服务器优化技术,例如使用缓存减少对数据库的直接访问,提高响应速度;利用CDN(Content Delivery Network)...

    数据访问宝典-实现最优性能可伸缩性的数据库应用程序

    资源名称:数据访问宝典-实现最优性能可伸缩性的数据库应用程序内容简介:在当今的企业数据库应用程序中,性能和可伸缩性比过去任何时候更为关键,传统的数据库调整对于解决可能在这些应用程序中遇到的性能问题有些...

    一种基于Qt的可伸缩全异步CS架构服务器实现

    在基于Qt的服务器实现中,可伸缩性可以通过多线程、进程或者分布式集群来实现。例如,当单个服务器无法处理所有请求时,可以动态添加新的服务器节点,通过负载均衡技术分散请求。此外,Qt的QThread类可以方便地管理...

    提高SQL SERVER 2005的可用性和伸缩性

    在许多应用程序中,提供较高的可用性和较高的读取可伸缩性是十分重要的;而复制便可作为提供这两方面的性能的解决方案的关键部分。对于某些应用程序而言,其目标可能是通过复制来提高可用性或可伸缩性。

Global site tag (gtag.js) - Google Analytics