江贵龙,游戏行业从业8年,历任多款游戏项目server主程。server负责人。 关注游戏server架构及优化,监控预警,智能运维,数据统计分析等。
1.背景
尽管游戏市场竞争激烈,产品行局变动较大,但游戏产业一直处于稳步增长阶段,不管是在端游。页游。手游还是已经初露端倪的H5游戏。
能够预见,游戏类型中,MMOARPG游戏仍然会是引领市场的主流趋势,贡献着大部分流水。市场上也仍然在不断涌现精品。研发团队对MMO游戏的探索从来未间断过,从付费模式的改变,到题材多元化,次时代的视觉效果。更成熟的玩法及数值体系,本文主要针对跨服玩法上的探索和实现做一些思考和分析。
依据2016年《中国游戏产业报告》数据显示,随着游戏人口红利逐渐消失,获取用户的成本居高不下,几年来至少翻了十倍以上。眼下平均导量成本页游为10~15元/人,手游在15~20元/人,当中IOS上成本30~50元/人,“洗”用户模式的效果正在变得微弱,用户流失严重。让我们先来看看滚服玩法的局限性,滚服洗量模式下存在着例如以下的弊端:
2.设计目标
在上述背景下。一款长留存,低流失的精品游戏就成了平台方。渠道商,研发方追捧的目标,设想一下,假设让全部server玩家通过“跨域体系”实现自由畅通交互,在此基础上,玩家能够体验到前所未有的“国战系统”——7×24小时昼夜不停服的国家战争,随时开战;突破单地图承载容量极限的国战对决,带来真正万人国战的刺激体验。形成全区玩家能够互动的游戏社交环境。
依托平台运营来打造一款真正意义上摆脱传统游戏运营模式的全新产品,为平台吸纳足够的市场份额。大幅减少流失率。
我们的蓝图是开创“1=1000”模式,让全部玩家,身处一个server却如同同一时候存在于全部server,这样的打破server屏障的设定,杜绝了游戏出现“被迫滚服”现象出现。玩家不用再操心鬼服人烟稀少。不用操心交易所一无全部,全部的数据共享。让玩家轻松Hold住全世界。
3.进化过程
项目组那时面临的现状是游戏各种档期计划、宣传推广安排都已经就绪。两个月后该独代项目要在腾讯平台按时上线。开发不能因引入跨服机制而导致全部完毕度100%的功能都要去分别去添加跨服的支持,而技术人员在跨服功能开发这块经验的积累上也不充分。
技术小组分析了时下项目的现状。跨服业务需求及现有的框架结构,明白了几点原则:
1. 为了实现跨服,游戏代码从底层架构到上层业务逻辑的代码修改成本尽量减少
2. 业务逻辑里尽量少关心或者不用关心是否在本服或者跨服。减少开发者的跨服功能开发复杂度,提高开发的效率,缩短开发周期。
那么,我们须要解决哪些技术疑点呢?
3.1 client直连还是server转发
a) 假设直连,那么,跨服玩法时client要维持两个连接,在跨服里,要模拟玩家登陆,绑定session的过程,游戏服和跨服两边要同一时候维护两份玩家数据,怎样做到数据的同步?跨服要暴露给玩家,须要有公网訪问IP和port。对client连接管理来说较复杂。
b) 假设通过大区server消息转发,那么,server之间做RPC通信,连接管理,消息需额外做一步跳转。性能是否能满足?跨不跨服,对于client来说透明,跨服隐藏在大区之后,更加安全,不需再浪费公网IP和port。
综合考虑了下,採用了B方案。
3.1.1 RPC框架设计需求
那么,我们须要先准备一套高性能轻量级的RPC框架。
业界有非常多典型的RPC框架。比方Motan、Thrift、gRPC、Hessian、Hprose,Wildfly,Dubbo,DubboX,为什么我们还要反复造轮子呢?综合考虑了下。框架要满足下面几点业务需求:
1. 该框架要简单、易用、支持高并发的跨服请求;
2. 依据现有的游戏server框架,会有非常多定制化的场景;
3. 通过NIO TCP长连接获取服务。但无需跨语言的需求;
4. 支持同步请求,异步请求。异步回调CallBack;
5. 要有服务发现的功能,要有Failfast能力;
6. 具备负载均衡。分组等路由策略;
基于有以上的诉求,结合团队曾经的开发经验,于是就决定自主研发。
我们选用的技术栈有 Netty、Apache Commons Pool、Redis等。
框架分为服务提供方(RPC Server)、服务调用方(RPC Client)、注冊中心(Registry)三个角色。基于Redis为服务注冊中心,通过其Pub/Sub实现服务动态的注冊和发现。
Server 端会在服务初始化时向Registry 注冊声明所提供的服务。Client 向 Registry 订阅到详细提供服务的 Server 列表,依据须要与相关的 Server 建立连接,进行 RPC 服务调用。同一时候。Client 通过 Registry 感知 Server 的状态变更。三者的交互关系如右图:
|
3.1.2 RPC请求的有序性
连接池在设计过程中。比較重要的是要考虑请求的顺序性,也就是先请求的先完毕。
假设玩家的跨服请求通过不同的RPC连接并发运行。就有可能单个玩家请求因错序而导致逻辑矛盾,比方玩家移动,见图2:
|
玩家移动是非常频繁的,假设A请求让玩家从位置1移动到位置2,B请求从位置2移动到位置3。有可能B请求先被跨服接收处理,这就会产生逻辑问题。
那么。怎样做到请求的有序性呢?其本质是让同一份数据的訪问能串行化,方法就是让同一个玩家的跨服请求通过同一条RPC连接运行,加上逻辑上的有效性验证,如图3所看到的:
3.1.3 同步RPC实现细节
限于篇幅,这里仅仅讲同步请求的RPC连接池实现。
同步请求的时序图如图4:
上图为进入跨服战场的一次同步请求,场景切换控制器StageControllAction发起进入跨服战场的请求applyChangeByBattlefield(),场景管理器StageControllManager首先要调用登录跨服的RPC请求GameRpcClient.loginCrossServer(LoginCrossServerReq),
跨服RPC请求的工作流是这样的:
public LoginCrossServerAck loginCrossServer(LoginCrossServerReqreq)throws ServiceException { //从连接池中获取一个连接 RpcClient rpcClient =rpcClientPool.getResource(req.getRoleId()); try { //发起一次同步RPC请求 RpcMsg msg=rpcClient.sendWithReturn(MsgType.RPC_LoginCrossServerReq,req); return JSON.parseObject(msg.getContent(), LoginCrossServerAck.class); } finally { //将连接放回连接池中 rpcClientPool.returnResource(rpcClient); } } |
该请求第一步先从连接池里获取一个连接RpcClient rpcClient = rpcClientPool.getResource(roleId),然后发起一个同步请求RpcClient.sendWithReturn(),等待直到结果返回,然后把资源归还连接池。
我们重点来看看sendWithReturn代码实现:
private ChannelsocketChannel; private Map<Long, CountDownLatch>watchDog =new ConcurrentHashMap<>(); private Map<Long, RpcMsg>responses =new ConcurrentHashMap<>(); /**同步请求*/ public RpcMsg sendWithReturn(intmsgType, Objectmsg)throws ServiceException { RpcMsg rpcMsg = RpcMsg.newBuilder().setServer(false).setSync(true).setSeqId(buildSeqId()). setTimestamp(System.nanoTime()).setType(msgType).setContent(JSON.toJSONString(msg)).build(); //创建一把共享锁 CountDownLatch latch =new CountDownLatch(1); watchDog.put(rpcMsg.getSeqId(),latch); writeRequest(rpcMsg); return readRequest(rpcMsg.getSeqId(),latch); } /**发送消息*/ publicvoid writeRequest(RpcMsgmsg)throws ServiceException { if (channel.isActive()) { channel.writeAndFlush(msg); } }
/**堵塞等待返回*/ protected RpcMsg readRequest(longseqId, CountDownLatchlatch)throws ServiceException { try { //锁等待 if (timeout <= 0) { //无限等待,直到有返回 latch.await(); } else { //超时等待 latch.await(timeout, TimeUnit.MILLISECONDS); } } catch (InterruptedExceptione) { throw new ServiceException(e); } //解锁后或者超时后继续往下走 watchDog.remove(seqId); RpcMsg response = responses.remove(seqId); if (response ==null) { throw new ServiceException("read request timeout"); } return response; } |
//获得锁 CountDownLatch latch = rpcClient.getCountDownLatch(msg.getSeqId()); if (latch !=null) { rpcClient.setResponse(msg.getSeqId(),msg); //解锁 latch.countDown(); } |
測试场景为分别在连接数在1,8,并发数1,8,数据大小在22byte,94byte,2504byte情况下,做測试。消息同步传输,原样返回,下面是针对同步请求压力測试的结果(取均值):
连接数 | 并发数 | 请求类型 | 数据大小(bytes) | 平均TPS | 平均响应时间(ms) |
1 | 1 | Sync | 22 | 5917 | 0.169 |
8 | 1 | Sync | 22 | 6849 | 0.146 |
8 | 8 | Sync | 22 | 25125 | 0.0398 |
8 | 8 | Sync | 94 | 20790 | 0.0481 |
8 | 8 | Sync | 2504 | 16260 | 0.0725 |
3.2 server之间主动推,还是被动拉取
3.2.1被动拉取模式(Pull)
因为我们的游戏server和跨服server代码基本一致,所以仅仅要能在跨服中获得游戏功能所要的数据,那么,就能完毕不论什么原有的功能,而且改造成本基本为零,我们选择了被动拉取。
这里要提出一个概念:数据源的相对性
提供数据方。C向B请求一份数据,B是C的数据源,B向A请求一份数据,A是B的数据源。
|
一个玩家跨服过去后。往游戏原服拉取数据的细节图如图6:
|
玩家先跨服过去,loginCrossServer(LoginCrossServerReq),然后。在用到随意数据时(主角,技能,坐骑,装备,宠物等),反向同步请求各个系统的数据。
我们的实现如图7所看到的:
|
public abstractclass AbstractCacheRepository<T, Kextends Serializable> { private final LoadingCache<K, DataWrapper<T>>caches; public AbstractCacheRepository() { Type mySuperClass = this.getClass().getGenericSuperclass(); Type type = ((ParameterizedType) mySuperClass).getActualTypeArguments()[0]; AnnotationEntityMaker maker = new AnnotationEntityMaker(); EntityMapping<T> entityMapping = maker.make((Class<T>) type); CacheLoader<K, DataWrapper<T>> loader =new CacheLoader<K, DataWrapper<T>>() { @Override public DataWrapper<T> load(K entityId) throws Exception { return new DataWrapper<T>(this.load(entityId,entityId)); } //依据不同的訪问接口訪问数据 public T load(Serializable roleId, K entityId) { return this.getDataAccessor(roleId).load(entityMapping,roleId,entityId); } public DataAccessor getDataAccessor(SerializableroleId) { return DataContext.getDataAccessorManager().getDataAccess(roleId); } }; caches = CacheBuilder.newBuilder().expireAfterAccess(300, TimeUnit.SECONDS).build(loader); } public T cacheLoad(K entityId) { return this.load(entityId); } private T load(K entityId) { return caches.getUnchecked(entityId).getEntity(); } } |
1) 玩家在游戏本服,获取Role数据,通过RoleRepository.cacheLoad(longroleId),先从Cache里读取,没有,则调用訪问器MySQLDataAccessor.load(EntityMapping<T> em,Serializable roleId, K id)从数据库读取数据。
2) 玩家在跨服,获取Role数据,通过RoleRepository.cacheLoad(longroleId),先从Cache里读取,没有。则调用訪问器NetworkDataAccessor.load(EntityMapping<T>em, Serializable roleId, K id),通过RPC远程同步调用读取数据session.sendRPCWithReturn(),该方法的实现能够參考上述的RpcClient.sendWithReturn()。相相似。
关于被动拉取的优缺点介绍,在下文另有论述。总之,因为被动拉取的一些我们始料未及的缺陷存在。成为了我们server端开发部分功能的噩梦,从选择该模式时就埋下了一个天坑。
3.2.2主动推送模式(Push)
为了攻克了上面碰到的一系列问题, 而且还能坚持最初的原则,我们做了例如以下几点优化
优化方案有例如以下几点:
1. 假设玩家在本服,和调整前一样的处理流程。假设玩家在跨服,client请求的指令,公布的事件。异步事件须要在场景Stage线程处理的,就转发到跨服。须要在其它个人业务线程(bus),公共业务线程(public)处理的,仍旧在本服处理。
2. 场景业务线程不再同意有DB操作
3. 内部指令的转发、事件分发系统、异步事件系统要在底层支持跨服
4. 玩家在登录本服时就会构PlayerTemplate, 场景用到的数据会实时更新,玩家去跨服,则会把场景中用到的数据PlayerTemplate主动推送给跨服。
|
主动推送模式图示显示如图8所看到的:
方案对照 | ||
基本參数 | 被动拉取模式 | 主动推送模式 |
修改工作量 | 既实现了原先的既定目标。修改成本基本为零,对于进度紧张的项目来说。是个极大的诱惑 | 需屏蔽在Stage线程中针对DB的CRUD操作,构建PlayerTemplate而引发的一系列修改 |
server之间的内部指令和事件分发量 | 因为个人业务数据和场景业务数据都在跨服处理。所以不须要进行跨进程通信 | 对于server之间内部指令。事件分发添加了一定的量 |
数据中心问题 | 数据中心进行了转移,把本服的数据更新给锁住。 假设部分数据没锁住,就会导致数据的不同步,或者说,本服数据做了更新而导致回档的风险。而假设跨服宕机。则有5分钟的回档风险 | 不变不转移,从根本上规避了数据回档的风险 |
通信数据量 | 大量数据的迁移,比方要获得一个道具,须要把这个玩家的全部的道具的数据从本服迁移到跨服,大大添加的了数据的通信量 | 仅仅把跨服所须要的场景数据推送过去,数据量大大减少 |
用户体验 | 为了不让一些游戏数据回档,我们不得不正确某些功能做显式屏蔽。但这样带来的体验就非常不好。当跨服后,点击获取邮件,会显示你在跨服不同意获取提取附件。屏蔽公会的操作,比方公会捐献,公会领工资,因为不可能把整个公会的数据给同步到跨服中 | 全部的功能都不会被屏蔽 |
开发活动的难易度 | 因为每一个游戏区的活动系统(开服活动,和服活动。节日活动。商业化冲KPI的活动)的差异性,给编码带来了非常大复杂性。 | 涉及到的全部商业化活动的功能开发和本服一样简单 |
充值问题 | 充值回调都是到游戏区本服,那怎么办呢,就必须同步这个数据到跨服 | 在处理充值回调时不用再考虑是否在跨服 |
RPC性能问题 | 因为要跨服从本服拉取数据,这个请求必须是同步的。所以同步的RPC请求的频繁导致了跨服性能的减少,特别是当某个跨服活动刚开启时,有非常多玩家涌入这个场景。会发生非常多同步请求(role,item,skill,horse,pet,achievement…)。导致部分玩家的卡在跨服场景跳转过程中,详细实现请參考上述同步请求代码实现sendWithReturn | 去掉了跨服从游戏服拉数据的需求。改成了跨服时本地推送一次场景须要用得到的数据。基本去掉了99%同步RPC请求。
|
消息转发量 | 须要把全部玩家的请求都转发到跨服,转发量非常大,60+%的消息事实上是不是必需转发到跨服去处理的 | 除了场景上的操作的Action请求,不须要再被转发到跨服去运行,极大的减少了消息的转发量。 |
看下事件分发代码的改造:
/**事件分发器*/ public abstract class AbEvent { private static AtomicLong seq = new AtomicLong(System.currentTimeMillis()); /**事件订阅*/ public abstract void subscribe(); /**事件监听器*/ protected abstract List<HandlerWrapper> getHandlerPipeline(); /**事件分发*/ protected void dispatch() { id = seq.incrementAndGet(); List<HandlerWrapper> handlerList = this.getHandlerPipeline(); DispatchEventReq<AbEvent> req = new DispatchEventReq<>(); req.setRoleId(roleId); req.setEntity(this); for (HandlerWrapper wrapper : handlerList) { byte group = wrapper.getGroup(); if (group == 0) { // 同线程串行运行 eventManager.syncCall(wrapper, this); } else { // 非同线程异步运行,可能去远程运行 this.advancedAsyncCall(req, wrapper); } } } } |
/** 跨服接收消息分发的事件 */ @Override public <T> void dispatchEvent(Session session, DispatchEventReq<T> msg) { T event = msg.getEntity(); List<String> list = msg.getHandlerList(); long roleId = msg.getRoleId(); for (String e : list) { HandlerWrapper wrapper = eventManager.getHandlerWrapper(e, event); eventManager.asyncCall(roleId, wrapper, event); } } |
例如以下图,举个样例。在跨服怪物死亡后,会抛出 MonsterDeadEvent事件,在跨服进程直接处理场景的监听相应的逻辑: 场景中道具掉落,尸体处理。其它的监听逻辑抛回游戏服处理。依据这事件,任务模块处理完毕任务,获得奖励;成就模块处理完毕成就,获得奖励; 主角模块获得经验。金币等奖励;活动模块处理完毕活动,获得奖励。
|
3.3 其它方面的优化
3.3.1 消息组播机制
消息组播的优化,在跨服,来自同一服的全部玩家广播从分别单独消息转发,改成一个消息发回本服,然后再广播给玩家(比方来自同一个服n个玩家。原本广播一条消息,server之间之间要处理n个RPC消息,如今仅仅须要处理1个消息,降到了原先的1/n)
|
3.3.2 通信数据量
一个完整的PlayerTemplate模版数据因为包括了玩家在场景里用到的全部数据,比方角色、宠物、坐骑、装备、神器、法宝、时装、技能、翅膀等等,数据量比較大,平均能达到5KB左右,须要在server之间传输时做zlib压缩,比方,做了压缩后。11767 Byte的玩家数据能压缩到2337Byte。压缩率可达到19.86%。
3.3.3 序列化/反序列化
改造前,全部的请求都须要先在本服做AMF3反序列化。假设请求是须要转发到跨服的,再通过JSON序列化传输给跨服,在跨服通过JSON反序列化,终于该请求被处理。
但实际上,中间过程JSON序列化和反序列化似乎是没有必要的。经过改造。对须要转发给跨服的请求。在本服先不做AMF3反序列化,发送到跨服后再处理,这样就少了一次JSON的序列化和反序列化,同一时候收益了另外的一个优点:减少了传输的字节
|
|
3.3.5 server分组机制
不定向跨服是指随意游戏区的玩家都有可能匹配到一起进行游戏玩法的体验,比方跨服战场。比方跨服副本匹配,如右图所看到的:
|
怎样在游戏正式大区中选择几个服做灰度服,又不影响不定向跨服体验;以及怎样解决新老服玩家战力发展不在同一起跑线而导致的不平衡问题曾一度让人纠结。
|
比方游戏产品推出了大型资料片。想先做下灰度測试。让1~4区的玩家先做下新功能的体验。同一时候又能防止玩家穿了一件旧版本号不存在的装备而在跨服环境下报异常。依据运营需求通过分组,就非常完美的攻克了上述问题。
3.3.6 战区自己主动分配机制
|
定向跨服是指在一定时间内会固定參与跨服玩法的几个国家,经常使用于战区中国家之间对战,如右图所看到的,须要运营在后台配置;当一段时间后,随着玩家流失。又须要运营依据战力进行战区的调整。对运营人员的要求比較高
调整后,每一种基于战区的跨服类型都能够自己定义调整时间间隔。到时间点全局server(global server)系统自己主动依据全区的活跃战力匹配进行调整,让运营人员从繁杂的配置中解脱出来。
3.3.7 跨服断线重连机制
比方战场系统或组队副本,因为网络状况而掉线。假设又一次登录后,没法进入,将会严重影响战场的战况,顺风局立即就可能会变成逆风局。主力DPS掉线副本就有可能通不了,这个机制就弥补了这块的缺陷。
4 支持的玩法
眼下,我们已经能支持随意的游戏区玩家能够到随意的跨服server进行游戏功能的体验。比方已经实现的跨服组队副本、跨服战场、跨服国战、跨服皇城争夺、跨服资源战、虫群入侵战、跨服押镖、挖矿争夺等。
也支持玩家在本服就能够进行跨服互动,比方和别的区的玩家聊天、加好友、送礼等无缝交互,及国家拍卖行,世界拍卖行的跨服贸易。
甚至支持玩家穿越到另外的游戏区做随意的游戏体验,比方一区的玩家听说二区服在举行抢亲活动,
你能够跑到2区去欣赏參与,也跑到随意的区的中央广场去显摆你的极品套装。
5 跨服在线数据
如图18,跨服定向玩法有战区国家玩法,虫群入侵,跨服押镖,挖矿争夺, 跨服皇城争夺,跨服国战等,例如以下图所看到的。我们能够看出这样的玩法的规律:每次活动开启,跨服就会迎来一波波玩家涌入,活动一结束,玩家就会离开。4个跨服进程支持了7600在线的玩家。
|
如图19,跨服非定向性玩法有跨服组队副本。跨服战场等,支持负载均衡。能够随时动态添加跨服。如右图所看到的。这些玩法的规律是24小时随时能够体验进入。在线比較稳定。8个跨服进程支持了28000在线的玩家。
|
图20是游戏某个跨服玩法的截图,能够看出,该游戏当时具有非常高的人气。
|
6 技术架构
图21为跨服通信拓扑图。属于总体架构的核心部分,关于这一部分的说明见图表:
|
server种类 | 说明 |
游戏逻辑server Game Server | 1.网关,跟玩家保持连接, 提供对外訪问,转发消息。直接与客户消息交互; |
跨服server Cross Server | 处理跨服相关的逻辑。随意区的玩家能够到达到随意的的跨服server, 依据负载压力无限动态扩展 |
全局server Gobal Server | 控制跨服server的负载均衡,处理要跨服的玩家的匹配处理,分配跨服房间等 |
Redis | 做战区的Pub/Sub服务 |
关于总体架构的介绍。兴许的文章会和大家分享。
7 小结
此套架构历经了《大闹天宫OL》、《诸神黄昏》、《暴风王座》、《惊天动地》,《三打白骨精》、《英雄领主》、《封神霸业》等先后近两万组server运行的验证和团队的技术积累。
|
本文从当前游戏市场发展的背景出发,提出了设计自由交互的“跨域体系”的必要性,然后在实现跨服架构过程中对设计目标、原则、存在的技术难点进行了思考。实现了一套用于跨服通信的高吞吐的RPC通信框架。先后体验了被动拉取模式带来的坑。和改成主动推送模式带来的便利。而且。对该架构设计在消息组播,通信量,消息序列化/反序列化,server分组,战区自己主动分配,断线重连等进行了多方面机制的分析及深度优化,最后上线实践做了可行性验证。提供了强有力的数据支持。总体表现稳定流畅。
原文链接:
原作者: 江贵龙