遊戲服務端究竟解決了什麼問題?
原文出處: fingerpass歡迎分享原創到伯樂頭條
1.寫在前面
既然是遊戲服務端程序員,那博客里至少還是得有一篇跟遊戲服務端有關的文章,今天文章主題就關於遊戲服務端。
寫這篇博客之前也挺糾結的,一方面是因為遊戲服務端其實不論架構上還是具體一些邏輯模塊的構建,都屬於非常成熟的技術,舉個簡單的例子,像端游的多zone/scene/game進程+單全局進程架構,網上隨便一搜能搜出來幾十篇內容差不多的。另一方面是因為中國特色MMO基本上把服務端程序員整成了業務邏輯狗,很多明星團隊的業務狗基本上從入職第一天開始就成天寫lua、寫python,純寫lua/python,你是完全無法辨別一個程序員的vision強弱區別的,結果論資排輩導致vision弱的上去了。(也許vision強的出去創業了?)你就會發現,遊戲服務端的話語權到底是被誰佔據了。
在我看來,遊戲服務端程序員容易陷入兩個誤區:
第一,遊戲服務端實際上要解決的並不是性能問題。一方面,即使是千人同屏的端游(姑且不論這千人同屏是不是一個中國特色的偽需求,反正我是沒法將千人同屏跟遊戲樂趣聯繫在一起的),其服務端如果進程劃分得當,一個場景進程也至多只有千級別entity的壓力,性能問題退化為了邏輯狗的業務素養問題。另一方面,現在端游MOBA和手游時代,開房間式場景同步已經成為主流,各種邏輯狗進化來的資深人士不需要也沒必要將性能掛在嘴邊了。
第二,大部分遊戲服務端所謂框架的定位有誤。服務端框架的設計有好有壞,判斷一個設計好不好沒有普適統一的標準,但是判斷一個設計爛不爛一定是存在一個標準線的。簡單列舉幾種爛設計:
爛設計基礎版本。幫你定義好框架中的幾種角色,你要麼全盤接受,要麼全不接受,不存在中間狀態。但是,提供一種簡單的通信機制,以及外部與框架通信的clientLib。或者能讓你定製開發其中一種角色,可以寫外部driver。這樣,雖然架構丑一點,至少還能提供一定程度的擴展性。
爛設計進階版本。除了滿足基礎版本的定義之外,還具有一些額外的爛特點:框架中的角色定義的特別二逼,舉個例子,基礎版本的爛設計在角色定義上可能只是大概區分了Db代理進程、Gate進程、邏輯進程,但是進階版本會對邏輯進程進行區分,定義了不同的邏輯進程角色。這意味著什麼?意味著我想寫一個簡單的單邏輯進程遊戲是沒辦法用這個框架的,因為框架默認就集成進來了一堆莫名其妙的東西。更有甚者,我想要添加一種角色,是需要動手去改框架的。
說實話,正是由於這類設計的存在,我在看到類似於「遊戲服務端技術含量不高」這類論斷的時候,總感覺辯無可辯,因為就這兩種設計而言,我甚至除了代碼邏輯複雜度之外看不到跟本科畢設級別的遊戲伺服器有什麼區別。
不知道算是不幸還是幸運,前段時間親眼目睹了上述提到的某種設計的從無到有的過程。當然,今天寫此文的目的不是為了將這種設計批判一番,每種設計的誕生都是與各種因素相關的,我們不能站在上帝視角去評判這個過程。今天寫此文,是希望對自己這整整一年半的遊戲服務端編碼歷程中的一些所思所惑做個整理,希望能帶各位看官從另一個思路看遊戲服務端。
2.遊戲服務端究竟解決了什麼問題?從定義問題開始,簡單直接地說,一套遊戲服務端開發框架應該具有下面兩種能力:
下面就從這兩點來展開這篇文章。
3 消息pipeline3.1 經典消息pipeline3.1.1 場景同步當討論到遊戲服務端的時候,我們首先想到的會是什麼?要回答這個問題,我們需要從遊戲服務端的需求起源說起。
定義問題
遊戲對服務端的需求起源應該有兩個:
第一種需求長盛不衰,一方面是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實現場景同步?
拓撲結構是這樣的(之後的所有圖片連接箭頭的意思表示箭頭指向的對於箭頭起源的來說是靜態的):
場景同步有兩個需求:
要做到前者,最理想的情況就是由遊戲程序員把控消息流的整套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模型就可以了。
現在,簡單介紹一種網路庫實現。
具體代碼不再在博客里貼了。請參考:Network
引入新的問題
如果類比馬斯洛需求中的層次,有了網路庫,我們只能算是解決了生理需求:可以聯網。但是後面還有一系列的複雜問題。
最先碰到的問題就是,玩家數量增加,一個進程扛不住了。那麼就需要多個進程,每個進程服務一定數量的玩家。
但是,給定任意兩個玩家,他們總有可能有交互的需求。
對於交互需求,比較直觀的解決方案是,讓兩個玩家在各自的進程中跨進程交互。但是這就成了一個分散式一致性問題——兩個進程中兩個玩家的狀態需要保持一致。至於為什麼一開始沒人這樣做,我只能理解為,遊戲程序員的計算機科學素養中位程度應該解決不了這麼複雜的問題。
因此比較流行的是一種簡單一些的方案。場景交互的話,就限定兩個玩家必須在同一場景(進程),比如攻擊。其他交互的話,就藉助第三方的協調者來做,比如公會相關的通常會走一個全局伺服器等等。
這樣,服務端就由之前的單場景進程變為了多場景進程+協調進程。新的問題出現了:
玩家需要與服務端保持多少條連接?
一種方法是保持O(n)條連接,既不環保,擴展性又差,可以直接pass掉。
那麼就只能保持O(1)條連接,如此的話,如何確定玩家正與哪個服務端進程通信?
要解決這個問題,我們只能引入新的抽象。
3.1.2 Gate定義問題
整理下我們的需求:
要解決這些需求,我們需要引入一種反向代理(reverse proxy)中間件。
反向代理是服務端開發中的一種常見基礎設施抽象(infrastructure abstraction),概念很簡單,簡單說就是內網進程不是藉助這種proxy訪問外部,而是被動地掛在proxy上,等外部通過這種proxy訪問內部。
更具體地說,反向代理就是這樣一種server:它接受clients連接,並且會將client的上行包轉發給後端具體的服務端進程。
很多年前linux剛支持epoll的時候,流行一個c10k的概念,解決c10k問題的核心就是藉助性能不錯的反向代理中間件。
遊戲開發中,這種組件的名字也比較通用,通常叫Gate。
Gate解決了什麼問題
僅就這兩點而言,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模型其實包括兩個組件:
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層。
可以說,在這種方案成熟之後,程序員之間開始流行「遊戲開發技術封閉」這種說法了。
為什麼?
舉一個簡單的例子,大概描述下現在一個遊戲項目的服務端生命周期狀況:
結果就是,產出了幾個玩具水平的伺服器進程。要非得說是工業級或者生產環境級別的吧,也算是,畢竟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是兩種不同的基礎設施抽象,提供的語義也不盡相同。
這樣,我們的服務端中就出現了兩個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)與其他部分通信。
這樣對應用層開發者來說就是一個負擔。
ps. Gate的私有協議和mqtt、amqp協議下面會統稱為消息路由協議。
而且不同的API的調用模型都不一樣,因此我們需要一種應用層統一的調用規範。
3.3 遊戲服務端中的RPC與Pattern3.3.1 RPC定義問題
整理一下現狀。
目前,client可以發送兩類消息:一類交由Gate路由,一類交由MQ路由。service也可以接收兩類消息:一類由Gate路由過來,一類由MQ路由過來。
我們希望的是,應用層只需要關心服務,也就是說發送的消息是希望轉到哪個服務上,以及接收的消息是請求自己提供的哪個服務。這樣對於應用層來說,其看到的協議應該是統一的,而至於應用層協議的底層協議是Gate的協議還是MQ的協議,由具體的適配器(Adaptor)適配。
這個應用層的協議就是RPC的一部分。RPC一直都是很有爭議的。一方面,它能讓代碼看起來更優雅,省了不少打解包的重複代碼;另一方面,程序員能調RPC了,系統就變得很不可控,特別是像某些架構下面RPC底層會繞很多,最後用的時候完全違背設計本意。
但是總的來說,RPC的優勢還是比較明顯的,畢竟遊戲服務端的整體服務定義都是同一個項目組內做的,副作用嚴格可控,很少會出現調用一條RPC要繞很多個節點的情況。
RPC解決什麼樣的問題?
RPC的定位是具體消息路由協議與應用層函數調用的中間層。一個標準的RPC框架要解決兩個問題:
RPC的協議定義也可以做個劃分:
RPC調用規範的核心設計意圖就是讓應用層程序員調用起來非常自然、不需要有太多包袱(類bigworld架構的rpc設計通常也是這個原則,盡量讓應用層不關注切進程的細節)。調用規範的具體細節就跟語言和平台相關了。在支持非同步語法的語言/平台,可以原生集成非同步等待、執行完恢復上下文繼續執行的語義。在不支持非同步語法的語言/平台,那就只能callback。如果是不支持將函數作為參數傳遞的語言/平台,我想你應該已經離現代遊戲開發太遠了。
通用的部分確定之後,還得解決特定於具體路由方式的、需要適配的部分。
我將這部分邏輯稱為Adaptor,很好理解,就是RPC到具體消息路由協議、具體消息路由協議到RPC的適配器。
下面,結合一種具體的RPC實現方式(下文稱為Phial規範),來探討下如何將上面提出的這幾個概念串起來。
先通過一個大概的流程來釐清一次RPC流程中涉及的所有角色。
RPC既然作為一次遠程過程調用,那麼,對於調用方來說,其調用的是一個跟普通函數很像的函數(有可能表現為一個非同步函數,也有可能表現為一個同步函數);對於被調用方來說,其被調用的就真的是自己的一個函數了。
整個的pipeline也很清晰:
在這個過程中,我們稱調用方可以調用的是服務的delegate(可以類比為Stub),被調用方註冊進來的是服務的implement(可以類比為Skeleton)。路由適配層就是Adaptor。可以基於不同類型的Adaptor構造服務的delegate。服務的implement也可以註冊在不同的Adaptor上。不同的Adaptor只需要針對RPC層提供同樣的介面,讓RPC層可以發送打包消息和服務特定的路由規則,可以註冊implement即可保證RPC層與Adaptor層是完全無關的。
我們在示例中實現了多種Adaptor,目前為止涉及到的有MqttAdaptor、GateAdaptor、AmqpAdaptor。
除了這整個的數據流之外,示例中還包裝了兩種非同步調用與回調形式。
第一種專門針對不支持.Net 4.5的平台,比如Unity。但是只要針對這種形式稍加擴展,也能支持.Net 2.0的yield語義,實現簡單協程。關於.Net 2.0中的協程實現,可以參考這裡。
兩種非同步方式實現回調的原理是相同的,都是本地hold住調用上下文,等回包的時候檢查即可。
這樣,在支持.Net 4.5的平台,一個穿插了RPC調用的函數就可以寫成這個樣子:
123 | int a = GetNumber(0); int b = await SceneToSceneMgrService.GetNumber(a); int c = b; |
在Unity中的腳本邏輯,可以這樣調用RPC:
12345678 | service.AskXXX(x, y).Callback = operation => {
if (operation.IsComplete)
{
var ret = operation.Result;
} }; |
關於一些細節的說明
引入新的問題
有了RPC之後,我們可以在應用層以統一的形式進行服務請求。
但是這樣還不夠——我們目前所提的RPC就是普通的方法調用,雖然對應用層完全隱藏了協議或者其他中間件的細節,但是這樣一來這些中間件的強大特性我們也就無法利用了。
還應該有另一種與RPC平行的抽象來特化RPC的形式,這種抽象與RPC共同組成了一種遊戲開發規範。
3.3.2 RPC、Pattern與規範定義問題
由於不同的中間件解決問題的方式不同,因此我們沒辦法在應用層用統一的形式引用不同的中間件。因此,我們可以針對遊戲開發中的一些比較經典的消息pipeline,定義pattern。然後,用pattern與RPC共同描述服務應該如何聲明,如何被調用。
Pattern解決了什麼問題
不同的基礎設施抽象可以實現不同的pattern子集,如果需要新增加一類基礎設施,我們可以看它的功能分別可以映射到哪幾種pattern上,這樣就能直接集成到Phial規範中。
下面,就針對遊戲服務端的常見需求,定義幾種pattern。
三種通信情景:
最簡單的pattern是ask,也就是向服務發起一次非同步調用,然後client不關注服務的處理結果就直接進行後續的邏輯。最常見的就是移動請求。還有一種是傳統MMO中不太重視,而非同步交互手游反倒從web引入的request。client向服務發起一次非同步調用,但是會等到服務處理結果返回(或超時)才進行後續的邏輯。例子比較多,比如一次抽卡或者一次非同步PVP。
與ask對應的是sync,是服務進行一次無源的對client的impl調用,client無條件執行impl邏輯。sync需要指明被調用方。這種最常見的是移動同步。有一點需要注意,示例中實現了一種形式比較醜陋的組播sync,依賴了Gate的私有協議,也就是forward指定一個int值。這個之後會做調整。
與request對應的是reply。這個相當於是處理一次request然後直接返回一個值,沒什麼特別之處。
最常見的就是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:
這基本上已經能涵蓋遊戲中的大部分需求了,因此我們對消息流的討論就到此為止。
接下來討論遊戲世界的狀態維護。
維護遊戲世界狀態的職責同樣由一種服務負責,這種服務下面稱為數據服務。
但是數據服務所說的服務與之前所提的作為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代理進程。雖然確實可以用來滿足一定程度的需求,但是,存在幾個致命問題。
我們可以構建一個數據服務解決這些問題。至於依賴的具體緩存基礎設施,我之後會以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訪問。
其中還有幾點細節:
引入新的問題
目前仍然遺留了幾個問題:
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服務、拍賣行服務等等。
數據服務對於無狀態服務來說,解決了什麼問題?
簡單來說,就是轉移了無狀態服務的狀態維護成本,同時讓無狀態服務具有了橫向擴展的能力。因為狀態維護在數據服務中,所以無狀態服務開多少個都無所謂。因此無狀態服務非常適合計算密集的業務需求。
你可能覺得我之前在服務劃分一節之後直接提出要引入MQ有些突兀,實際上,服務劃分要解決的根本問題就是讓程序員能清楚自己定義每種服務的意圖是什麼,哪一種服務更適合Request-Reply,哪一種服務更適合Ask-Sync。
假設策劃對遊戲沒有分服的需求,理論上講,有節操的程序是不應該以「其他遊戲就這樣做的」或「做不到」之類的借口搪塞。每一種服務都由分散式的多個節點共同提供服務,如果服務的消息流更適合Request-Reply pattern,那麼實現為無狀態服務就更合適,原因有二:
針對第二點,可能需要稍微介紹下rabbitMQ。rabbitMQ中有exchange(交換機)、queue、binding(綁定規則)三個主要概念。其中,exchange是對應生產者的,queue是對應消費者的,binding則是描述消息從exchange到queue的路由關係的。exchange有兩種常用類型direct、topic。其中direct exchange接收到的消息是不會dup的,而topic exchange則會將接收到的消息根據匹配的binding確定要dup到哪個target queue上。
這樣,對於無狀態服務,比如同一命名空間下的切場景服務,可以共用同一個queue,然後client發來的消息走direct exchange,就可以在MQ層面做到round-robin,將消息輪流分配到不同的切場景服務上。而且無狀態服務本質上是沒有擴容成本的,波峰就多開,波谷就少開。
程序員負責為不同服務規劃不同的橫向擴展方式。比如類似公會服務這種走MQ的,橫向擴展的觸發條件就是現在請求數量級或者是節點壓力。比如場景服務這種Ask-Sync的,橫向擴展就需要藉助第三方的服務作為仲裁者,而這個仲裁者可以實現為基於MQ的服務。
這裡有個問題需要注意一下。
由於現在同一個client上來的request消息可能由無狀態服務的不同節點處理,那麼就會出現這樣的情況:
- 某個client由於一些原因,快速發了兩個message1、message2。
- message1先到了服務A,服務A去數據服務拉相關數據集合Sa,並進行後續處理。
- 此時message2到了服務B,服務B去數據服務拉相關數據集合Sb,進行後續處理,處理完畢,將結果存回數據服務。
- 然後服務A才處理完,並嘗試將處理結果存回數據服務。
假如Sa與Sb有交集,那就會出現競態條件,如果這時允許服務A存回結果,那數據就有可能存在不一致。
類似的情況還會出現在像率土之濱或者cok這種策略遊戲的大世界刷怪需求中。當然前提是玩家與大地圖上的元素交互和後台刷怪邏輯都是基於無狀態服務做的。
這其實是一個跨進程共享狀態問題,而且是一個高度簡化的版本——因為這個共享狀態只在一個實例上維護。可以引入鎖來解決問題,思路通常有兩個:
最直觀的一種方案是悲觀鎖。也就是如果要進行修改操作,就需要在讀相關數據的時候就都加上鎖,最後寫成功的時候釋放鎖。獲得鎖所有權期間其他impure服務任意讀寫請求都是非法的。
但是,這畢竟不是多線程執行環境,沒有語言或平台幫你做自動鎖釋放的保證。獲取悲觀鎖的服務節點不能保證一定會將鎖釋放掉,拿到鎖之後節點掛掉的可能性非常大。這樣,就需要給悲觀鎖增加超時機制。
第二種方案是樂觀鎖。也就是impure服務可以隨意進行讀請求,讀到的數據會額外帶個版本號,等寫的時候對比版本號,如果一致就可以成功寫回,否則就通知到應用層失敗,由應用層決定後續操作。
帶過期機制的悲觀鎖和樂觀鎖本質上都屬於可搶佔的分散式鎖,相當於是將paxos要解決的問題退化為單Acceptor,因此實現起來非常簡單。可過期的悲觀鎖和樂觀鎖唯一的區別就是前者在申請鎖的時候有可能申請失敗,而後者申請鎖時永遠不會失敗。兩種方案具體的表現優劣跟業務需求有關,不論一開始選擇的是哪一種,都非常容易切換到另一種。
我在示例中實現了一個簡單的樂觀鎖,在提交修改的時候用一個lua腳本做原子檢查就能簡單實現。如果要實現帶過期機制的悲觀鎖,需要保證應用層有簡單的時鐘同步機制,而且在申請鎖的時候也要寫一個lua腳本。
在應用層也做了對應修改,調用數據訪問層API可以按如下這種方式調用。之所以用了RTTI,是考慮到有可能會改成悲觀鎖實現,在Dispose的時候會自動release lock。現在pure服務與impure服務對數據服務調用的介面是不一樣的,我們甚至還可以基於這一點在底層做一些擴展,最典型的比如讀寫分離。當然,這些都是引入主從之後要考慮的問題了。
123456789101112131415161718 | using (var structFstAccesser = await GetStructFstAccesser()) {
using (var structSndAccesser = await GetStructSndAccesser())
{
var fieldFst = await structFstAccesser.LoadFieldFstAsync();
var fieldSnd = await structSndAccesser.LoadFieldSndAsync();
// logic here...
structFstAccesser.UpdateFieldFst(fieldFst);
structSndAccesser.UpdateFieldSnd(fieldSnd);
await SubmitChanges(structFstAccesser, structSndAccesser);
// result handle here
return true ;
} } |
有了這樣一個簡易的鎖機制,我們可以保證單redis實例內的一致性。
引入新的問題
有了無狀態服務的概念,我們的架構中就可以逐步幹掉類似切場景管理這種單點進程。無狀態服務是高可用的,也就是說,任意掛掉一個,仍然能持續提供服務。
整個遊戲服務端理論上應該具有整體持續提供服務的能力。也就是說,隨便掛掉一個節點,不需要停服。場景服務掛掉一個節點,不會影響其他任何服務,只是玩家短期內無法進行場景相關操作了而已。
而我們見過的大多數架構,處處皆單點,這完全不能叫可用的架構。有的時候一個服務端跑的好好的,有人硬是要額外加一個全局單點,而且理由是更容易管理,讓人哭笑不得。分散式系統中動不動就想加單點,這是病,得治。判斷一整個遊戲服務端是否具有可用性很簡單,隨便kill掉一個節點,如果服務端仍然能持續提供服務,即使是部分client受到了影響,也能稱為是可用的。
但是,現在邏輯服務具有可用性了,可是數據服務還沒有具有可用性,數據服務依賴於一個redis實例,這個redis實例反而成為了整個服務端中的單點。
幸好,redis像其他大多數工業級緩存基礎設施一樣,已經提供了足夠用的可用性機制。但是,在討論redis的可用性機制之前,我們先解決一下數據服務的一個遺留問題,那就是如何構建一個可以擴展的全局數據服務。
4.2 數據服務的擴展redis是一種stateful service,繼續應用之前的CAP原則,redis是傾向於AP的。之後我們可以看到,redis的各種擴展,實際上都是基於這個原則來做的。
4.2.1 分片方案定義問題
我們遇到的問題是,如果將數據服務定位為全局服務,那僅用單實例的redis就難以應對多變的負載情況。畢竟redis是單線程的。
從mysql一路用過來的同學這時都會習慣性地水平拆分,redis中也是類似的原理,將整體的數據進行切分,每一部分是一個分片shard,不同的shard維護的key集合是不同的。
那麼,問題的實質就是如何基於多個redis實例設計全局統一的數據服務。同時,有一個約束條件,那就是我們為了性能需要犧牲全局一致性。也就是說,數據服務進行分片擴展的前提是,不提供跨分片事務的保障。redis cluster也沒有提供類似支持,因為分散式事務本來就跟redis的定位是有衝突的。
因此,我們之後的討論會有一個預設前提:不同shard中的數據一定是嚴格隔離的,比如是不同組服的數據,或者是完全不相干的數據。要想實現跨shard的數據交互,必須依賴更上層的協調機制保證,底層不做任何承諾。
這樣,我們的分片數據服務就能通過之前提到的簡易鎖機制提供單片內的一致性保證,而不再提供全局的一致性保證。
基於同樣的原因,我們的分片方案也不會在分片間做類似分散式存儲系統的數據冗餘機制。
分片方案解決了什麼問題
分片需要解決兩個問題:
針對第一個問題,解決方案通常有三:
需求決定解決方案,對於遊戲服務端來說,後兩者的成本太高,而且增加了很多不確定的複雜性,因此現階段這兩種方案並不是合適的選擇。比如gossip protocol,redis cluster現在都不算是release,確實不太適合遊戲服務端。而且,遊戲服務端畢竟不是web服務,通常是可以在設計階段確定每個分片的容量上限的,也不需要太複雜的機制支持。
但是第一種方案的缺點也很明顯,做不到動態增容減容,而且無法高可用。但是如果稍加改造,就足以滿足需求了。
在談具體的改造措施之前,先看之前提出的第二個問題。
第二個問題實際上是從另一種維度看分片,解決方案很多,但是如果從對架構的影響上來看,大概分為兩種:
第一種方案的缺點顯而易見,在整個架構中增加了額外的間接層,pipeline中增加了一趟round-trip。如果是像twemproxy或者Codis這種支持高可用的還好,但是github上隨便一翻還能找到特別多的沒法做到高可用的proxy-based方案,無緣無故多個單點,這樣就完全搞不明白sharding的意義何在了。
第二種方案的缺點就是集群狀態發生變化的時候沒法即時通知到dbClient。
第一種方案,我們其實可以直接pass掉了。因為這種方案本質上還是更適合web開發的。web開發部門眾多,開發數據服務的部門有可能和業務部門相去甚遠,因此需要統一的轉發代理服務。但是遊戲開發不一樣,數據服務邏輯服務都是一幫人開發的,沒什麼增加額外中間層的必要。
那麼,看起來只能選擇第二種方案了。
將presharding與client sharding結合起來後,現在我們的改造成果是:數據服務是全局的,redis可以開多個實例,不相干的數據需要到不同的shard上存取,dbClient掌握這個映射關係。
引入新的問題
目前的方案只能滿足遊戲對數據服務的基本需求。
大部分採用redis的遊戲團隊,一般最終會選定這個方案作為自己的數據服務。後續的擴展其實對他們來說不是不可以做,但是可能有維護上的複雜性與不確定性。今天這篇文章,我就繼續對數據服務做擴展,後面的內容權當拋磚引玉。
現在的這個方案存在兩個問題:
針對第一個問題,處理方式跟proxy-based採用的處理方式沒太大區別,由於目前的數據服務方案比較簡單,採用一致性哈希即可。或者採用一種比較簡單的兩段映射,第一段是靜態的固定哈希,第二段是動態的可配置map。前者通過演算法,後者通過map配置維護的方式,都能最小化影響到的key集合。
而對於第二個問題,實際上就是上一節末提到的數據服務可用性問題。
4.2.2 可用性方案定義問題
討論數據服務的可用性之前,我們首先看redis的可用性。
對於redis來說,可用性的本質是什麼?其實就是redis實例掛掉之後可以有後備節點頂上。
redis通過兩種機制支持這一點。
還是由於CAP原則,redis的replication方案採用的是一種一致性較弱的active-passive方案。也就是master自身維護log,將log向其他slave同步,master掛掉有可能導致部分log丟失,client寫完master即可收到成功返回,是一種非同步replication。這個機制只能解決節點數據冗餘的問題,redis要具有可用性就還得解決redis實例掛掉讓備胎自動頂上的問題,畢竟由人肉去監控master狀態再人肉切換是不現實的。 因此還需要第二種機制。
redis基於自帶的這兩種機制,已經能夠實現一定程度的可用性。那麼接下來,我們來看數據服務如何高可用。
數據服務具有可用性的本質是什麼?除了能實現redis可用性的需求——redis實例數據冗餘、故障自動切換之外,還需要將切換的消息通知到每個dbClient。
由於是redis sentinel負責主從切換,因此最自然的想法就是問sentinel請求當前節點主從連接信息。但是redis sentinel本身也是redis實例,數量也是動態的,redis sentinel的連接信息不僅在配置上成了一個難題,動態更新時也會有各種問題。而且,redis sentinel本質上是整個服務端的static parts(要像dbClient提供服務),但是卻依賴於redis的啟動,並不是特別優雅。另一方面,dbClient要想問redis sentinel要到當前連接信息,只能依賴其內置的pub-sub機制。redis的pub-sub只是一個簡單的消息分發,沒有消息持久化,因此需要輪詢式的請求連接信息模型。
上一節末提到過,要想最小化數據遷移成本可以採用兩段映射或一致性哈希。這時還有另一種可以擴展的思路,如果採用兩段映射,那麼我們可以動態下發第二段的配置數據;如果採用一致性哈希,那麼我們可以動態下發分片的連接信息。這其中的動態,就可以基於新的符合Phial規範的服務來做。而這個通知機制,就非常適合採用Phial中的Notify pattern實現。而且redis sentinel的實現難度比較低,我們完全可以以較低的成本實現一個擴展性更強,定製性更強,還能額外支持分片服務的部分在線數據遷移機制的服務。同時,有一部分我在這篇文章里也沒提過,那就是落地服務所依賴的mysql的可用性保障機制。相比於再開一個額外的mysql高可用組件,倒不如整合到同樣的一個數據服務監控服務中。
這個監控服務就是watcher。由於原理類似,接下來的討論就不再涉及對mysql的監控部分,只針對redis的。
watcher解決了什麼問題?
解決這些問題,watcher的職責就已經達成,我們的數據服務也就更加健壯,可用程度更高。
引入新的問題
但是,如果我們引入了新的服務,那就引入了新的不確定性。如果引入這個服務的同時還要保證數據服務具有可用性,那我們就還得保證這個服務本身是可用的。
先簡單介紹一下redis sentinel的可用性是如何做到的。同時監控同一組主從的sentinel可以有多個,master掛掉的時候,這些sentinel會根據一種raft演算法的工業級實現選舉出leader,演算法流程也不是特別複雜,至少比paxos簡單多了。所有sentinel都是follower,判斷出master客觀下線的sentinel會升級成candidate同時向其他follower拉票,所有follower同一epoch內只能投給第一個向自己拉票的candidate。在具體表現中,通常一兩個epoch就能保證形成多數派,選出leader。有了leader,後面再對redis做SLAVEOF的時候就容易多了。
如果想用watcher取代sentinel,最複雜的實現細節可能就是這部分邏輯了。這部分邏輯說白了就是要在分散式系統中維護一個一致狀態,舉個例子,可以將「誰是leader」這個概念當作一個狀態量,由分散式系統中的身份相等的幾個節點共同維護,既然誰都有可能修改這個變數,那究竟誰的修改才奏效呢?
幸好,針對這種常見的問題情景,我們有現成的基礎設施抽象可以解決。
這種基礎設施就是分散式系統的協調器組件(coordinator),老牌的有zookeeper(zab),新一點的有etcd(raft)。這種組件通常沒有重複開發的必要,像paxos這種演算法理解起來都得老半天,實現起來的細節數量級更是難以想像。因此很多現成的開源項目都是依賴這兩者實現高可用的,比如codis就是用的zk。
zk解決了什麼問題?
就我們的遊戲服務端需求來說,zk可以用來選leader,還可以用來維護dbClient的配置數據——dbClient直接去找zk要數據就行了。
zk的具體原理我就不再介紹了,具體的可以參考lamport的paxos paper,沒時間沒精力的話搜一下看看zk實現原理的博客就行了。
簡單介紹下如何基於zk實現leader election。zk提供了一個類似於os文件系統的目錄結構,目錄結構上的每個節點都有類型的概念同時可以存儲一些數據。zk還提供了一次性觸發的watch機制。leader election就是基於這幾點概念實現的。
假設有某個目錄節點/election,watcher1啟動的時候在這個節點下面創建一個子節點,節點類型是臨時順序節點,也就是說這個節點會隨創建者掛掉而掛掉,順序的意思就是會在節點的名字後面加個數字後綴,唯一標識這個節點在/election的子節點中的id。
一個簡單的方案是我們可以每個watcher都watch /election的所有子節點,然後看自己的id是否是最小的,如果是就說明自己是leader,然後告訴應用層自己是leader,讓應用層進行後續操作就行了。但是這樣會產生驚群效應,因為一個子節點刪除,每個watcher都會收到通知,但是至多一個watcher會從follower變為leader。
優化一些的方案是每個節點都關注比自己小一個排位的節點。這樣如果id最小的節點掛掉之後,id次小的節點會收到通知然後了解到自己成為了leader,避免了驚群效應。
還有一點需要注意的是,臨時順序節點的臨時性體現在一次session而不是一次連接的終止。例如watcher1每次申請節點都叫watcher1,第一次它申請成功的節點全名假設是watcher10002(後面的是zk自動加的序列號),然後下線,watcher10002節點還會存在一段時間,如果這段時間內watcher1再上線,再嘗試創建watcher1就會失敗,然後之前的節點過一會兒就因為session超時而銷毀,這樣就相當於這個watcher1消失了。解決方案有兩個,可以創建節點前先顯式delete一次,也可以通過其他機制保證每次創建節點的名字不同,比如guid。
至於配置下發,就更簡單了。配置變更時直接更新節點數據,就能藉助zk通知到關注的dbClient,這種事件通知機制相比於輪詢請求sentinel要配置數據的機制更加優雅。
我在實現中將zk作為路由協議的一種整合進了Phial規範,這樣基於zk的消息通知可以直接走Phial的RPC協議。
有興趣的同學可以看下我實現的zkAdaptor,leader election的功能作為zkAdaptor的特殊API,watcherService會直接調用。而配置下發直接走了RPC協議,集成在統一的Phial.RPC規範中。zkAdaptor僅支持Phial.RPC中的Notify pattern。
watcher的實現在這裡。
5.總結目前形成的架構以及能做什麼整理下這篇文章到目前為止做了什麼事情:
總結下出現的幾種概念:
到目前為止的拓撲圖:
系統設計中的static parts與dynamic parts
gate/mq/zk/redis/mysql
almost all custom services
這篇文章的靈感起源是the log,看完之後深有感觸。雖然JAVA不是一門好語言,但是JAVA技術棧卻發展得如此優雅。JAVA技術棧上的每一種IA都專註於解決特定的一小塊問題,比如這裡提到的。未來的應用框架開發者,就像是用膠水將這些基礎設施粘合起來。遊戲服務端程序員通常習慣於c++的小圈子,甚至有一種傳教的趨勢宣揚c++才是代表的遊戲服務端的核心技術。有的時候,遊戲程序員需要從c++的小圈子跳出來向外走一走,有可能你就不想再湮沒在繁文縟節中,而是發現更大的世界。
不過話又說回來,不喜歡跳出c++小圈子的遊戲服務端程序員,大部分又都對c++本身其實知之甚少,奉OOP為圭臬,各種虛繼承、多繼承出來的代碼看到想吐。嘗試用模板的各種奇技淫巧把c++寫成haskell的雖然更有跳出c++小圈子的傾向,但是既然都如此用了,又何必拘泥於c++?
其他
我在這篇文章里盡量少的插入代碼,盡量描述遊戲服務端定義問題、解決問題的思路。服務端用C#寫的畢竟是少數,但是有了思路隨便改寫成其他語言都沒問題。
我順便也借著寫這篇博客的機會,整理了下一些小東西放在github上。
比如之前的面向組合子博客提到的代碼生成器組合子,CodeC
比如之前的定時器博客提到的linux內核風格定時器,以及基於定時器寫的example,C#協程,都放在這裡,CoroutineSharp
比如之前的遊戲AI博客提到的行為樹編譯器原型和c# runtime示例,Behaviour
還有學習parsec的一個小結,可以用來parse單個c#文件拿到一些描述信息的,當然純屬學習性質,有這種需求的時候最好優先用反射。cs_file_parser
然後就是跟這篇博客相關的
一個簡單的網路庫,Network;
一個簡單的基於Network的Gate,GateSharp;
規範的整個底層庫,Phial;
為底層庫開發的兩個配套代碼生成器,Phial.CodeGenerator;
示例實現,Phial.Fantasy。
github中的以演示為目的,因此相比於博客,還有不少部分是to be determined(比如詳細的配置流程、MQ的集群化、mysql的故障轉移集成、落地服務的實現細節等等),之後我也會繼續維護。
2 贊 3 收藏 評論你的讚賞是我堅持原創的動力
讚賞共 0 人讚賞推薦閱讀:
※三個錦囊,解決90%讀書低效的問題
※掌握一個流程搞定所有問題
※中青報刊文談超級稻事件:別落井下石,也別只當小問題
※硃砂掌功諮詢問題解答18