聊天室服务分析设计
2013-03-05 09:52 by 轩脉刃, 3913 阅读, 21 评论, 收藏, 编辑如果你需要写一个简单的聊天室的服务,那么我想很多网上的demo都可以直接拿来用。但是如果你要做的是给线上百万甚至千万级用户用的服务,那么,整个结构和聊天室Demo是必然不一样的。本文就从设计一个大用户量的聊天室服务的角度出发来思考。
分布式?
首先用户量大必然先考虑的问题是服务是单进程还是多进程,单机器还是多机器,单进程代表的是单机上跑一个服务,单机器代表的是单机上跑一个或者多个服务,这两种方案都是不可行的。理由是考虑下面几个方面:
1 单进程或单机器对机器性能要求较高:由于一台机器上的一个进程直接服务于这么多用户,因此在内存,带宽,CPU等要求上是非常高的。
2 单进程必然导致高内存,高内存的内存回收机制会对服务稳定性产生影响
3 单进程的进程容错性不好。一旦这个进程挂了,那么整个服务就会处于瘫痪
4 单进程的扩展性不好。由于服务用户必然是从少到多,我们需要的是一个可以不断增加机器就能增加抗压性的服务。
于是考虑使用到多机器多进程(分布式服务)。
谈到多进程的架构设计,避免不了的是进程间通信的问题。两个进程进行相互通信分为两种情况:同机器两个进程的相互通信,不同机器的两个进程的相互通信
同机器两个进程间的通信机制有:Unix管道,信号(比如我们平常使用的Ctrl + C),共享存储(包括把需要通信的信息放在文件或者内存中)
不同机器的两个进程通信机制就要使用到socket通信了。通过TCP从一个进程发送信息给另一个在监听端口的进程,等候监听端口的进程会返回需要的结果的整个模型就是RPC模型(远程服务调用)。RPC模型也有两种,PRC via TCP或者RPC via HTTP。RPC via TCP相较于后者传输的数据更少(少了HTTP包的那些部分),所以效率更高,但是却实际上不适合在Internet这样存在着危险的环境中使用。所以RPC via TCP适合在内网的两个进程间交互使用。
对于聊天室的需求,服务器一般是假设在内部网络上,不需要对外进行服务提供,所以应该选在RPC via TCP的方式最好。
如何保证实时?
对于一个聊天室服务,聊天的实时性是最硬性的需求。这里该考虑使用什么技术来完成实时性的需求了。
一般有几种方法能做到实时性或者是近实时性:
1 HTTP轮询。
客户端每隔一段时间(较短的时间)对服务端进行HTTP请求,询问是否有发送给自己的聊天消息。如果有则返回具体消息,如果没有就返回空消息。
这个方法的缺点有几个:
a 不实时:较短的时间再短也不能说是实时的。
b 浪费请求:必然是空消息的请求次数大大多于有消息的请求次数。那么意味着大多数的请求实际上都是没有用的。造成的结果就是浪费了流量,增加了服务端的压力。
2 HTTP Long Polling
算是第一种方法的优化。具体表现就是发送了HTTP Request之后不立刻返回HTTP Response,而是把HTTP请求hold住在服务端,等到有消息返回的时候,再返回HTTP Response。
这种实现方法就将第一种的两个缺点给解决了。老王这篇文章写了一个nginx+lua的实现:http://huoding.com/2012/09/28/174
3 TCP长连接
客户端发起TCP的连接,与服务器建立连接后,客户端和服务器都不关闭连接。如果有消息到达,服务端就会主动推送消息给客户端。这就是“推”的机制。
这个方法看起来是最美好的。因为它能完全保证消息的实时性,也能最小限度的节约请求。但是放到具体的环境中就有几个问题了:
首先是在BS架构中,浏览器充当客户端,这个方法就需要浏览器能发送TCP请求。在HTML5出现之前浏览器是只能发送HTTP请求的。但是好在HTML5推出了websocket协议,它使用HTTP握手+TCP传输的方式来实现了浏览器与服务器的TCP连接。具体的协议细节可以看http://www.cnblogs.com/yjf512/archive/2013/02/18/2915171.html。
其次,如果你有客户端,比如QQ,手机或者flash等,你就需要自己定义一套可扩展的协议了。比如最简单的设计可能是:前32个bit是代表内容byte长度,后面是json化的内容。
还有就是考虑到安全性,如何保证TCP网络包的安全,你可能会用到签名,加密(对称、非对称)等方法。
对于聊天室的需求,websocket是最佳选择。
要提供哪些分布式服务呢?
下面进行服务组件设计。
connector服务
使用长连接,必然有个服务组件专门服务于长连接的建立,维护,接收和发送消息。我们可以称之为connector。
这个服务需要是分布式的,有多少用户就会有多少个长连接,维护这么多长连接需要使用的是多个机器的服务。而且如果允许的话,建议connector能监听在80端口,以避免客户端的防火墙对端口的限制。
chat服务
connector是暴露在服务器前端的,后端必然有个服务能处理聊天的业务逻辑,其功能包括:
A 管理聊天室的用户
B 接收connector发送的群聊或者单聊的消息(通过进程间通信)
C 发送群聊和单聊的消息给对应的connector(通过进程间通信)
这个业务逻辑服务我们称之为chat
这里就有几种设计了
管理聊天室的用户?
这实际就是一种存储的功能,那么能不能使用现有的存储服务来做呢?nosql(比如redis)?
这种设计是完全可以的,redis的存储是放在内存中,存取的效率是有保证的,唯一无法保证的可能就是网络传输了。
缺点很明显:
A 业务逻辑“获取聊天室用户”的请求量一定非常大,这个请求如果是通过网络传输来请求的话,那么对网络和存储服务的稳定性等要求较高。一旦网络不稳定,那么服务必然受到影响(但或许想想,什么服务不是这样呢的)。
B 存储服务是否要分布式的呢?估计也是需要的,那么可能还需要建议一个hash算法来定位和保证扩展性。
使用存储服务的好处当然也不可忽略,比如可以使用现成的配套服务(比如redis的持久化),现成的数据结构存储(比如redis的zset等结构),以及监控等服务。
在这个聊天室需求里面,我们可能更倾向于不使用存储功能,直接把管理聊天室用户的功能放在chat服务中。那么我们就需要存储每个用户在哪个connector中存储的情况了,就是保存用户session信息。
第二个问题:chat是分布式的,那么一个聊天室是分布在一个chat进程中还是分布在多个chat进程中呢?
从效率角度上来说,一个聊天室之在一个chat进程中是最为好的方法。因为减少了进程间通信。聊天室内部的消息传递只要在进程内部进行交互就行了。
但是考虑下这么个极端情况,某天搞一个活动,一个聊天室人数非常多,另一个聊天室人数却很少,两个聊天室是分布在不同的进程中的,那么就会导致的情况可能就是一个进程占用非常多资源,另一个进程却非常空闲。这个时候多么希望空闲的进程能够分担繁忙进程的服务!
其实作为聊天室的需求,第二种情况是比较极端的了,所以可以不用考虑。但是或许在其他的场景中(游戏等)这种情况就需要重点考虑了。
下面考虑一些配套服务组件
gate服务
首先是connector的分配问题,我们不应该直接将connector的选择抛给客户端,由客户端决定连接哪个connector,因为这样很容易会造成connector的负载不平衡的问题。
所以我们设计在connector之前使用一个gate服务,算是一种负载均衡服务,它的行为是:
客户端先连接gate,gate告知客户端应该连接哪个connector,并将connector的地址端口等信息返回,后客户端连接指定的connector
但是这里其实也是存在安全隐患的,如果攻击性的客户端跳过gate的负载均衡,直接连接connector,那么可能导致某个connector崩溃,可能会出现雪崩效应了。安全的问题,深究下去是个很复杂的工程。
master服务
分布式系统最头疼的可能是服务管理了,不管是进程的启动,关闭,更新等操作,如果一台一台机器一个一个进程进行操作很容易导致操作者的崩溃。所以就需要一个服务能一次性进行所有机器的启动,关闭,重启等操作。
我们希望达到的最终结果是启动了主服务器的master进程,本地和远程的connector,chat,gate进程都会根据配置文件进行启动。当然本地的进程启动相对容易,不管是什么语言都应该会有command.exec类似的命令来启动,远程的进程启动则稍微麻烦些:
归结起来有两种方法:
1 ssh进行远程机器调用命令。这个就要求进行ssh的帐号在其他机器上有存在,并且在~/.ssh下存储了对应的RSA等密钥信息。那么就可以使用这个帐号来调用远端机器的进程了。
这个方法有个不好的地方,就是帐号权限,一般出于安全考虑,这个ssh的帐号不可能是root帐号。但是不用root帐号启动,就可能遇到各种限制(比如无法占用80端口来启用connector)
2 在每个机器上启动一个启动和关闭聊天服务的服务,我们叫做deploy。这个deploy对master提供RPC服务,master通过RPC调用来发送“启动/关闭/更新 进程”的命令。
这个方法的好处就是可以使用root来启动deploy了,但是不好的地方是deploy只能由手动启动了...
例子
chatOfPomelo是在网易的Pomelo游戏服务框架上搭建的一个聊天服务。使用这个例子,我们就能很好的了解分布式聊天服务器的架构
在官网上是有个架构图的,https://github.com/NetEase/pomelo/wiki/pomelo%E6%9E%B6%E6%9E%84%E6%A6%82%E8%A7%88,原图稍微复杂了点,我这里做一下修改:
联系到上面的分析可能就更好理解这个图了。
后记
任何设计都是存在弊端的,但是只要这个弊端不会在需求中被体现,即能满足需求,就是一个好设计,所以说在不明确的需求面前,任何设计都不能说是正确的。
分类: Linux
【下篇:从腾讯QQgame高性能服务器集群架构看“分而治之”与“自治”等分布式架构设计原则】
笔者在闲暇时,偶尔会登录腾讯QQGame玩玩升级游戏。这确实是一款非常优秀的软件作品,腾讯的开发人员在此展现了极高的技术水准。QQ游戏同时在线用户数都在百万到千万之数量级以上,可以想象其在性能方面所面临的挑战有多高。QQ升级游戏有一个“快速加入游戏”的功能,方便玩家尽快加入目标牌桌。这本身是个非常人性化的功能,但其实现却存在一个缺陷,当玩家当前所在房间内,同时执行“快速加入游戏”功能的用户数较多时,常常会出现加入失败的情况。笔者碰到的最糟情形是重复5、6次以上,才最后成功加入,其间获得的用户体验自然是不够好。
(图A)“快速加入游戏”失败示意
注意:点击图片可以放大观看笔者分析这个缺陷的根源,可能是“快速加入游戏”这一功能之实现在服务器端与客户端之间的协作分配方式有问题:
QQgame的服务器端维护了游戏大厅中所有房间、和每个房间中所有牌桌,及相关玩家在这些牌桌的占位情况;而每个登录QQgame的客户端会实时(因为客户端数目庞大,服务器端提供数据更新服务的实际延迟时间会达到秒级以上,并不能真正做到实时)地从服务器端获取这些数据;玩家(用户)进入某个房间后,当其执行“快速加入游戏”功能时,客户端会先分析当前房间中所有牌桌的占位情况,并选择一个未满桌的牌桌,然后向服务器端发出加入此牌桌的请求;服务器端收到客户端的加入指定牌桌的请求后,开始尝试完成加入操作,然而,因为客户端数据更新的后滞性,造成服务器端在此前可能已经收到其它客户端所发相同的(指定了同一牌桌)加入请求,并为其完成加入,使得此牌桌位满,于是本次加入操作失败。
(图一)客户端执行“加入未满牌桌”
显然,解决此缺陷最简单的做法便是改变服务器端与客户端之间的协作分配方式。原来的方式之所以造成可能加入失败,是因为进行“快速加入游戏”,必须先选择一个未满桌的牌桌,这必然依赖于当前房间中所有牌桌的占位数据,这些数据是由服务器端所维护的,如果在客户端完成这一选择,就需要从服务器端实时更新这些数据到客户端,而数据的更新根本实现不了真正的实时,于是有两个以上客户端同时发出向同一牌桌加入的请求就不能避免;那么,如果“选择一个未满桌的牌桌”这个工作不在客户端进行呢?我们可以由客户端向服务器端发出一个*加入牌桌的请求,这个请求并不指定目标牌桌,服务器端收到请求后,直接找到一个未满桌的牌桌分配给客户端所对应的玩家,并把此结果返回给客户端;在这种协作方式下,无论多少个并发客户端同时发出加入请求,因为服务器端都是排队按顺序来一一加以完成的(可以通过对牌桌的玩家占位数据加同步锁以实现多线程安全来实现),所以总是不会出现争位而因满桌失败的情形。
(图二)服务器端执行“*加入未满牌桌”
(图三)“加入游戏”场景实现的参与类视图
如何在服务器端与客户端之间正确分配相关的协作,是分布式软件设计中极其关键的环节。由于网络的延迟特性,我们不能将分布式环境下的数据与桌面应用下的等同看待;QQ升级游戏的开发人员有可能忽略了这一点,而没有更深入地思考这一协作分配问题。有个所谓“客户与服务器端协作分配之数据依赖”原则应当尽量遵守--如果某个功能的实现依赖于某些数据,那么这个功能的实现最好分配给数据的直接拥有者(不论是客户端还是服务器端),这实际上就是GRASP之信息专家模式的翻版(GRASP模式中数据的拥有者指的是对象)。
当然,因为QQGame在高性能方面的要求极高,QQ升级游戏开发者可能有其它的考虑因素而选择目前的做法。例如,每个牌桌在开始进行游戏时,有复杂的游戏逻辑要运行,这些游戏逻辑如果都放在服务器端运行,恐怕需要的服务器集群是个吓人的数目,腾讯或许不愿意投入如此多的资金去购买这些服务器;那么玩家的机器同时充当客户端与服务器端的做法就是一个很自然的选择。游戏开发者可能会这样设计:第一个加入某个牌桌的用户,其主机将自动充当本牌桌的游戏服务器,此后,其它玩家要加入此牌桌,其加入请求应当发往第一个加入的用户主机,而非腾讯的服务器。不管怎样,这些都涉及到极其复杂的分布式架构设计问题,有兴趣的话,笔者将在后续文章中进一步深入探讨这些问题。实际上,升级游戏在可以执行“快速加入游戏”功能前,还有一个进入某个房间的步骤;而同样由于数据更新延迟的因素,造成玩家常常选择加入一个当时显示人数为未满的房间时,结果却为已满的情形。要强调的是,这一问题是无法通过上述调整协作分配的方法来解决的。
为了改善用户的操作体验,避免加入游戏失败的次数是必要的,实际上,除了上述的做法,还有其它临时性的修复方法。例如,可以增加一个所谓“自动快速加入游戏”的功能,就是客户端自动重复执行原来的“快速加入游戏”的功能,直到加入成功为止(不需用户重复按那个按钮而已,一笑)。另外,还有一个减少“快速加入游戏”失败概率的方法,就是客户端在选择目标牌桌时,不援用固定的策略,而是使用某种随机方式,这样多个客户端同时指定加入同一牌桌的概率大幅降低,争位失败的情形自然也大幅减少了。当然,上述两种途径还可以结合在一起应用。笔者很期待腾讯能尽早修复上述缺陷,给包括笔者在内的玩家们一个更好的操作体验。
本文基于署名-非商业性使用 3.0许可协议发布,欢迎转载,演绎,但是必须保留本文的署名叶剑峰(包含链接http://www.cnblogs.com/yjf512/),且不得用于商业目的。如您有任何疑问或者授权方面的协商,请与我联系。
【上篇:从腾讯QQ升级游戏之“快速加入游戏”功能的实现缺陷看C/S之间如何正确分配相关协作】
腾讯QQGame游戏同时在线的玩家数量极其庞大,为了方便组织玩家组队游戏,腾讯设置了大量游戏室(房间),玩家可以选择进入属意的房间,并在此房间内找到可以加入的游戏组(牌桌、棋盘等)。玩家选择进入某个房间时,必须确保此房间当前人数未满(通常上限为400),否则进入步骤将会失败。玩家在登入QQGame后,会从服务器端获取某类游戏下所有房间的当前人数数据,玩家可以据此找到未满的房间以便进入。如上篇所述的原因,如果待进入房间的人数接近上限时,玩家的进入请求可能失败,这是因为服务器在收到此进入请求之前可能有若干其他玩家也请求进入这个房间,造成房间人数达到上限。这一问题是无法通过上篇所述调整协作分配的方法来解决的,这是因为:要进入的房间是由玩家来指定的,无法在服务器端完成此项工作,游戏软件必须将服务器端所维护的所有房间人数数据复制到玩家的客户端,并让玩家在界面上看到这些数据,以便进行选择。这样,上篇所述的客户端与服务器端协作分配原则(谁掌握数据,谁干活),还得加上一些限制条件,并让位于另一个所谓"用户驱动客户端行为"原则--如果某个功能的执行是由用户来推动的,则这个功能的实现应当放在客户端(或者至少由客户端来控制整个协作),并且客户端必须持有此功能所依赖相关数据的副本,这个副本应当尽量与服务器端的源保持同步。
注意:点击图片可以放大观看
QQGame还存在一个明显的不足,就是:玩家如果在游戏一段时间后,离开了某个房间,并且想进入其它房间,这时QQGame并不会刷新所有房间的当前人数,造成玩家据此信息所选的待进入房间往往实际上人数已满,使得进入步骤失败。笔者碰到的最糟情形是重复3、4次以上,才最后成功进入另外某个房间。此缺陷其实质是完全放弃了客户端数据副本与服务器端的源保持同步的原则。
实际上,QQGame的开发者有非常充分的理由来为此缺陷的存在进行辩护:QQGame同时在线的用户数超过百万甚至千万数量级,如果所有客户端要实时(所谓实时,就玩家的体验容忍度而言,可以定为不超过1秒的延迟)地从服务器端获取更新数据,那么最终只有一个结果--系统彻底崩溃。设想一下每秒千万次请求的吞吐量,以普通服务器每秒上百个请求的处理能力(这个数据是根据服务请求处理过程可能涉及到I/O操作来估值的,纯内存处理的情形可能提高若干数量级),需要成千上万台服务器组成集群方能承受(高可用性挑战);而随着玩家不断地进入或退出游戏房间,相关数据一直在快速变化中,正向来看,假设有一台中心服务器持有这些数据,那么需要让成千上万台服务器与中心保持这些动态数据的实时同步(数据一致性挑战);相对应的,逆向来看,玩家进入房间等请求被分配给不同的服务器来处理,一旦玩家进入房间成功则对应服务器内的相关数据被改变,那么假定中的中心服务器就需要实时汇集所有工作服务器内发生的数据变动(数据完整性挑战)。同时处理上万台服务器的数据同步,这需要什么样的中心服务器呢?即使有这样的超级服务器存在,那么Internet网较大的(而且不稳定的)网络通讯延迟又怎么解决呢?
对于软件缺陷而言,可以在不同的层面来加以解决--从设计、到需求、甚至是直接在业务层面来解决(例如,08年北京奥运会网上购票系统,为了解决订票请求拥塞而至系统崩溃的缺陷,最后放弃了原先"先到先得"的购票业务流程,改为:用户先向系统发订票申请,系统只是记录下来而不进行处理,而到了空闲时,在后台随机抽选幸运者,为他们一一完成订票业务)。当然解决方案所处的层面越高,可能就越让人不满意。就上述进入房间可能遭遇失败的缺陷而言,最简便的解决方案就是:在需求层面调整系统的操作方式,即增加一个类似上篇所述"自动快速加入游戏"的功能--"自动进入房间"功能。系统在服务器端为玩家找到一个人数较多又未满的房间,并尝试进入(注意,软件需求是由用户的操作目标所驱动的,玩家在此的目标就是尽快加入一个满意的游戏组,因此由系统来替代玩家选择目标房间同样符合相关目标)。而为了方便玩家手工选择要进入的房间,则应当增加一个"刷新当前各房间人数"的功能。另外,还可以调整房间的组织模式,例如以地域为单位来划分房间,像深圳(长城宽带)区房间1、四川(电信)房间3、北美区房间1等,在深圳上网的玩家将被系统引导而优先进入深圳区的房间。
不管怎样,解决软件缺陷的王道还是在设计层面。要解决上述缺陷,架构设计师就必须同时面对高可用、数据一致性、完整性等方面的严峻挑战。
在思考相关解决方案时,我们将应用若干与高性能服务器集群架构设计相关的一些重要原则。首先是"分而治之"原则,即将大量客户端发出的服务请求进行适当的划分(例如,所有从深圳长城宽带上网的玩家所发出的服务请求分为一组),分别分配给不同的服务器(例如,将前述服务请求分组分配给放置于深圳数据中心的服务器)来加以处理。对于QQGame千万级的并发服务请求数而言,采用Scale Up向上扩展,即升级单个服务器处理能力的方式基本上不予考虑(没有常规的主机能处理每秒上千万的请求)。唯一可行的,只有Scale Out向外扩展,即利用大量服务器集群做负载均衡的方式,这实质上就是"分而治之"原则的具体应用。
图二 分而治之"下的QQGame游戏服务集群部署
然而,要应用"分而治之"原则进行Scale Out向外扩展,还依赖于其它的条件。如果各服务器在处理被分配的服务请求时,其行为与其它服务器的行为结果产生交叉(循环)依赖,换句话讲就是共享了某些数据(例如,服务器A处理客户端a发来的进入房间#n请求,而同时,服务器B也在处理客户端b发来的进入房间#n请求,此时服务器A与B的行为存在循环依赖--因为两者要同时访问房间#n的数据,这一共享数据会造成两者间的循环依赖),则各服务器之间必须确保这些共享数据的一致完整性,否则就可能发生逻辑错误(例如,假定房间#n的人数差一个就满了,服务器A与B在独自处理的情况下,将同时让客户端a与b的进入请求成功,于是房间#n的最终人数将超出上限)。而要做到此点,各服务器的处理进程之间就必须保持同步(实际上就是排队按先后顺序访问共享数据,例如服务器A先处理,让客户端a进入房间成功,此时房间#n满员;此后服务器B更新到房间#n满的数据,于是客户端b的进入请求处理结果失败),这样,原来将海量请求做负载均衡的意图就彻底失败了,多台服务器的并发处理能力在此与一台实质上并没有区别。由此,我们导出了另外一个所谓"处理自治"(或称"行为独立")的原则,即所有参与负载均衡的服务器,其处理对应服务请求的行为应当不循环依赖于其它服务器,换句话讲,就是各服务器的行为相对独立(注意,在这里,非循环依赖是允许的,下文中我们来分析为什么)。
由此可见,简单的负载均衡策略对于QQGame而言是解决不了问题的。我们必须找到一种途径,使得在使用大量服务器进行"分而治之"的同时,同时有确保各个服务器"处理自治"。此间的关键就在于"分而治之"的"分"字上。前述将某个地域网段内上网的玩家所发出的服务请求分到一组,并分配给同一服务器的做法,其目的不外乎是尽可能地减少网络通讯延迟带来的负面影响。但它不能满足"处理自治"的要求,为了确保自治,应当让同一台服务器所处理的请求本身是"自治"(准确的说法是"自闭包"Closure)的。同一台服务器所处理的所有请求组成一个服务请求集合,这个集合如果与其它任何与其无交集的(请求)集合(包含此集合的父集合除外)不循环依赖,则此服务请求集合是"自闭包"的,而处理此请求集合的服务器,其"行为独立"。我们可以将针对同一房间的进入请求划分到同一服务请求分组,这些请求相互之间当然是存在循环依赖的,但与其它分组中的请求却不存在循环依赖(本房间内人数的变化不会影响到其它房间),而将它们都分配给同一服务器(不妨命名为"房间管理服务器",简称"房间服务器")后,那个服务器将是"处理自治"的。
图三 满足"处理自治"条件的QQ游戏区域"房间管理"服务部署
那么接下来要解决的问题,就是玩家所关注的某个游戏区内,所有房间当前人数数据的实时更新问题。其解决途径与上述的方法类似,我们还是将所有获取同一区内房间数据的服务请求归为一组,并交给同一服务器处理。与上文所述场景不同的是,这个服务器需要实时汇集本区内所有房间服务器的房间人数数据。我们可以让每个房间服务器一旦发生数据变更时,就向此服务器(不妨命名为"游戏区域管理服务器",简称"区服务器")推送一个变更数据记录,而推送的数据只需包含房间Id和所有进入的玩家Id(房间服务器还包含其它细节数据,例如牌桌占位数据)便可。
另外,由于一个区内的玩家数可能是上十万数量级,一个服务器根本承担不了此种负荷,那么怎么解决这一矛盾呢?如果深入分析,我们会发现,更新区内房间数据的请求是一种数据只读类请求,它不会对服务器状态造成变更影响,因此这些请求相互间不存在依赖关系;这样,我们可以将它们再任意划分为更小的分组,而同时这些分组仍然保持"自闭包"特性,然后分配给不同的区服务器。多台区服务器来负责同一区的数据更新请求,负载瓶颈被解决。当然,此前,还需将这些区服务器分为1台主区服务器和n台从属区服务器;主区服务器负责汇集本区内所有房间服务器的房间人数数据,从属区服务器则从主区服务器实时同步区房间数据副本。更好的做法,则是如『图五』所示,由房间服务器来充当从属区服务器的角色,玩家进入某个房间后,在玩家进入另外一个房间之前,其客户端都将从此房间对应的房间服务器来更新区内房间数据。要注意的是,图中房间服务器的数据更新利用了所谓的"分布式对象缓存服务"。
玩家进入某个房间后,还要加入某个游戏组才能玩游戏。上篇所述的方案,是让第一个加入某个牌桌的用户,其主机自动充当本牌桌的游戏服务器;而其它玩家要加入此牌桌,其加入请求应当发往第一个加入的用户主机;此后开始游戏,其对弈过程将由第一个加入用户的主机来主导执行。
那么此途径是否同样也符合上述的前两个设计原则呢?游戏在执行的过程中,根据输赢结果,玩家要加分或减分,同时还要记录胜负场数。这些数据必须被持久化(比如在数据库中保存下来),因此游戏服务器(『图六』中的设计,是由4个部署于QQ客户端的"升级"游戏前台逻辑执行服务,加上1个"升级"游戏后台逻辑执行服务,共同组成一个牌桌的"升级"游戏服务)在处理相关游戏执行请求时,将依赖于玩家游戏账户数据服务(『图六』中的所谓"QQGame会话服务");不过这种依赖是非循环的,即玩家游戏账户数据服务器的行为反过来并不依赖于游戏服务器。上文中曾提到,"处理自治"原则中非循环依赖是允许的。这里游戏服务器在处理游戏收盘请求时,要调用玩家游戏账户数据服务器来更新相关数据;因为不同玩家的游戏账户数据是相互独立的,此游戏服务器在调用游戏账户数据服务器时,逻辑上不受其它游戏服务器调用游戏账户数据服务器的影响,不存在同步等待问题;所以,游戏服务器在此能够达成负载均衡的意图。
图四 存在"非循环依赖"的QQ游戏客户端P2P服务与交互逻辑部署
不过,在上述场景中,虽然不存在同步依赖,但是性能依赖还是存在的,游戏账户数据服务器的处理性能不够时,会造成游戏服务器长时间等待。为此,我们可以应用分布式数据库表水平分割的技术,将QQ玩家用户以其登记的行政区来加以分组,并部署于对应区域的数据库中(例如,深圳的玩家数据都在深圳的游戏账户数据库中)。
图五 满足"自闭包"条件的QQ分布式数据库(集群)部署
实际上,我们由此还可以推论出一个数据库表水平分割的原则--任何数据库表水平分割的方式,必须确保同一数据库实例中的数据记录是"自闭包"的,即不同数据库实例中的数据记录相互间不存在循环依赖。
总之,初步满足QQGame之苛刻性能要求的分布式架构现在已经是初具雏形了,但仍然有很多涉及性能方面的细节问题有待解决。例如,Internet网络通讯延迟的问题、服务器之间协作产生的性能瓶颈问题等等。笔者将在下篇中继续深入探讨这些话题。