既然是游戲服務端程式員,那博客里至少還是得有一篇跟游戲服務端有關的文章,今天文章主題就關於游戲服務端。
1.寫在前面
既然是游戲服務端程式員,那博客里至少還是得有一篇跟游戲服務端有關的文章,今天文章主題就關於游戲服務端。
寫這篇博客之前也挺糾結的,一方面是因為游戲服務端其實不論架構上還是具體一些邏輯模塊的構建,都屬於非常成熟的技術,舉個簡單的例子,像端游的多zone/scene/game進程+單全局進程架構,網上隨便一搜能搜出來幾十篇內容差不多的。另一方面是因為中國特色MMO基本上把服務端程式員整成了業務邏輯狗,很多明星團隊的業務狗基本上從入職第一天開始就成天寫lua、寫python,純寫lua/python,你是完全無法辨別一個程式員的vision強弱區別的,結果論資排輩導致vision弱的上去了。(也許vision強的出去創業了?)你就會發現,游戲服務端的話語權到底是被誰占據了。
在我看來,游戲服務端程式員容易陷入兩個誤區:
第一,游戲服務端實際上要解決的並不是性能問題。一方面,即使是千人同屏的端游(姑且不論這千人同屏是不是一個中國特色的偽需求,反正我是沒法將千人同屏跟游戲樂趣聯繫在一起的),其服務端如果進程劃分得當,一個場景進程也至多只有千級別entity的壓力,性能問題退化為了邏輯狗的業務素養問題。另一方面,現在端游MOBA和手游時代,開房間式場景同步已經成為主流,各種邏輯狗進化來的資深人士不需要也沒必要將性能掛在嘴邊了。
第二,大部分游戲服務端所謂框架的定位有誤。服務端框架的設計有好有壞,判斷一個設計好不好沒有普適統一的標準,但是判斷一個設計爛不爛一定是存在一個標準線的。簡單列舉幾種爛設計:
爛設計基礎版本。幫你定義好框架中的幾種角色,你要麼全盤接受,要麼全不接受,不存在中間狀態。但是,提供一種簡單的通信機制,以及外部與框架通信的clientLib。或者能讓你定製開發其中一種角色,可以寫外部driver。這樣,雖然架構醜一點,至少還能提供一定程度的擴展性。
爛設計進階版本。除了滿足基礎版本的定義之外,還具有一些額外的爛特點:框架中的角色定義的特別二逼,舉個例子,基礎版本的爛設計在角色定義上可能只是大概區分了Db代理進程、Gate進程、邏輯進程,但是進階版本會對邏輯進程進行區分,定義了不同的邏輯進程角色。這意味著什麼?意味著我想寫一個簡單的單邏輯進程游戲是沒辦法用這個框架的,因為框架預設就集成進來了一堆莫名其妙的東西。更有甚者,我想要添加一種角色,是需要動手去改框架的。
說實話,正是由於這類設計的存在,我在看到類似於“游戲服務端技術含量不高”這類論斷的時候,總感覺辯無可辯,因為就這兩種設計而言,我甚至除了代碼邏輯複雜度之外看不到跟本科畢設級別的游戲伺服器有什麼區別。
不知道算是不幸還是幸運,前段時間親眼目睹了上述提到的某種設計的從無到有的過程。當然,今天寫此文的目的不是為了將這種設計批判一番,每種設計的誕生都是與各種因素相關的,我們不能站在上帝視角去評判這個過程。今天寫此文,是希望對自己這整整一年半的游戲服務端編碼歷程中的一些所思所惑做個整理,希望能帶各位看官從另一個思路看游戲服務端。
2.游戲服務端究竟解決了什麼問題?
從定義問題開始,簡單直接地說,一套游戲服務端開發框架應該具有下麵兩種能力:
- 定義了client到server、server到client、server到server的消息pipeline。
- 描述了游戲世界狀態的維護方式。
下麵就從這兩點來展開這篇文章。
3 消息pipeline
3.1 經典消息pipeline
3.1.1 場景同步
當討論到游戲服務端的時候,我們首先想到的會是什麼?要回答這個問題,我們需要從游戲服務端的需求起源說起。
定義問題
游戲對服務端的需求起源應該有兩個:
- 第一種是單機游戲聯網版,實現為主客機模式的話,主機部分可以看做服務端。
- 第二種是所有mmo的雛形mud,跟webserver比較類似,一個host服務多clients,表現為cs架構。
第一種需求長盛不衰,一方面是console游戲特別適合這一套,另一方面是最近幾年手游起來了,碎片化的PVE玩法+開房間式同步PVP玩法也得到驗證,畢竟MMO手游再怎麼火也不可能改變手游時間碎片化的事實的,最近的皇家衝突也證明,手游不會再重走端游老路了。
第二種需求就不用說了,網上大把例子可以參考。最典型的是假設有這樣一塊野地,上面很多玩家和怪,邏輯都在服務端驅動,好了,這類需求沒其他額外的描述了。
但是,解決方案畢竟是不斷發展的,即使速度很慢。
說不斷發展是特指針對第一種需求的解決方案,發展原因就是國情,外掛太多。像war3這種都還是純正的主客機,但是後來對戰平臺出現、發展,逐漸過渡成了cs架構。真正的主機 其實是建在伺服器的,這樣其實伺服器這邊也維護了房間狀態。後來的一系列ARPG端游也都是這個趨勢,服務端越來越重,逐漸變得與第二種模式沒什麼區別。 同理如現在的各種ARPG手游。
說發展速度很慢特指針對第二種需求的解決方案,慢的原因也比較有意思,那就是wow成了不可逾越的鴻溝。bigworld在wow用之前名不見經傳,wow用了之後國內廠商也跟進。發展了這麼多年,現在的無縫世界服務端跟當年的無縫世界服務端並無二致。發展慢的原因就觀察來說可能需求本身就不是特別明確,MMO核心用戶是重社交的,無縫世界核心用戶是重體驗的。前者跑去玩了天龍八部和倩女不幹了,說這倆既輕鬆又妹子多;後者玩了console游戲也不幹了,搞了半天MMO無縫世界是讓我更好地刷刷刷的。所以仔細想想,這麼多年了,能數得上的無縫世界游戲除了天下就是劍網,收入跟重社交的那幾款完全不在一個量級。
兩種需求起源,最終其實導向了同一種業務需求。傳統MMO架構(就是之前說的天龍、倩女類架構),一個進程維護多個場景,每個場景里多個玩家,額外的中心進程負責幫玩家從一個場景/進程切到另一個場景/進程。bigworld架構,如果剝離開其圍繞切進程所做的一些外圍設施,核心工作流程基本就能用這一段話描述。
抽象一下問題,那我們談到游戲服務端首先想到的就應該是多玩家對同一場景的view同步,也就是場景服務。
本節不會討論幀同步或是狀態同步這種比較上層的問題,我們將重點放在數據流上。
如何實現場景同步?
首先,我們看手邊工具,socket。
之所以不提TCP或UDP是因為要不要用UDP自己實現一套TCP是另一個待撕話題,這篇文章不做討論。因此,我們假設,後續的實現是建立在對底層協議一無所知的前提之上的,這樣設計的時候只要適配各種協議,到時候就能按需切換。
socket大家都很熟悉,優點就是各操作系統上抽象統一。
因此,之前的問題可以規約為:如何用socket實現場景同步?
拓撲結構是這樣的(之後的所有圖片連接箭頭的意思表示箭頭指向的對於箭頭起源的來說是靜態的):
場景同步有兩個需求:
- low latency
- rich interaction
要做到前者,最理想的情況就是由游戲程式員把控消息流的整套pipeline,換句話說,就是不藉助第三方的消息庫/連接庫。當然,例外是你對某些第三方連接庫特別熟悉,比如很多C++服務端庫喜歡用的libevent,或者我在本篇文章提供的示例代碼所依賴的,mono中的IO模塊。
要做到後者,就需要保持場景同步邏輯的簡化,也就是說,場景邏輯最好是單線程的,並且跟IO無關。其核心入口就是一個主迴圈,依次更新場景中的所有entity,刷新狀態,並通知client。
正是由於這兩個需求的存在,網路庫的概念就出現了。網路庫由於易於實現,概念簡單,而且籠罩著“底層”光環,所以如果除去玩具性質的項目之外,網路庫應該是程式員造過最多的輪子之一。
那麼,網路庫解決了什麼問題?
拋開多項目代碼復用不談,網路庫首先解決的一點就是,將傳輸層的協議(stream-based的TCP協議或packet-based的UDP協議)轉換為應用層的消息協議(通常是packet-based)。對於業務層來說,接收到流和包的處理模型是完全不同的。對於業務邏輯狗來說,包顯然是處理起來更直觀的。
流轉包的方法很多,最簡單的可伸縮的non-trivial buffer,ringbuffer,bufferlist,不同的結構適用於不同的需求,有的方便做zero-copy,有的方便做無鎖,有的純粹圖個省事。因為如果沒有個具體的testcast或者benchmark,誰比誰一定好都說不准。
buffer需要提供的語義也很簡單,無非就是add、remove。buffer是只服務於網路庫的。
網路庫要解決的第二個問題是,為應用層建立IO模型。由於之前提到過的場景服務的rich interaction的特點,poll模型可以避免大量共用狀態的存在,理論上應該是最合適場景服務的。所謂poll,就是IO線程準備好數據放在消息隊列中,用戶線程負責輪詢poll,這樣,應用層的回調就是由用戶線程進入的,保證模型簡單。
而至於IO線程是如何準備數據的,平臺不同做法不同。linux上最合適的做法是reactor,win最合適的做法就是proactor,一個例外是mono,mono跑在linux平臺上的時候雖然IO庫是reactor模型,但是在C#層面還是表現為proactor模型。提供統一poll語義的網路庫可以隱藏這種平臺差異,讓應用層看起來就是統一的本線程poll,本線程回調。
網路庫要解決的第三個問題是,封裝具體的連接細節。cs架構中一方是client一方是server,因此連接細節在兩側是不一樣的。而由於socket是全雙工的,因此之前所說的IO模型對於任意一側都是適用的。
連接細節的不同就體現在,client側,核心需求是發起建立連接,外圍需求是重連;server側,核心需求是接受連接,外圍需求是主動斷開連接。而兩邊等到連接建立好,都可以基於這個連接構建同樣的IO模型就可以了。
現在,簡單介紹一種網路庫實現。
- 一個連接好的socket對應一個connector。
- connector負責向上提供IO模型抽象(poll語義)。同時,其藉助維護的一個connector_buffer,來實現流轉包。
- 網路庫中的client部分主要組件是ClientNetwork,維護連接(與重連)與一條connector。
- 網路庫中的server部分主要組件是ServerNetwork,維護接受連接(與主動斷開)與N條connector。
- Network層面的協議非常簡單,就是len+data。
具體代碼不再在博客里貼了。請參考:Network
引入新的問題
如果類比馬斯洛需求中的層次,有了網路庫,我們只能算是解決了生理需求:可以聯網。但是後面還有一系列的複雜問題。
最先碰到的問題就是,玩家數量增加,一個進程扛不住了。那麼就需要多個進程,每個進程服務一定數量的玩家。
但是,給定任意兩個玩家,他們總有可能有交互的需求。
對於交互需求,比較直觀的解決方案是,讓兩個玩家在各自的進程中跨進程交互。但是這就成了一個分散式一致性問題——兩個進程中兩個玩家的狀態需要保持一致。至於為什麼一開始沒人這樣做,我只能理解為,游戲程式員的電腦科學素養中位程度應該解決不了這麼複雜的問題。
因此比較流行的是一種簡單一些的方案。場景交互的話,就限定兩個玩家必須在同一場景(進程),比如攻擊。其他交互的話,就藉助第三方的協調者來做,比如公會相關的通常會走一個全局伺服器等等。
這樣,服務端就由之前的單場景進程變為了多場景進程+協調進程。新的問題出現了:
玩家需要與服務端保持多少條連接?
一種方法是保持O(n)條連接,既不環保,擴展性又差,可以直接pass掉。
那麼就只能保持O(1)條連接,如此的話,如何確定玩家正與哪個服務端進程通信?
要解決這個問題,我們只能引入新的抽象。
3.1.2 Gate
定義問題
整理下我們的需求:
- 玩家在服務端的entity可以在不同的進程中,也可以移動到同一個進程中。
- 玩家只需要與服務端建立有限條連接,即有訪問到任意服務端進程的可能性。同時,這個連接數量不會隨服務端進程數量增長而線性增長。
要解決這些需求,我們需要引入一種反向代理(reverse proxy)中間件。
反向代理是服務端開發中的一種常見基礎設施抽象(infrastructure abstraction),概念很簡單,簡單說就是內網進程不是藉助這種proxy訪問外部,而是被動地掛在proxy上,等外部通過這種proxy訪問內部。
更具體地說,反向代理就是這樣一種server:它接受clients連接,並且會將client的上行包轉發給後端具體的服務端進程。
很多年前linux剛支持epoll的時候,流行一個c10k的概念,解決c10k問題的核心就是藉助性能不錯的反向代理中間件。
游戲開發中,這種組件的名字也比較通用,通常叫Gate。
Gate解決了什麼問題
- 首先,Gate作為server,可以接受clients的連接。這裡就可以直接用我們上一節輸出的網路庫。同時,其可以接受服務端進程(之後簡稱backend)的連接,保持通信。
- 其次,Gate能夠將clients的消息轉發到對應的backend。與此對應的,backend可以向Gate訂閱自己關註的client消息。對於場景服務來說,這裡可以增加一個約束條件,那就是限制client的上行消息不會被dup,只會導到一個backend上。
僅就這兩點而言,Gate已經能夠解決上一節末提出的需求。做法就是client給消息加head,其中的標記可以供Gate識別,然後將消息路由到對應的backend上。比如公會相關的消息,Gate會路由到全局進程;場景相關的消息,Gate會路由到訂閱該client的場景進程。同時,玩家要切場景的時候,可以由特定的backend(比如同樣由全局進程負責)調度,讓不同的場景進程向Gate申請修改對client場景相關消息的訂閱關係,以實現將玩家的entity從場景進程A切到場景進程B。
站在比需求更高的層次來看Gate的意義的話,我們發現,現在clients不需要關註backends的細節,backends也不需要關註clients的細節,Gate成為這一pipeline中唯一的靜態部分(static part)。
當然,Gate能解決的還不止這些。
我們考慮場景進程最常見的一種需求。玩家的移動在多client同步。具體的流程就是,client上來一個請求移動包,路由到場景進程後進行一些檢查、處理,再推送一份數據給該玩家及附近所有玩家對應的clients。
如果按之前說的,這個backend就得推送N份一樣的數據到Gate,Gate再分別轉給對應的clients。
這時,就出現了對組播(multicast)的需求。
組播是一種通用的message pattern,同樣也是發佈訂閱模型的一種實現方式。就目前的需求來說,我們只需要為client維護組的概念,而不需要做inter-backend組播。
這樣,backend需要給多clients推送同樣的數據時,只需要推送一份給Gate,Gate再自己dup就可以了——儘管帶來的好處有限,但是還是能夠一定程度降低內網流量。
那接下來就介紹一種Gate的實現。
我們目前所得出的Gate模型其實包括兩個組件:
- 針對路由client消息的需求,這個組件叫Broker。Broker的定義可以參考zguide對DEALER+ROUTER pattern的介紹。Broker的工作就是將client的消息導向對應的backend。
- 針對組播backend消息的需求,這個組件叫Multicast。簡單來說就是維護一個組id到clientIdList的映射。
Gate的工作流程就是,listen兩個埠,一個接受外網clients連接,一個接受內網backends連接。
Gate有自己的協議,該協議基於Network的len+data協議之上構建。
clients的協議處理組件與backends的協議處理組件不同,前者只處理部分協議(不會識別組控制相關協議,訂閱協議)。
在具體的實現細節上,判斷一個client消息應該路由到哪個backend,需要至少兩個信息:一個是clientId,一個是key。
同一個clientId的消息有可能會路由到不同的backend上。
當然,Gate的協議設計可以自由發揮,將clientId+key組成一個routingKey也是可以的。
引入Gate之後的拓撲:
具體代碼請參考:GateSharp
引入新的問題
現在我們在需求的金字塔上更上了一層。之前我們是擔心玩家數量增長會導致服務端進程爆掉,現在我們已經可以隨意擴容backend進程,我們還可以通過額外實現的全局協調者進程來實現Gate的多開與動態擴容。甚至,我們可以通過構建額外的中間層,來實現服務端進程負載動態伸縮,比如像bigworld那樣,在場景進程與Gate之間再隔離出一層玩家agent層。
可以說,在這種方案成熟之後,程式員之間開始流行“游戲開發技術封閉”這種說法了。
為什麼?
舉一個簡單的例子,大概描述下現在一個游戲項目的服務端生命周期狀況:
- 第一階段,大概到目前這篇文章的進度為止,實現了場景內跑跳打。
- 第二階段,瘋狂地為場景進程增加邏輯,各種跟游戲有關的邏輯全加進來,直到這部分的代碼量占到整個服務端代碼量的80%以上。
- 第三階段,有節操的程式員考慮拆分進程,當然,一開始的協調者進程一直都會存在,畢竟有些需求是場景進程無論如何都實現不了的。拆分進程的典型例子有聊天、郵件、公會等等。拆分出來的進程基本上是對場景進程代碼輪廓的拷貝粘貼,刪掉邏輯就開始在這之上寫了。
結果就是,產出了幾個玩具水平的伺服器進程。要非得說是工業級或者生產環境級別的吧,也算是,畢竟bugfix的代碼的體量是玩具項目比不了的。而且,為了更好地bugfix,通常會引入lua或者python,然後游戲邏輯全盤由腳本構建,這下更方便bugfix了,還是hotfix的,那開發期就更能隨便寫寫寫了,你說架構是什麼東西?
至於具體拓撲,可以對著下圖腦補一下,增加N個節點,N個節點之間互相連接。
玩具水平的項目再修修補補,也永遠不會變成工藝品。
skynet別的不說,至少實現了一套輕量級的actor model,做服務分離更自然,服務間的拓撲一目瞭然,連接拓撲更是優雅。網易的mobile_server,說實話我真的看不出跟bigworld早期版本有什麼區別,連接拓撲一塌糊塗,完全沒有服務的概念,手游時代了強推這種架構,即使成了幾款過億流水又怎樣?
大網易的游戲開發應屆生招聘要求精通分散式系統設計,就mobile_server寫出來的玩具也好意思說是“分散式系統”?
很多游戲服務端程式員,在游戲服務端開發生涯結束之前,其接觸的,或者能接受的設計基本到此為止。如果是純MMO手游,這樣做沒什麼,畢竟十幾年都這樣過來了,開發成本更重要。更搞笑的是社交游戲、非同步戰鬥的卡牌游戲也用mobile_server,真搞不明白怎麼想的。
大部分游戲服務端實現中,伺服器進程是原子單位。進程與進程之間的消息流建立的成本很低,結果就是服務端中很多進程互相之間形成了O(n^2)的連接數量。
這樣的話會有什麼問題?
一方面,連接拓撲關係很複雜。一種治標不治本的方法是抬高添加新進程的成本,比如如非必要上面不會允許你增加額外進程,這樣更深度的解耦合就成了幻想。
另一方面,游戲服務端的應用層與連接層難以分離。舉個例子,在這種設計思路下,兩個進程有沒有連接是一種不確定態,設計的時候覺得沒有,結果某個需求來了,不建立連接就很難實現。這樣對於應用層來說,就需要提供連接的概念,某個進程必須先跟其他進程連接建立成功了,然後才能調用其他進程提供的服務。而實際上,更優雅的設計是應用層完全不關註連接細節,只需要知道其他的進程提供了服務,自己就能獲取到這種服務。
這樣,我們就需要在游戲服務端中提供服務的概念。場景同步服務是一種服務,聊天服務是另一種服務。基於這個思路,我們繼續探討服務應該如何定義,服務有哪些類型,不同類型的服務的消息流應該是怎樣的。
3.2 Service-Oriented游戲服務端
3.2.1 游戲服務端中的服務
定義問題
之前提到,傳統MMO架構隨發展逐漸出現了分拆的需求,最常見的是把聊天邏輯從全局進程中拆出來。
這種拆分的思路是符合service-oriented的發展趨勢的,仔細想想的話,其實聊天服務本來就應該是具體項目無關的。游戲中可以嵌入公司的公共聊天服務,甚至是第三方提供的聊天服務,比如網易最近開推的雲信。http://netease.im/
這樣,聊天服務就是獨立於游戲業務而持久存在的,我們就從代碼復用的層次上升到了服務復用。誠然,公司內不同項目,也可以直接用同一套聊天服務代碼庫,達到代碼級別的復用。但是這樣做最後的結果往往就是,每個團隊都會從更早的團隊拿過來聊天業務代碼,然後自己改造改造,成了完全不同的分支,最後連代碼復用都做不到了。
從另一個思路來講,同一款游戲的不同組伺服器,其實也只需要同樣的一組聊天服務。但是如果按傳統的模式,一組伺服器只能開零或一個聊天伺服器,事實上,有可能某10組伺服器用1個聊天伺服器就夠了,而某1組伺服器用1個聊天伺服器壓力都有些大。
因此,我們可以定義服務的概念。
在明確這個定義之前,你也許註意到了,我在文章的之前部分措詞很混亂——一會兒是XX進程,一會兒是XX伺服器,一會兒又是XX服務。現在,我們統稱為XX服務(或XXservice)。
比如,場景服務與切場景服務,聊天服務,公會服務等等。
服務是什麼?
可以簡單理解為一組方法集合。服務是分散式游戲服務端中的最小實體,一個服務提供了一組確定的、可供調用的方法。
skynet中,一個skynet_context唯一對應一個服務,而一個skynet節點對應一組服務;傳統MMO中,一個進程對應一組服務,但是很難在其中找到“一個”服務的劃分界限。
在確定如何劃分服務之前,首先看看服務的類型。
對於游戲服務端的需求來說,服務可以大概分為兩類:一類是具有獨立命名空間的;一類是在全局命名空間的。
服務的命名空間其實也算是具有游戲開發特色的,雖然不知道最早MMO分服的具體原因是什麼,但是就事實而言,ARPG游戲的分服已經成了策劃需求。後來又是各種渠道服的需求出現,命名空間更是沒辦法丟掉。而且還有一點,就是開發階段本地調試對隔離服務端環境的需求。
舉個例子,之前提到的聊天服務就是一種全局命名空間的服務,而對於分服游戲來說,場景服務就是具有獨立命名空間的服務。而對於手游來說,可以劃分出的服務就更多了。
服務劃分解決了什麼問題?
以進程為單位開發和服務為單位開發是兩種不同的思路。人是有惰性的,如果不是特別必要,上面也沒人強推,那我想大部分程式員還是會把聊天服務實現在全局協調進程里。
服務的概念就是為了提出一種與物理容器無關的抽象。服務可以以某個進程為容器,也可以以某個線程為容器。可以像skynet一樣以一個luaState為容器,也可以像Erlang游戲服務端那樣以一個actor為容器。而一個容器也可以提供多種服務。
註意,這裡提出的服務這種抽象與之前所說的Gate這種基礎設施抽象是不同的。如果將游戲服務端看做一個整體,那麼Gate就是其中的static parts,服務就是其中的dynamic parts。兩者解決的是不同層面的問題。
服務劃分的核心原則是將兩組耦合性較低的邏輯劃分為不同的服務。具體實現中肯定不存在完美的劃分方案,因此作為讓步,只要是互交互不多的邏輯都可以劃分為不同的服務。舉一個簡單的例子就是公會服務v.s.場景服務,兩者的關係並不是特別密切。
引入新的問題
服務劃分的極端是每一個協議包都對應一種服務。事實上,服務的定義本來就是基本隔絕的邏輯集合。如果服務定義得太多,服務間數據交互就會複雜到程式員無法維護的程度。
複雜數據交互的另一方面,是複雜的網路拓撲。基於我們之前的架構,client與服務的通信可以藉助Gate簡化模型,但是服務之間的通信卻需要 O(n^2)的連接數。服務都是dynamic parts,卻對其他服務的有無產生了依賴,而且大部分情況下這種依賴都是雙向的。整個服務端的網路會錯綜複雜。
3.2.2 游戲服務端中的Message Queue
定義問題
我們要解決的最關鍵的問題是:如果服務之間很容易就產生相互依賴,應該如何化簡複雜的網路拓撲。如果說得實際一點,那就是讓伺服器組的啟動流程與關閉流程更加優雅,同時保證正確性。
skynet給我帶來的思路是,服務與服務之間無需保持物理連接,而只需要藉助自己寄宿的skynet與其他服務通信,相當於所有服務間的連接都是抽象的、虛擬的。skynet是整個集群中的static parts,服務作為dynamic parts啟動順序肯定在skynet之後。
skynet可以大大簡化服務間拓撲關係,但是其定位畢竟不在於此,比如,skynet並不做消息的qos保證,skynet也沒有提供各種方便的外圍設施。我們還需要提供更強大語義的基礎設施抽象。
面對這種需求,我們需要一種消息隊列中間件。
生產者消費者一直都是一種比較經典的解耦模型,而消息隊列就是基於這種模型構建的。每個skynet節點本質上就是一個高度精簡的消息隊列,為寄宿的每個服務維護一個私有隊列,對全局隊列中的消息dispatch,驅動寄宿服務。
而我希望的是更純粹的消息隊列中間件,選擇有很多,下麵以RabbitMQ為例簡單介紹。
RabbitMQ提供了消息隊列中間件能提供的所有基本語義,比如消息的ack機制和confirm機制、qos保證、各種pattern的支持、許可權控制、集群、高可用、甚至是現成的圖形化監控等等。接下來的討論會儘量不涉及RabbitMQ具體細節,把它當做一個通常的消息隊列中間件來集成到我們目前為止形成的游戲服務端之中。當然,Gate經過擴展之後也能替代MQ,但是這樣就失去了其作為Gate的意義——Gate更多的是用在性能敏感的場合,比如移動同步,協議太重是沒有必要的。而且,重新造個MQ的輪子,說實話意義真的不大。
MQ解決了什麼問題?
MQ與Gate的定位類似,都是整個生態中的static parts。但是MQ與Gate是兩種不同的基礎設施抽象,提供的語義也不盡相同。
- Gate要解決的問題是以最低的成本構建消息流模型,僅提供傳輸層所能提供的消息送到質量保證(TCP撕UDP暫時領先)。client與Gate的連接斷開,Gate沒有義務再保留連接上下文。Gate其實是在游戲場景同步需求情景下誕生的特殊的MQ,具有一部分MQ的職責,比如發佈訂閱(客戶端發佈,服務端訂閱,帶組播的話還支持message dup),協議高度簡化(對比下AMQP協議的複雜度。。),沒有一些MQ專有的capability(qos保證,消息持久化等)。
- MQ要解決的問題是提供普適的消息隊列抽象。性能不敏感的服務可以依賴MQ構建,將自己的連接維護與會話保持兩塊狀態寄存在MQ上。
這樣,我們的服務端中就出現了兩個static parts——一個是Gate,一個是MQ。Gate與MQ是兩個完全無關的基礎設施,這兩部分先於其他所有dynamic parts啟動、構建。場景服務連接Gate與MQ(前提是確實有其他服務會與場景服務進行通信),聊天服務連接MQ,client連接Gate與MQ。
引入MQ之後的拓撲:
再腦補一下,增加N個節點,就形成了Gate和MQ的雙中心,網路拓撲優雅了很多。
就具體實現來說,之所以選擇RabbitMQ,還因為其對mqtt協議支持的比較好,官網上就有插件下載。mqtt協議可以參考這裡。client走mqtt協議跟MQ通信還是比較輕量級的。
引入新的問題
現在,client或者服務都需要通過不同的協議(Gate、MQ)與其他部分通信。
這樣對應用層開發者來說就是一個負擔。
- 服務端和客戶端,走Gate的流程都是基於私有協議與自有的API。
- 同時,在客戶端,走MQ的流程基於mqtt協議與mqttLib的API;在服務端,走MQ的流程基於amqp協議與rabbitMQ的API。
ps. Gate的私有協議和mqtt、amqp協議下麵會統稱為消息路由協議。
而且不同的API的調用模型都不一樣,因此我們需要一種應用層統一的調用規範。
3.3 游戲服務端中的RPC與Pattern
3.3.1 RPC
定義問題
整理一下現狀。
目前,client可以發送兩類消息:一類交由Gate路由,一類交由MQ路由。service也可以接收兩類消息:一類由Gate路由過來,一類由MQ路由過來。
我們希望的是,應用層只需要關心服務,也就是說發送的消息是希望轉到哪個服務上,以及接收的消息是請求自己提供的哪個服務。
這樣對於應用層來說,其看到的協議應該是統一的,而至於應用層協議的底層協議是Gate的協議還是MQ的協議,由具體的適配器(Adaptor)適配。
這個應用層的協議就是RPC的一部分。
RPC一直都是很有爭議的。一方面,它能讓代碼看起來更優雅,省了不少打解包的重覆代碼;另一方面,程式員能調RPC了,系統就變得很不可控,特別是像某些架構下麵RPC底層會繞很多,最後用的時候完全違背設計本意。
但是總的來說,RPC的優勢還是比較明顯的,畢竟游戲服務端的整體服務定義都是同一個項目組內做的,副作用嚴格可控,很少會出現調用一條RPC要繞很多個節點的情況。
RPC解決什麼樣的問題?
RPC的定位是具體消息路由協議與應用層函數調用的中間層。一個標準的RPC框架要解決兩個問題:
- 第一是協議定義。
- 第二是調用規範的確立。
RPC的協議定義也可以做個劃分:
- 協議中的一部分用來標識一次調用session,可以用來實現RPC的回調,可以用來實現RPC的超時管理等等。
- 另一部分用來標識調用的具體方法。這部分其實跟用不用RPC沒太大關係,因為不用RPC只是打解包的話也是會用一些協議序列化、反序列化協議包的。採用RPC框架的話,就是希望這部分工作儘可能多的自動化。
RPC調用規範的核心設計意圖就是讓應用層程式員調用起來非常自然、不需要有太多包袱(類bigworld架構的rpc設計通常也是這個原則,儘量讓應用層不關註切進程的細節)。調用規範的具體細節就跟語言和平臺相關了。在支持非同步語法的語言/平臺,可以原生集成非同步等待、執行完恢覆上下文繼續執行的語義。在不支持非同步語法的語言/平臺,那就只能callback。如果是不支持將函數作為參數傳遞的語言/平臺,我想你應該已經離現代游戲開發太遠了。
通用的部分確定之後,還得解決特定於具體路由方式的、需要適配的部分。
我將這部分邏輯稱為Adaptor,很好理解,就是RPC到具體消息路由協議、具體消息路由協議到RPC的適配器。
下麵,結合一種具體的RPC實現方式(下文稱為Phial規範),來探討下如何將上面提出的這幾個概念串起來。
先通過一個大概的流程來釐清一次RPC流程中涉及的所有角色。
RPC既然作為一次遠程過程調用,那麼,對於調用方來說,其調用的是一個跟普通函數很像的函數(有可能表現為一個非同步函數,也有可能表現為一個同步函數);對於被調用方來說,其被調用的就真的是自己的一個函數了。
整個的pipeline也很清晰:
- 調用方調用某個服務的某個函數,RPC層會根據之前說的RPC層協議將調用信息(invokeId、方法id、參數等)打包,並將打包的消息和函數對應服務的路由規則告訴路由適配層,路由適配層根據路由規則給打包消息加個消息頭,然後傳給路由層(具體的Gate路由或MQ路由)。
- 路由層將消息路由到對應節點,該節點上的路由適配層解出消息頭和打包消息,根據消息頭確定被請求服務,並這些信息傳給RPC層,RPC層解打包消息得到調用信息,然後做一次dispatch,被調用方的一開始註冊進來的對應函數就會被回調到了。
在這個過程中,我們稱調用方可以調用的是服務的delegate(可以類比為Stub),被調用方註冊進來的是服務的implement(可以類比為Skeleton)。路由適配層就是Adaptor。可以基於不同類型的Adaptor構造服務的delegate。服務的implement也可以註冊在不同的Adaptor上。不同的Adaptor只需要針對RPC層提供同樣的介面,讓RPC層可以發送打包消息和服務特定的路由規則,可以註冊implement即可保證RPC層與Adaptor層是完全無關的。
我們在示例中實現了多種Adaptor,目前為止涉及到的有MqttAdaptor、GateAdaptor、AmqpAdaptor。
除了這整個的數據流之外,示例中還包裝了兩種非同步調用與回調形式。
- 一種是針對.Net 2.0的callback模式;
- 一種是針對.Net 4.5的Task await/async模式。
第一種專門針對不支持.Net 4.5的平臺,比如Unity。但是只要針對這種形式稍加擴展,也能支持.Net 2.0的yield語義,實現簡單協程。關於.Net 2.0中的協程實現,可以參考這裡。
兩種非同步方式實現回調的原理是相同的,都是本地hold住調用上下文,等回包的時候檢查即可。
這樣,在支持.Net 4.5的平臺,一個穿插了RPC調用的函數就可以寫成這個樣子:
1 int a = GetNumber(0); 2 int b = await SceneToSceneMgrService.GetNumber(a); 3 int c = b;View Code
在Unity中的腳本邏輯,可以這樣調用RPC:
1 service.AskXXX(x, y).Callback = 2 operation => 3 { 4 if (operation.IsComplete) 5 { 6 var ret = operation.Result; 7 } 8 };View Code
關於一些細節的說明
- 我在之前的流程裡面特意沒有說明打包協議,打包協議選擇很多,比如msgpack、bson、pb、pbc等等。示例實現中採用的是一種順序的二進位打解包機制,缺點就是沒辦法做版本相容,優點就是實現起來簡單速度快。當然換打包協議也是很容易的。示例項目後續會增加對多種打包協議的支持。
- 由於這個RPC框架主要是針對unity游戲,客戶端部分與服務端部分的平臺本質是不同的,客戶端以.Net 2.0為基礎,服務端以.Net 4.5為基礎。相關的服務定義文件也都隔離成了兩個庫,既減少了對客戶端的協議暴露,又可以保證客戶端依賴庫的體積最小。
- Adaptor的介面設計。Adaptor是為RPC層服務的,因此不同的Adaptor所需要實現的介面只需要面向RPC層保持一致。Adaptor需要針對delegate與implement提供不同的抽象意義。對於implement來說,Adaptor是一個持續產出消息的流;對於delegate來說,Adaptor是一個可以接受消息的傳輸器。應用層對自己掌握的Adaptor是知情的,因此Adaptor可以提供特化的介面,比如client需要的所有Adaptor都需要額外提供Poll介面,場景服務需要的GateAdaptor也需要Poll介面。
引入新的問題
有了RPC之後,我們可以在應用層以統一的形式進行服務請求。
但是這樣還不夠——我們目前所提的RPC就是普通的方法調用,雖然對應用層完全隱藏了協議或者其他中間件的細節,但是這樣一來這些中間件的強大特性我們也就無法利用了。
還應該有另一種與RPC平行的抽象來特化RPC的形式,這種抽象與RPC共同組成了一種游戲開發規範。
3.3.2 RPC、Pattern與規範
定義問題
由於不同的中間件解決問題的方式不同,因此我們沒辦法在應用層用統一的形式引用不同的中間件。因此,我們可以針對游戲開發中的一些比較經典的消息pipeline,定義pattern。然後,用pattern與RPC共同描述服務應該如何聲明,如何被調用。
Pattern解決了什麼問題
- pattern規定了客戶端與服務、服務與服務的有限種交互形式。
- pattern解決了之前我們只能靠感覺確定服務應該走哪種基礎設施抽象的問題。
不同的基礎設施抽象可以實現不同的pattern子集,如果需要新增加一類基礎設施,我們可以看它的功能分別可以映射到哪幾種pattern上,這樣就能直接集成到Phial規範中。
下麵,就針對游戲服務端的常見需求,定義幾種pattern。
三種通信情景:
- client -> server
最簡單的pattern是ask,也就是向服務發起一次非同步調用,然後client不關註服務的處理結果就直接進行後續的邏輯。最常見的就是移動請求。
還有一種是傳統MMO中不太重視,而非同步交互手游反倒從web引入的request。client向服務發起一次非同步調用,但是會等到服務處理結果返回(或超時)才進行後續的邏輯。例子比較多,比如一次抽卡或者一次非同步PVP。
- server -> client
與ask對應的是sync,是服務進行一次無源的對client的impl調用,client無條件執行impl邏輯。sync需要指明被調用方。這種最常見的是移動同步。有一點需要註意,示例中實現了一種形式比較醜陋的組播sync,依賴了Gate的私有協議,也就是forward指定一個int值。這個之後會做調整。
與request對應的是reply。這個相當於是處理一次request然後直接返回一個值,沒什麼特別之處。
- server -> server
最常見的就是invoke,相當於一次希望返回值的遠程調用。例子有很多,適用於任意兩個服務間的通信需求。
還有一種解耦利器我稱之為notify。當然本質上其實就是pub-sub,消息會在中間件上dup。應用情景是消息提供者/事件源只管raise
event,而不關註event是否被處理、event後續會被路由到哪裡。在中間件上,只要有服務實現了該notify service的impl,就能得到通知;如果沒有任何節點提供對該服務的impl,就相當於消息被推到了sink。應用情景是可以將玩家行為log以及各種監控、統計系統邏輯從業務代碼中剝離出來,事件源觸發的邏輯只有一處,而處理的邏輯可以分散在其他監控進程中,不需要增加一種監控就得在每個事件源都對應插一行代碼。
Gate和MQ實現了不同的pattern集合。當然,正如之前所說,Gate本質上也是一種MQ,但是由於我們對這兩種基礎設施抽象的定位不同,所以在實現各自的Adaptor的時候也限定了各自支持的pattern。比如,Gate不能支持notify,MQ不能支持ask-sync。
我在示例實現中沒有加入MQ對客戶端組播的支持。主要原因是考慮到client是通過MQTT協議跟MQ通信,相當於組維護是client發起的。對於聊天這種的可能還好,對於其他的可能會有隱患。
引入新的問題
到目前為止,我們總結出瞭如下幾種與游戲服務端有關的消息pipeline:
- client -> Gate -> service
- service -> Gate -> client
- service -> Gate -> client*
- client -> MQ -> service
- service -> MQ -> client
- service -> MQ -> service
- service -> MQ -> service*
這基本上已經能涵蓋游戲中的大部分需求了,因此我們對消息流的討論就到此為止。
接下來討論游戲世界的狀態維護。
維護游戲世界狀態的職責同樣由一種服務負責,這種服務下麵稱為數據服務。
但是數據服務所說的服務與之前所提的作為dynamic parts的Phial服務不太相同,實際上是一些基礎設施抽象和Phial服務的組合。
有了數據服務,我們還可以更加明確client與service走Gate與MQ究竟有什麼本質區別。
4 游戲世界狀態的維護方式
4.1數據服務的定位
游戲世界的狀態可以簡單分為兩個部分,一部分是需要存檔的,比如玩家數據;一部分是不需要存檔的,比如場景狀態。
對於訪問較頻繁的部分,比如場景狀態,會維護成純記憶體數據;對於訪問較不頻繁的部分,比如玩家存檔,就可以考慮維護在第三方。這個第三方,就是數據服務。
數據服務與之前所提到的場景服務、IM服務等都屬於應用層的概念。數據服務通常也會依賴於一種基礎設施抽象,那就是緩存。
4.1.1 傳統架構中的數據服務
傳統MMO架構中,數據服務的概念非常模糊。
我們還是先通過回顧發展歷史的形式來釐清數據服務的定義。回到場景進程的發展階段,玩家狀態是記憶體中的數據,但是伺服器不會一直開著,因此就有了存檔(文件或db)需求。但是隨著業務變複雜,存檔邏輯需要數據層暴露越來越多的存儲API細節,非常難擴展。因此發展出了Db代理進程,場景進程直接將存檔推給Db代理進程,由Db代理進程定期存檔。
這樣,存儲API的細節在Db代理進程內部閉合,游戲邏輯無須再關註。場景進程只需要通過協議封包或者RPC的形式與Db代理進程交互,其他的就不用管了。
Db代理進程由於是定期存檔,因此它相當於維護了玩家存檔的緩存。這個時候,Db代理進程就具有了數據服務的雛形。
跟之前的討論一樣,我在這裡又要開始批判一番了。
很多團隊至今,新立項的項目都仍然採用這種Db代理進程。雖然確實可以用來滿足一定程度的需求,但是,存在幾個致命問題。
- 第一,Db代理進程讓整個團隊的代碼復用級別保持在copy-paste層面。玩家存檔一定是項目特定的,而採用Db代理進程的團隊,通常並不會將Db代理進程設計成普適、通用的,畢竟對於他們來說,Db代理進程是場景進程和存檔之間的唯一中間層。舉個例子,Db代理進程提供一個LoadPlayer的RPC介面,那麼,介面實現就一定是具體游戲相關的。
- 第二,Db代理進程嚴重耦合了兩個概念:一個是面向游戲邏輯的存儲API;一個是數據緩存。數據緩存本質上是一種新的基礎設施抽象,kv發展了這麼多年,已經涌現出無數高度成熟的工業級緩存基礎設施,居然還有新立項游戲對此後知後覺。殊不知,自己對Db代理進程再怎麼做擴展,也不過是在feature set上逐漸接近成熟的KV,但是在可用性上就是玩具和工業級生產資料的差距。舉個最簡單的例子,有多少團隊的Db代理進程能提供一個規範化的容忍多少秒掉線的保證?
- 第三,Db代理進程在分區分服架構下通常是一區一個的,一個很重要的原因就是Db代理進程通常是自己YY寫出來的,很少能夠解決擴容問題。如果多服共用一個Db代理進程,全局單點給系統增加不穩定性的問題暫且按下不表,負載早就撐爆了。但是只是負責緩存玩家存檔以及將存檔存檔,這跟之前討論過的全局IM服務定位非常類似,又有什麼必要分區分服?
我們可以構建一個數據服務解決這些問題。至於依賴的具體緩存基礎設施,我之後會以redis為例。
redis相比於傳統的KV比如memcache、tc,具有不同的設計理念,redis的定位是一種數據結構伺服器。游戲服務端開發可以拿redis當緩存用,也可以直接當一個資料庫用。
數據服務解決了什麼問題
數據服務首先要解決的就是玩家存檔問題。redis作為一個高性能緩存基礎設施,可以滿足邏輯層的存檔需求。同時還可以實現額外的落地服務,比如將redis中的數據定期存回mysql。之所以這樣做,一方面是因為redis的定位是高性能緩存設施,那就不希望它被rdb、aofrewrite機制拖慢表現,或者卡IO;另一方面是對於一些數據分析系統,用SQL來描述數據查詢需求更合適,如果只用redis,還得單獨開發查詢工具,得不償失。
數據服務其次要解決的問題是可以做到服務級別的復用。這一點我們可以藉助企業應用開發中的ORM來設計一套對象-kv-關係映射。也就是數據服務是統一的,而不同的業務可以用不同的數據結構描述自己的領域模型,然後數據服務的配套工具會自動生成數據訪問層API、redis中cache關係以及mysql中的table schema。也就是說,同樣的數據服務,我在項目A中引用並定義了Player結構,就會自動生成LoadPlayer的API;在項目B中定義User同理生成LoadUser的API。
這兩個問題是比較容易解決的,最關鍵的還是一個思路的轉換。
下麵看一種non-trivial的實現。Phial中的DataAccess部分,Phial的Model代碼生成器。
實際上,數據服務除去緩存基礎設施的部分,都屬於外圍機制。在有些設計中,我們可以看到還是存在緩存服務與邏輯服務的中間層。這種中間層的單點問題很容易解決——只要不同的邏輯服務訪問不同的中間層節點即可。中間層的意義通常是進行RPC到具體緩存協議API的轉換,在我的實現中,由於已經有了數據訪問API的自動生成,因此沒有這種中間層存在的必要。所有需要訪問數據服務的邏輯服務都可以直接通過數據訪問API訪問。
其中還有幾點細節:
- 數據訪問層API的調用規範與RPC的調用規範保持了統一,都是基於async/await模式。
- 通過數據服務對任意存檔進行增加或修改都會記錄一個job,由落地服務定期檢查job進行落地。
引入新的問題
目前仍然遺留了幾個問題:
- redis單實例的性能確實很強悍,但是如果全區全服只開一個redis實例確實是存在問題的,這個問題需要解決。
- 數據服務對於傳統MMO架構來說可以無縫替換掉醜陋的Db代理進程,但是,既然數據服務已經能提供抽象程度如此高的存儲介面,那是否還可以應用在其他地方?
4.1.2 無狀態服務中數據服務的定位
定義問題
之前提到過,游戲世界的狀態除了需要存檔的玩家數據,還有一部分是不需要存檔的邏輯服務的狀態。
數據服務如果只是用來替代MMO中的Db代理進程的,那麼它的全部職責就僅僅是為需要存檔的數據提供服務。從更高的抽象層次來看的話,數據服務相當於是維護了client在服務端的狀態。
但是,數據服務提供了更強大的抽象能力。現在數據服務的API結構是任意定製的、code first,而且數據服務依賴的基礎設施——redis又被證明非常強大,不僅僅是性能極佳,而且提供了多種數據結構抽象。那麼,數據服務是否可以維護其他服務的狀態?
在web開發中,用緩存維護服務狀態是一種很常規的開發思路。而在游戲服務端開發中,由於場景服務的存在,這種思路通常並不靠譜。
為什麼要用緩存維護服務狀態?
考慮這樣一個問題:如果服務的狀態維護在服務進程中,那麼服務進程掛掉,狀態就不存在了。而對於我們來說,服務的狀態是比服務進程本身更加重要的——因為進程掛了可以趕緊重啟,哪怕耽誤個1、2s,但是狀態沒了卻意味著這個服務在整個分散式服務端中所處的全局一致性已經不正確了,即使瞬間就重啟好了也沒用。
那麼為了讓服務進程掛掉時不會導致服務狀態丟掉,只要分離服務進程的生命周期和服務狀態的生命周期就可以了。
將進程和狀態的生命周期分離帶來的另一個好處就是讓這類服務的橫向擴展成本降到最低。
比較簡單的分離方法是將服務狀態維護在共用記憶體里——事實上很多項目也確實是這樣做的。但是這種做法擴展性不強,比如很難跨物理機,而且共用記憶體就這樣一個文件安全性很難保障。
我們可以將服務狀態存放在外部設施中,比如數據服務。
這種可以將狀態存放在外部設施的服務就是無狀態服務(stateless
service)。而與之對應的,場景服務這種狀態需要在進程內維護的就是有狀態服務(stateful
service)。
有時候跟只接觸過游戲服務端開發的業務狗談起無狀態服務,對方竟然會產生 一種“無狀態服務是為瞭解決游戲斷線重連的吧”這種論點,真的很哭笑不得。斷線重連在游戲開發中固然是大坑之一,但是解決方案從來都跟有無狀態毫無關係, 無狀態服務畢竟是服務而不是客戶端。如果真的能實現一個無狀態游戲客戶端,那真的是能直接解決坑人無數的斷線重連問題。
無狀態游戲客戶端意味著網路通信的成本跟記憶體數據訪問的成本一樣低——這當然是不可能實現的。
無狀態服務就是為了scalability而出現的,無狀態服務橫向擴展的能力相比於有狀態服務大大增強,同時實現負載均衡的成本又遠低於有狀態服務。
分散式系統中有一個基本的CAP原理,也就是一致性C、響應性能A、分區容錯P,無法三者兼顧。無狀態服務更傾向於CP,有狀態服務更傾向於AP。但是要補充一點,有狀態服務的P與無狀態服務的P所能達到的程度是不一樣的,後者是真的容錯,前者只能做到不把雞蛋放在一個籃子里。
兩種服務的設計意圖不同。無狀態服務的所有狀態訪問與修改都增加了內網時延,這對於場景服務這種性能優先的服務是不可忍受的。而有狀態服務非常適合場景同步與交互這種數據密集的情景,一方面是數據交互的延遲僅僅是進程內方法調用的開銷,另一方面由於數據局部性原理,對同樣數據的訪問非常快。
既然設計意圖本來就是不同的,我們這一節就只討論數據服務與無狀態服務的關係。
游戲中可以拆分為無狀態服務的業務需求其實有很多,基本上所有服務間交互需求都可以實現為無狀態服務。比如切場景服務,因為切場景的請求是有限的,對時延的要求也不會特別高,同理的還有分配房間服務;或者是面向客戶端的IM服務、拍賣行服務等等。
數據服務對於無狀態服務來說,解決了什麼問題?
簡單來說,就是轉移了無狀態服務的狀態維護成本,同時讓