江貴龍,游戲行業從業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分組,戰區自己主動分配,斷線重連等進行了多方面機制的分析及深度優化,最后上線實踐做了可行性驗證。提供了強有力的數據支持。總體表現穩定流暢。
原文鏈接:
原作者: 江貴龍