天天看點

經典遊戲伺服器端架構概述 (1)

現代電子遊戲,基本上都會使用一定的網絡功能。從驗證正版,到多人互動等等,都需要架設一些專用的伺服器,以及編寫在伺服器上的程式。是以,遊戲伺服器端軟體的架構,本質上也是遊戲伺服器這個特定領域的軟體架構。

軟體架構的分析,可以通過不同的層面入手。比較經典的軟體架構描述,包含了以下幾種架構:

運作時架構——這種架構關心如何解決運作效率問題,通常以程式程序圖、資料流圖為表達方式。在大多數開發團隊的架構設計文檔中,都會包含運作時架構,說明這是一種非常重要的設計方面。這種架構也會顯著的影響軟體代碼的開發效率和部署效率。本文主要讨論的是這種架構。

邏輯架構——這種架構關心軟體代碼之間的關系,主要目的是為了提高軟體應對需求變更的便利性。人們往往會以類圖、子產品圖來表達這種架構。這種架構設計在需要長期營運和重用性高的項目中,有至關重要的作用。因為軟體的可擴充性和可重用度基本是由這個方面的設計決定的。特别是在遊戲領域,需求變更的頻繁程度,在多個網際網路産業領域裡可以說是最高的。本文會涉及一部分這種架構的内容,但不是本文的讨論重點。

實體架構——關心軟體如何部署,以機房、伺服器、網絡裝置為主要描述對象。

資料架構——關心軟體涉及的資料結構的設計,對于資料分析挖掘,多系統協作有較大的意義。

開發架構——關心軟體開發庫之間的關系,以及版本管理、開發工具、編譯建構的設計,主要為了提高多人協作開發,以及複雜軟體庫引用的開發效率。現在流行的內建建構系統就是一種開發架構的理論。

伺服器端軟體的本質,是一個會長期運作的程式,并且它還要服務于多個不定時,不定地點的網絡請求。是以這類軟體的特點是要非常關注穩定性和性能。這類程式如果需要多個協作來提高承載能力,則還要關注部署和擴容的便利性;同時,還需要考慮如何實作某種程度容災需求。由于多程序協同工作,也帶來了開發的複雜度,這也是需要關注的問題。

功能限制,是架構設計決定性因素。一個萬能的架構,必定是無能的架構。一個優秀的架構,則是正好把握了對應業務領域的核心功能産生的。遊戲領域的功能特征,于伺服器端系統來說,非常明顯的表現為幾個功能的需求:

對于遊戲資料和玩家資料的存儲

對玩家用戶端進行資料廣播

把一部分遊戲邏輯在伺服器上運算,便于遊戲更新内容,以及防止外挂。

針對以上的需求特征,在伺服器端軟體開發上,我們往往會關注軟體對電腦記憶體和CPU的使用,以求在特定業務代碼下,能盡量滿足承載量和響應延遲的需求。最基本的做法就是“時空轉換”,用各種緩存的方式來開發程式,以求在CPU時間和記憶體空間上取得合适的平衡。在CPU和記憶體之上,是另外一個限制因素:網卡。網絡帶寬直接限制了伺服器的處理能力,是以遊戲伺服器架構也必定要考慮這個因素。

對于遊戲伺服器架構設計來說,最重要的是利用遊戲産品的需求限制,進而優化出對此特定功能最合适的“時-空”架構。并且最小化對網絡帶寬的占用。

經典遊戲伺服器端架構概述 (1)

[圖:遊戲伺服器的分析模型]

基于上述的分析模型,對于遊戲服務端架構,最重要的三個部分就是,如何使用CPU、記憶體、網卡的設計:

記憶體架構:主要決定伺服器如何使用記憶體,以保證盡量少的記憶體洩漏的可能,以及最大化利用伺服器端記憶體來提高承載量,降低服務延遲。

排程架構:設計如何使用程序、線程、協程這些對于CPU排程的方案。選擇同步、異步等不同的程式設計模型,以提高伺服器的穩定性和承載量。同時也要考慮對于開發帶來的複雜度問題。現在出現的虛拟化技術,如虛拟機、docker、雲伺服器等,都為排程架構提供了更多的選擇。

通信模式:決定使用何種方式通訊。網絡通訊包含有傳輸層的選擇,如TCP/UDP;據表達層的選擇,如定義協定;以及應用層的接口設計,如消息隊列、事件分發、遠端調用等。

本文的讨論,也主要是集中于對以上三個架構的分析。

最早的遊戲伺服器是比較簡單的,如UO《網絡創世紀》的服務端一張3.5寸軟碟就能存下。基本上隻是一個廣播和存儲檔案的伺服器程式。後來由于國内的外挂、盜版流行,各遊戲廠商開始以MUD為模型,建立主要運作邏輯在伺服器端的架構。這種架構在MMORPG類産品的不斷更新中發揚光大,進而出現了以地圖、視野等分布要素設計的分布式遊戲伺服器。而在另外一個領域,休閑遊戲,天然的需要集中超高的線上使用者,是以全區型架構開始出現。現代的遊戲伺服器架構,基本上都希望能結合承載量和擴充性的有點來設計,進而形成了更加豐富多樣的形态。

本文的讨論主要是選取這些比較典型的遊戲伺服器模型,分析其底層各種選擇的優點和缺點,希望能探讨出更具廣泛性,更高開發效率的伺服器模型。

分服模型是遊戲伺服器中最典型,也是曆久最悠久的模型。其特征是遊戲伺服器是一個個單獨的世界。每個伺服器的帳号是獨立的,而且隻用同一伺服器的帳号才能産生線上互動。在早期伺服器的承載量達到上限的時候,遊戲開發者就通過架設更多的伺服器來解決。這樣提供了很多個遊戲的“平行世界”,讓遊戲中的人人之間的比較,産生了更多的空間。是以後來以伺服器的開放、合并形成了一套成熟的營運手段。一個技術上的選擇最後導緻了遊戲營運方式的模式,是一個非常有趣的現象。

經典遊戲伺服器端架構概述 (1)

[圖:分服模型]

1 . 單程序遊戲伺服器

最簡單的遊戲伺服器隻有一個程序,是一個單點。這個程序如果退出,則整個遊戲世界消失。在此程序中,由于需要處理并發的用戶端的資料包,是以産生了多種選擇方法:

經典遊戲伺服器端架構概述 (1)

[圖:單程序排程模型]

同步-動态多線程:每接收一個使用者會話,就建立一個線程。這個使用者會話往往就是由用戶端的TCP連接配接來代表,這樣每次從socket中調用讀取或寫出資料包的時候,都可以使用阻塞模式,編碼直覺而簡單。有多少個遊戲用戶端的連接配接,就有多少個線程。但是這個方案也有很明顯的缺點,就是伺服器容易産生大量的線程,這對于記憶體占用不好控制,同時線程切換也會造成CPU的性能損失。更重要的多線程下對同一塊資料的讀寫,需要處理鎖的問題,這可能讓代碼變的非常複雜,造成各種死鎖的BUG,影響伺服器的穩定性。

同步-多線程池:為了節約線程的建立和釋放,建立了一個線程池。每個使用者會話建立的時候,向線程池申請處理線程的使用。在使用者會話結束的時候,線程不退出,而是向線程池“釋放”對此線程的使用。線程池能很好的控制線程數量,可以防止使用者暴漲下對伺服器造成的連接配接沖擊,形成一種排隊進入的機制。但是線程池本身的實作比較複雜,而“申請”、“施放”線程的調用規則需要嚴格遵守,否則會出現線程洩露,耗盡線程池。

異步-單線程/協程:在遊戲行業中,采用Linux的epoll作為網絡API,以期得到高性能,是一個常見的選擇。遊戲伺服器程序中最常見的阻塞調用就是網路IO,是以在采用epoll之後,整個伺服器程序就可能變得完全沒有阻塞調用,這樣隻需要一個線程即可。這徹底解決了多線程的鎖問題,而且也簡化了對于并發程式設計的難度。但是,“所有調用都不得阻塞”的限制,并不是那麼容易遵守的,比如有些資料庫的API就是阻塞的;另外單程序單線程隻能使用一個CPU,在現在多核多CPU的伺服器情況下,不能充分利用CPU資源。異步程式設計由于是基于“回調”的方式,會導緻要定義很多回調函數,并且把一個流程裡面的邏輯,分别寫在多個不同的回調函數裡面,對于代碼閱讀非常不理。——針對這種編碼問題,協程(Coroutine)能較好的幫忙,是以現在比較流行使用異步+協程的組合。不管怎樣,異步-單線程模型由于性能好,無需并發思維,依然是現在很多團隊的首選。

異步-固定多線程:這是基于異步-單線程模型進化出來的一種模型。這種模型一般有三類線程:主線程、IO線程、邏輯線程。這些線程都在内部以全異步的方式運作,而他們之間通過無鎖消息隊列通信。

2 . 多程序遊戲伺服器

多程序的遊戲伺服器系統,最早起源于對于性能問題需求。由于單程序架構下,總會存在承載量的極限,越是複雜的遊戲,其單程序承載量就越低,是以開發者們一定要突破程序的限制,才能支撐更複雜的遊戲。

一旦走上多程序之路,開發者們還發現了多程序系統的其他一些好處:能夠利用上多核CPU能力;利用作業系統的工具能更仔細的監控到運作狀态、更容易進行容災處理。多程序系統比較經典的模型是“三層架構”:

在多程序架構下,開發者一般傾向于把每個子產品的功能,都單獨開發成一個程序,然後以使用程序間通信來協調處理完整的邏輯。這種思想是典型的“管道與過濾器”架構模式思想——把每個程序看成是一個過濾器,使用者發來的資料包,流經多個過濾器銜接而成的管道,最後被完整的處理完。由于使用了多程序,是以首選使用單程序單線程來構造其中的每個程序。這樣對于程式開發來說,結構清晰簡單很多,也能獲得更高的性能。

經典遊戲伺服器端架構概述 (1)

[圖:經典的三層模型]

盡管有很多好處,但是多程序系統還有一個需要特别注意的問題——資料存儲。由于要保證資料的一緻性,是以存儲程序一般都難以切分成多個程序。就算對關系型資料做分庫分表處理,也是非常複雜的,對業務類型有依賴的。而且如果單個邏輯處理程序承載不了,由于其記憶體中的資料難以分割和同步,開發者很難去平行的擴充某個特定業務邏輯。他們可能會選擇把業務邏輯程序做成無狀态的,但是這更加加重了存儲程序的性能壓力,因為每次業務處理都要去存儲程序處拉取或寫入資料。

除了資料的問題,多程序也架構也帶來了一系列運維和開發上的問題:首先就是整個系統的部署更為複雜了,因為需要對多個不同類型程序進行連接配接配置,造成大量的配置檔案需要管理;其次是由于程序間通訊很多,是以需要定義的協定也數量龐大,在單程序下一個函數調用解決的問題,在多程序下就要定義一套請求、應答的協定,這造成整個源代碼規模的數量級的增大;最後是整個系統被肢解為很多個功能短小的代碼片段,如果不了解整體結構,是很難了解一個完整的業務流程是如何被處理的,這讓代碼的閱讀和交接成本巨高無比,特别是在遊戲領域,由于業務流程變化非常快,幾經修改後的系統,幾乎沒有人能完全掌握其内容。

由于伺服器程序需要長期自動化運作,是以記憶體使用的穩定是首要大事。在伺服器程序中,就算一個觸發幾率很小的記憶體洩露,都會積累起來變成嚴重的營運事故。需要注意的是,不管你的線程和程序結構如何,記憶體架構都是需要的,除非是Erlang這種不使用堆的函數式語言。

1 . 動态記憶體

在需要的時候申請記憶體來處理問題,是每個程式員入門的時候必然要學會的技能。但是,如何控制記憶體釋放卻是一個大問題。在C/C++語言中,對于堆的控制至關重要。有一些開發者會以樹狀來規劃記憶體使用,就是一般隻new/delete一個主要的類型的對象,其他對象都是此對象的成員(或者指針成員),隻要這棵樹上所有的對象都管理好自己的成員,就不會出現記憶體漏洞,整個結構也比較清晰簡單。

經典遊戲伺服器端架構概述 (1)

[圖:對象樹架構]

在Objective C語言中,有所謂autorealse的特性,這種特性實際上是一種引用計數的技術。由于能配合在某個排程模型下,是以使用起來會比較簡單。同樣的思想,有些開發者會使用一些智能指針,配合自己寫的架構,在完整的業務邏輯調用後一次性清理相關記憶體。

經典遊戲伺服器端架構概述 (1)

[圖:根據業務處理排程管理記憶體池]

在帶虛拟機的語言中,最常見的是JAVA,這個問題一般會簡單一些,因為有自動垃圾回收機制。但是,JAVA中的容器類型、以及static變量依然是可能造成記憶體洩露的原因。加上無規劃的使用線程,也有可能造成記憶體的洩露——有些線程不會退出,而且在不斷增加,最後耗盡記憶體。是以這些問題都要求開發者專門針對static變量以及線程結構做統一設計、嚴格規範。

2 . 預配置設定記憶體

動态配置設定記憶體在小心謹慎的程式員手上,是能發揮很好的效果的。但是遊戲業務往往需要用到的資料結構非常多,變化非常大,這導緻了記憶體管理的風險很高。為了比較徹底的解決記憶體漏洞的問題,很多團隊采用了預先配置設定記憶體的結構。在伺服器啟動的時候配置設定所有的變量,在運作過程中不調用任何new關鍵字的代碼。

這樣做的好處除了可以有效減少記憶體漏洞的出現機率,也能降低動态配置設定記憶體所消耗的性能。同時由于啟動時配置設定記憶體,如果硬體資源不夠的話,程序就會在啟動時失敗,而不是像動态配置設定記憶體的程式一樣,可能在任何一個配置設定記憶體的時候崩潰。然而,要獲得這些好處,在編碼上首先還是要遵循“動态配置設定架構”中對象樹的原則,把一類對象構造為“根”對象,然後用一個記憶體池來管理這些根對象。而這個記憶體池能存放的根對象的數目,就是此服務程序的最大承載能力。一切都是在啟動的時候決定,非常的穩妥可靠。

經典遊戲伺服器端架構概述 (1)

[圖:預配置設定記憶體池]

不過這樣做,同樣有一些缺點:首先是不太好部署,比如你想在某個資源較小的虛拟機上部署一套用來測試,可能一位内沒改記憶體池的大小,導緻啟動不成功。每次更換環境都需要修改這個配置。其次,是所有的用到的類對象,都要在根節點對象那裡有個指針或者引用,否則就可能洩漏記憶體。由于對于非基本類型的對象,我們一般不喜歡用拷貝的方式來作為函數的參數和傳回值,而指針和應用所指向的記憶體,如果不能new的話,隻能是現成的某個對象的成員屬性。這回導緻程式越複雜,這類的成員屬性就越多,這些屬性在代碼維護是一個不小的負擔。

要解決以上的缺點,可以修改記憶體池的實作,為動态增長,但是具備上限的模型,每次從記憶體池中“擷取”對象的時候才new。這樣就能避免在小記憶體機器上啟動不了的問題。對于對象屬性複雜的問題,一般上需要好好的按面向對象的原則規劃代碼,做到盡量少用僅僅表示函數參數和傳回值的屬性,而是主要是記錄對象的“業務狀态”屬性為主,多花點功夫在建構遊戲的資料模型上。

在多程序的系統中,程序間如何通訊是一個至關重要的問題,其性能和使用便利性,直接決定了多程序系統的技術效能。

1 . Socket通訊

TCP/IP協定是一種通用的、跨語言、跨作業系統、跨機器的通訊方案。這也是開發者首先想到的一種手段。在使用上,有使用TCP和UDP兩個選擇。一般我們傾向在遊戲系統中使用TCP,因為遊戲資料的邏輯相關性比較強,UDP由于可能存在的丢包和重發處理,在遊戲邏輯上的處理一般比較複雜。由于多程序系統的程序間網絡一般情況較好,UDP的性能優勢不會特别明顯。

要使用TCP做跨程序通訊,首先就是要寫一個TCP Server,做端口監聽和連接配接管理;其次需要對可能用到的通信内容做協定定制;最後是要編寫編解碼和業務邏輯轉發的邏輯。這些都完成了之後,才能真正的開始用來作為程序間通信手段。

使用Socket程式設計的好處是通用性廣,你可以用來實作任何的功能,和任何的程序進行協作。但是其缺點也異常明顯,就是開發量很大。雖然現在有一些開源元件,可以幫你簡化Socket Server的編寫工作,簡化連接配接管理和消息分發的處理,但是選擇目标建立連接配接、定制協定編解碼這兩個工作往往還是要自己去做。遊戲的特點是業務邏輯變化很多,導緻協定修改的工作量非常大。是以我們除了直接使用TCP/IP socket以外,還有很多其他的方案可以嘗試。

經典遊戲伺服器端架構概述 (1)

[圖:TCP通訊]

2 . 消息隊列

在多程序系統中,如果程序的種類比較多,而且變化比較快,大量編寫和配置程序之間的連接配接是一件非常繁瑣的工作,是以開發者就發明了一種簡易的通訊方法——消息隊列。這種方法的底層還是Socket通訊實作,但是使用者隻需要好像投遞信件一樣,把消息包投遞到某個“信箱”,也就是隊列裡,目标程序則自動不斷去“收取”屬于自己的“信件”,然後觸發業務處理。

這種模型的好處是非常簡單易懂,使用者隻需要處理“投遞”和“收取”兩個操作即可,對于消息也隻需要處理“編碼”和“解碼”兩個部分。在J2EE規範中,就有定義一套消息隊列的規範,叫JMS,Apache ActiveMQ就是一個應用廣泛的實作者。在Linux環境下,我們還可以利用共享記憶體,來承擔消息隊列的存儲器,這樣不但性能很高,而且還不怕程序崩潰導緻未處理消息丢失。

經典遊戲伺服器端架構概述 (1)

[圖:消息隊列]

需要注意的是,有些開發者缺乏經驗,使用了資料庫,如MySQL,或者是NFS這類運作效率比較低的媒介作為隊列的存儲者。這在功能上雖然可以行得通,但是操作一頻繁,就難以發揮作用了。如以前有一些手機短信應用系統,就用MySQL來存儲“待發送”的短信。

消息隊列雖然非常好用,但是我們還是要自己對消息進行編解碼,并且分發給所需要的處理程式。在消息到處理程式之間,存在着一個轉換和對應的工作。由于遊戲邏輯的繁多,這種對應工作完全靠手工編碼,是比較容易出錯的。是以這裡還有進一步的改進空間。

3 . 遠端調用

有一些開發者會希望,在編碼的時候完全屏蔽是否跨程序在進行調用,完全可以好像調用本地函數或者本地對象的方法一樣。于是誕生了很多遠端調用的方案,最經典的有Corba方案,它試圖實作能在不同語言的代碼直接,實作遠端調用。JAVA虛拟機自帶了RMI方案的支援,在JAVA程序之間遠端調用是比較友善的。在網際網路的環境下,還有各種Web Service方案,以HTTP協定作為承載,WSDL作為接口描述。

使用遠端調用的方案,最大好處是開發的便捷,你隻需要寫一個函數,就能在任何一個其他程序上對此函數進行調用。這對遊戲開發來說,就解決了多程序方案最大的一個開發效率問題。但是這種便捷是有成本的:一般來說,遠端調用的性能會稍微差一點,因為需要用一套統一的編解碼方案。如果你使用的是C/C++這類靜态語言,還需要使用一種IDL語言來先描述這種遠端函數的接口。但是這些困難帶來的好處,在遊戲開發領域還是非常值得的。

經典遊戲伺服器端架構概述 (1)

[圖:遠端調用]

在多程序模型中,由于可以采用多台實體伺服器來部署服務程序,是以為容災和擴容提供了基礎條件。

在單程序模型下,容災常常使用的熱備伺服器,依然可以在多程序模型中使用,但是開着一台什麼都不做的伺服器完全是為了做容災,多少有點浪費。是以在多程序環境下,我們會啟動多個相同功能的伺服器程序,在請求的時候,根據某種規則來确定對哪個服務程序發起請求。如果這種規則能規避通路那些“失效”了的服務程序,就自動實作了容災,如果這個規則還包括了“更新新增服務程序”的邏輯,就可以做到很友善的擴容了。而這兩個規則,統一起來就是一條:對服務程序狀态的集中儲存和更新。

為了實作上面的方案,常常會架設一個“目錄”伺服器程序。這個程序專門負責搜集伺服器程序的狀态,并且提供查詢。ZooKeeper就是實作這種目錄伺服器的一個優秀工具。

經典遊戲伺服器端架構概述 (1)

[圖:伺服器狀态管理]

盡管用簡單的目錄伺服器可以實作大部分容災和擴容的需求,但是如果被通路程序的記憶體中有資料存在,那麼問題就比較複雜了。對于容災來說,新的程序必須要有辦法重建那個“失效”了的程序記憶體中的資料,才可能完成容災功能;對于擴容功能來說,新加入的程序,也必須能把需要的資料載入到自己的記憶體中才行,而這些資料,可能已經存在于其他平行的程序中,如何把這部分資料轉移過來,是一個比較耗費性能和需要編寫相當多代碼的工作。——是以一般我們喜歡對“無狀态”的程序來做擴容和容災。