首頁 遊戲資訊 小霸王土豆伺服器養成史

小霸王土豆伺服器養成史

分析模型

一、討論的背景

現代電子遊戲,基本上都會使用一定的網絡功能。從驗證正版,到多人交互等等,都需要架設一些專用的伺服器,以及編寫在伺服器上的程序。因此,遊戲伺服器端軟體的架構,本質上也是遊戲伺服器這個特定領域的軟體架構。軟體架構的分析,可以通過不同的層面入手。比較經典的軟體架構描述,包含了以下幾種架構:

  • 運行時架構——這種架構關心如何解決運行效率問題,通常以程序進程圖、數據流圖為表達方式。在大多數開發團隊的架構設計文檔中,都會包含運行時架構,說明這是一種非常重要的設計方面。這種架構也會顯著的影響軟體代碼的開發效率和部署效率。本文主要討論的是這種架構。
  • 邏輯架構——這種架構關心軟體代碼之間的關系,主要目的是為了提高軟體應對需求變更的便利性。人們往往會以類圖、模塊圖來表達這種架構。這種架構設計在需要長期運營和重用性高的項目中,有至關重要的作用。因為軟體的可擴展性和可重用度基本是由這個方面的設計決定的。特別是在遊戲領域,需求變更的頻繁程度,在多個網際網路產業領域里可以說是最高的。本文會涉及一部分這種架構的內容,但不是本文的討論重點。
  • 物理架構——關心軟體如何部署,以機房、伺服器、網絡設備為主要描述對象。
  • 數據架構——關心軟體涉及的數據結構的設計,對於數據分析挖掘,多系統協作有較大的意義。
  • 開發架構——關心軟體開發庫之間的關系,以及版本管理、開發工具、編譯構建的設計,主要為了提高多人協作開發,以及復雜軟體庫引用的開發效率。現在流行的集成構建系統就是一種開發架構的理論。
  • 二、遊戲伺服器架構的要素

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

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

  • 對於遊戲數據和玩家數據的存儲
  • 對玩家客戶端進行數據廣播
  • 把一部分遊戲邏輯在伺服器上運算,便於遊戲更新內容,以及防止外掛。
  • 針對以上的需求特徵,在伺服器端軟體開發上,我們往往會關注軟體對電腦記憶體和 CPU 的使用,以求在特定業務代碼下,能盡量滿足承載量和響應延遲的需求。最基本的做法就是「時空轉換」,用各種緩存的方式來開發程序,以求在 CPU 時間和記憶體空間上取得合適的平衡。在 CPU 和記憶體之上,是另外一個約束因素:網卡。網絡帶寬直接限制了伺服器的處理能力,所以遊戲伺服器架構也必定要考慮這個因素。

    對於遊戲伺服器架構設計來說,最重要的是利用遊戲產品的需求約束,從而優化出對此特定功能最合適的「時-空」架構。並且最小化對網絡帶寬的占用。

    小霸王土豆伺服器養成史

    三、核心的三個架構

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

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

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

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

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

    四、遊戲伺服器模型的進化歷程

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

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

    分服模型

    一、模型描述

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

    小霸王土豆伺服器養成史

    二、調度架構

    a) 單進程遊戲伺服器

    最簡單的遊戲伺服器只有一個進程,是一個單點。這個進程如果退出,則整個遊戲世界消失。在此進程中,由於需要處理並發的客戶端的數據包,因此產生了多種選擇方法:

    小霸王土豆伺服器養成史

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

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

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

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

    b) 多進程遊戲伺服器

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

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

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

    小霸王土豆伺服器養成史

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

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

    三、記憶體架構

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

    a) 動態記憶體

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

    小霸王土豆伺服器養成史

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

    小霸王土豆伺服器養成史

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

    b) 預分配記憶體

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

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

    小霸王土豆伺服器養成史

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

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

    四、進程間通訊手段

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

    a) Socket通訊

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

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

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

    小霸王土豆伺服器養成史

    b) 消息隊列

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

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

    小霸王土豆伺服器養成史

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

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

    c) 遠程調用

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

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

    小霸王土豆伺服器養成史

    五、容災和擴容手段

    在多進程模型中,由於可以採用多台物理伺服器來部署服務進程,所以為容災和擴容提供了基礎條件。

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

    為了實現上面的方案,常常會架設一個「目錄」伺服器進程。這個進程專門負責搜集伺服器進程的狀態,並且提供查詢。ZooKeeper、ETCD、Consul 都是實現這種目錄伺服器的優秀軟體。

    小霸王土豆伺服器養成史

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

    全服分線模型

    一、模型描述

    由於多進程伺服器模型的發展,遊戲開發者們首先發現,由於遊戲業務的特點,那些需要持久化的數據,一般都是玩家的存檔,以及一些遊戲本身需要用的,在運行期只讀的數據。這對於存儲進程的分布,提供了非常有利的條件。於是玩家數據可以存放於同一個集群中,可以不再和遊戲伺服器綁定在一起,因為登錄的時候便可根據玩家的ID去存儲集群中定位想要存取的存儲進程。

    小霸王土豆伺服器養成史

    二、存儲的挑戰

    a) 需求:擴容和容災

    在全區分線模型下,遊戲玩家可以隨便選擇任何一個伺服器登錄,自己的帳號數據都可以提取出來玩。這種顯然比每個伺服器重新「練」一個號要省事的多。而且這樣也可以和朋友們約定去一個負載較低的伺服器一起玩,而不用苦苦等待某一個特定的伺服器變得空閒。然而,這些好處所需要付出的代價,是在存儲層的分布式設計。這種設計有一個最需要解決的問題,就是遊戲伺服器系統的擴容和容災。

    從模型上說,擴容是加入新的伺服器,容災是減掉失效的伺服器。這兩個操作在無狀態的伺服器進程上操作,都只是更新一下連接配置表,然後重啟一下即可。但是,由於遊戲存在大量的狀態,包括運行時記憶體中的狀態,以及持久化的存儲狀態,這就讓擴容和容災需要更多的處理才能成功。

    最普通的情況下,在擴容和容災的時候,首先需要通知所有玩家下線,把記憶體中的狀態數據寫入持久化數據進程;然後根據需要的配置,把持久化數據重新「搬遷」到新的變化後的伺服器上。——如果一個遊戲有幾千萬用戶,這樣的數據搬遷將會耗時非常長,玩家也被迫等待很長的時間才能重新登錄遊戲。所以在這種模型下,對於數據存儲的設計是最關鍵的地方。

    b) 分區分服的關系型資料庫

    我們常常會使用MySQL這種關系型資料庫來存放遊戲數據。由於SQL能夠表述非常復雜的數據操作,這對於遊戲數據的一些後期處理有非常好的支持:如客服需要發獎勵,需要撤銷某些錯誤的運營數據,需要封停某些特徵的玩家……但是,分布式資料庫也是最難做分布的。一般來說我們都需要通過某一主鍵欄位做分庫和分表;而另外一些如唯一關鍵字等數據,就需要一些技巧來處理。

    小霸王土豆伺服器養成史

    以玩家ID作為分表分庫是一個非常自然的選擇,但是這種方案,往往需要在邏輯代碼中,對玩家數據按照自定義的規則,做存儲進程的選擇。但是如果發現這個分表分庫的算法(原則)不符合需求,就需要把大量的數據做搬遷。如上圖是按玩家ID做奇偶規則分布到兩個表中,一旦需要增加第三台伺服器,數據存儲的目的伺服器編號就變成了id%3,這樣就需要把好多數據需要從原來的第一、二台資料庫中拷貝出來,非常麻煩。

    有的開發者會預先建立幾十個表(如120個表=2x3x4x5),一開始是全部都放在一個伺服器上,然後在增加資料庫伺服器的時候,把對應的整個表搬遷出來。這樣能減輕在搬遷數據的時候造成的復雜度,但還是需要搬遷數據的。最後如果與建立的表還是放不下了,依然還是需要很復雜和耗時的重新拷貝數據。

    c) NoSQL

    在很多開發者絞盡腦汁折騰MySQL的時候,NoSQL橫空出世了。實際上在很早,目錄型存儲進程就在DNS等特定領域默默工作了。NoSQL系統最大的好處正是關系型資料庫最大的弱點——分布。

    由於主鍵只有一個,因此內置的分布功能使用起來非常簡便。而且遊戲玩家數據,絕大多數的操作都是根據主鍵來讀寫的。「自古以來」遊戲就有「SL大法」之稱,其本質就是對存檔數據的簡單讀、寫。在網游的早期版本 MUD 遊戲時代,玩家存檔只是簡單的放在硬碟的文件上,文件名就是玩家的 ID。這些,都說明了遊戲中的玩家數據,其讀寫都是有明顯約束的——玩家ID。這和 NoSQL 簡直是天作之合。

    小霸王土豆伺服器養成史

    NoSQL的確是非常適合用來存儲遊戲數據。特別是有些伺服器如Redis還帶有豐富的欄位值類型。但是,NoSQL本身往往不帶很復雜的容災熱備機制,這是需要額外注意的。而且NoSQL的訪問延遲雖然比關系型資料庫快很多,但是畢竟要經過一層網絡。這對於那些發展了很多年的ORM庫來說,缺乏了一個本地緩存的功能。這就導致了NoSQL還不能簡單的取代掉所有伺服器上的「狀態」。而這些正是分布式緩存所希望達成的目標。

    d) 分布式緩存

    在業界用的比較多的緩存系統有memcached,開發者有時候也會使用諸如Hibernate這樣的ROM庫提供的cache功能。但是這些緩存系統在使用上往往會有一些限制,最主要的限制是「無法分布式使用」,也就是說緩存系統本身成為性能瓶頸後,就沒有辦法擴容了。或者在容災的情景下,緩存系統往往容易變成致命的單點。

    Orcale公司有一款叫Coherence的產品,就是一種能很好解決以上問題的「能分布式使用」的產品。他利用區域網的組播功能來做節點間的狀態同步,同時採用節點互相備份的方案來分布數據。這款產品還使用Map接口來提供功能。這讓整個緩存系統既使用簡單又功能強大。更重要的是,它能讓用戶對於數據的存取特性做配置,從而提供用戶可接受的數據風險下的更高性能——本地緩存。

    由於遊戲的數據,真正變化頻繁的,往往不是「關鍵」的需要安全保障數據,如玩家的位置、玩家在某次戰鬥中的HP、子彈怪物的位置等等。而那些非常重要的數據,如等級、裝備,又變化的不頻繁。這就給了開發者針對數據特性做優化以很大的空間。而且,大部分數據的讀、寫頻率都有典型的不平衡狀態。普遍遊戲數據都是讀多寫少。少量的日誌、上報數據是寫多、幾乎不讀。

    對於緩存系統來說,有三個重要的因數決定了在遊戲開發中的地位。首先是其使用的便利性,因為遊戲的數據結構變化非常頻繁,如果要很繁瑣的配置數據結構,則不會適合遊戲開發;其次是要能提供近似本地記憶體的性能,由於遊戲伺服器邏輯基本上都是在頻繁的讀寫某一特定數據塊,如玩家位置、經驗、HP等等,而且遊戲對於處理延遲也有較高的需求(WEB應用在2秒以內都可以忍受,遊戲則要求最好能在20ms以內完成)。要能同時滿足這兩點,是不太容易的。

    小霸王土豆伺服器養成史

    e) 集成緩存的NoSQL

    根據上面的描述,讀者應該也會想到,如果資料庫系統,或者叫持久化系統,自帶了緩存,是否更好呢?這樣確實是會更好的,而且特別是對於NOSQL系統來說,能以一些內部的算法策略,來降低前端邏輯開發的復雜程度。一般來說,我們需要對集成緩存的NOSQL系統有以下幾方面的需求:首先是冷熱數據自動交換,就是對於常用數據有算法來判別其冷熱,然後換入到記憶體以提高存取性;其次是分布式擴容和容災功能,由於NOSQL是可以知道數據的主關鍵字的,所以自然就可以自動的去劃分數據所在的分段,從而可以自動化的尋找到目標存儲位置來做操作;最後是數據導出功能,由於NOSQL支持的查詢索引只能是主鍵,對於很多後台遊戲操作來說是不夠的,所以一定要能夠到處到傳統的SQL伺服器上去。

    在這方面,有很多產品都做過一定的嘗試,比如在Redis或者MangoDB上做插件修改,或者以ORM系統封裝MySQL以試圖構造這種系統等等。

    小霸王土豆伺服器養成史

    三、跳線和開房間

    a) 開房間型遊戲模型

    在全區分線伺服器模型中,最早出現在開房間類型的遊戲中。因為海量玩家需要臨時聚合到一個個小的在線服務單元上互動。比如一起下棋、打牌等。這類遊戲玩法和MMORPG有很大的不同,在於其在線廣播單元的不確定性和廣播數量很小。

    這一類遊戲最重要的是其「遊戲大廳」的承載量,每個「遊戲房間」受邏輯所限,需要維持和廣播的玩家數據是有限的,但是「遊戲大廳」需要維持相當高的在線用戶數,所以一般來說,這種遊戲還是需要做「分服」的。典型的遊戲就是《英雄聯盟》《穿越火線》這一類遊戲了。而「遊戲大廳」里面最有挑戰性的任務,就是「自動匹配」玩家進入一個「遊戲房間」,這需要對所有在線玩家做搜索和過濾。

    小霸王土豆伺服器養成史

    這類遊戲伺服器,玩家先登錄「大廳伺服器」,然後選擇組隊遊戲的功能,伺服器會通知參與的所有遊戲客戶端,新開一條連接到房間伺服器上,這樣所有參與的用戶就能在房間伺服器里進行遊戲交互了。

    由於「大廳伺服器」只負責「組隊」,所以其承載力會比具體的房間伺服器更高一些,但這里仍然會是性能瓶頸。所以一般我們需要盡量減少大廳伺服器的功能,比如把登錄功能單獨列出來、把玩家的購買物品商城功能也單獨出來等等。最後,我們也可以直接想辦法把「組隊」功能也按組隊邏輯做一定劃分,比如不同的組隊玩法、副本類型、組隊用戶等級等等。

    雖然這種模型已經可以對很多遊戲做很好的承載了,但是在大廳伺服器這里依然無法做到平行擴展,原因是玩家的在線數據比較難分布到不同的服務進程上去,而且還帶有大量復雜的數據查詢邏輯。

    b) 專用聊天伺服器

    不管是MMORPG還是開房間類遊戲,聊天一直都是網路遊戲中一個重要的功能。而這個功能在「在線人數」很多,「聊天頻道」很多的情況下,會給性能帶來非常大的挑戰。在很多類型的頁游和少部分手機遊戲里面,在線聊天甚至是唯一的「帶公共狀態」的服務。

    聊天服務處理點對點的聊天,還有群聊。用戶可能會添加好友、建立好友群組等各種功能。這些功能,都是和一般的遊戲邏輯有一定差別的功能。這些功能往往並不是非常容易實現。很多遊戲都期望建立類似騰訊QQ的遊戲聊天功能,但是QQ是一整個公司在做開發,要用僅僅一個遊戲團隊做成這麼完整的功能,是有一定困難的。

    因此遊戲開發者們常常會專門的針對聊天功能來開發一系列的服務進程,以便能讓遊戲的聊天功能獨立出來,做到負載分流和代碼重用的邏輯。很多網游系統,其聊天系統從客戶端來說就是和主遊戲進程分開的。

    聊天伺服器的本質是對客戶端數據做廣播,從而讓玩家可以交互,所以有很多遊戲開發者也直接拿聊天伺服器來做棋牌遊戲的房間伺服器,或者反過來用。由於在遊戲「分服」里面單獨部署了聊天伺服器,這類伺服器也往往被用來承擔做「跨服玩法」的進程。比如跨服團隊戰、跨服副本等等。不管這些伺服器最終叫什麼名字,實際上他們承擔的主要功能還是廣播,而且是運行玩家「二次登錄」的廣播伺服器。以至於後來,有部分遊戲直接全部都用聊天伺服器來代替原始的「遊戲伺服器」,這樣還能實現一個叫「跳線」的功能,也就是玩家從一個「在線環境」跳到另外一個「在線環境」去。——這些都是對於「廣播」功能的靈活運用。

    小霸王土豆伺服器養成史

    全服全線模型

    盡管分服的遊戲模型已經運營了很多年,但是有一些遊戲運營商還是希望能讓盡量多的玩家一起玩。因為網游的人氣越活躍,產生的交互越多,遊戲的樂趣也可能越多。這一點最突出表現在棋牌類網游上。如聯眾、QQ遊戲這類產品,無不是希望更多玩家能同時在線接入一個「大」伺服器,從而找到可以一起玩的夥伴。在手遊時代,由於手機本身在線時間不穩定,所以想要和朋友一起玩本來就比較困難,如果再以「伺服器」劃分區域,交互的樂趣就更少了,所以同樣也呼喚這一個「大」伺服器,能容納下所有此款遊戲的玩家。因此,開發者們在以前積累的分服模型和分線模型基礎上,開發出滿足海量在線互動需求的一系列遊戲伺服器模型——全服全線模型。

    小霸王土豆伺服器養成史

    一、服務進程的組織

    a) 靜態配置

    全服全線模型的本質是一個各種不同功能進程組成的分布式系統,因此這些進程間的關系是在運維部署期間必須關注的信息。最簡單的處理方法,就是預先規劃出具體的進程數量、以及進程部署的物理位置,然後通過一套配置文件來描述這個規劃的內容。對於每個進程,需要配置列明每個進程的pid文件位置;內部通訊用的地址,如IP+埠或者消息隊列ID;啟動和停止腳本路徑;日誌路徑等等……由於有了一套這樣的配置文件,我們還可以編寫工具對所有的這些進程進行監控和操作批量啟停。

    小霸王土豆伺服器養成史

    雖然我們可以以靜態配置為基礎做很豐富的管理工具,但是這種做法還是有可以改進的空間:每次擴容、更換故障伺服器或者搬遷伺服器(這在運營中很常見),我們都必須手工修改靜態配置數據,由於是人工操作,就總會產生很多錯誤,根據個人經驗,遊戲運營事故中的70%以上,是跟運維操作有關;由於整個分布式系統被切分成大量的進程,對於新進入此項目的程式設計師來說,要完整的理解這個系統,需要在思想上跨越層層阻隔:每個進程的功能、它們部署的關聯、每個進程間的協議報的含義、每個業務流程具體的跨進程過程……這要花費很多時間才能搞明白的。而且大部分遊戲的這種架構並不統一,每個遊戲都可能需要重新理解一次,知識無法重用;在開發測試上,由於分布式系統的復雜性,要多搭幾個開發、測試環境也是很費時間的,以至於這項工作甚至要安排專人來負責,這對於小型遊戲開發團隊來說幾乎是不可承擔的成本。因此我們還需要一些更加自動化,更加容易理解的全服全線遊戲伺服器模型。

    b) 基於中心點的動態組織

    SOA架構模式是業界一個比較經典的分布式軟體架構模式,這個架構的特點是能動態的組織一個非常復雜的分布式服務系統。這個系統可以包含提供各種各樣供的服務程序,而這些服務程序都以同一個標準接口來使用,並且服務自己會注冊自己到集群中,以便請求方能找到自己。這種架構使用Web Serivce來作為服務接口標準,通過發布WSDL來提供接口API,這極大的降低了開發者對這些服務的使用成本。在遊戲領域,伺服器端提供的功能程序,實際上也是非常多樣的,如果要構建一個分布式的系統,在這個方面是非常適合SOA架構的思想的;然而,遊戲卻很少使用HTTP協議及其之上的Web Service做通訊層,因為這個協議性能太低。不過,類似SOA的,基於中心節點的動態組織的服務管理思路,卻依然適用。

    小霸王土豆伺服器養成史

    一般來說我們會使用一組目錄伺服器來充當「中心點」,代表整個集群。開源產品中最好的產品就是ZooKeeper了。當然也有一些開發者自己編寫這樣的目錄伺服器。由於每個服務進程會自己上報負載和狀態,所以每個進程只需要配置自己提供的服務即可:服務名字、服務接口。對於請求方來說,一般都可以預先編寫目標服務接口的類庫,用來編程,有些項目還使用RPC功能,使用IDL語言配置直接生成這些接口類庫。當需要請求的時候,執行「名字查找」-「路由選擇」-「發起請求」就可以完成整個過程。由於有「查找」-「路由」的過程,所以如果目標服務故障、或者新增了服務提供者,請求方就能自動獲得這些信息,從而達到自動動態擴容或容災的效果,這些都是無需專門去做配置的。

    c) 服務化與雲

    盡管動態組織的架構有如此多優點,但是開發者還是需要自己部署和維護中心節點。對於一些常用的服務,如網絡代理服務、數據存儲服務,用戶還是要自己去安裝,以及想辦法接入到這套體系中去。這對於開發、測試還是有一定的運維工作壓力的。於是一些開發團隊就把這類工作集中起來,預先部署一套大的集群中心系統,所有開發者都直接使用,而不是自己去安裝部署,這就成為了服務化,或者雲服務。

    小霸王土豆伺服器養成史

    使用專人維護的服務化集群確實是一個輕松愉快的過程。但是遊戲開發和運營過程中,往往需要多套環境,如各個不同版本的測試環境、給不同運營平台搭建的環境、海外運營的環境等等……這些環境會大大增加維護服務化集群的工作量,對於解決這個問題,建立高度自動化運維的私有雲,成為一個需要解決的問題放上了桌面。提高集群的運維效率,降低工作復雜程度,需要一些特別的技術,而虛擬化技術正式解決這些問題的最新突破。

    二、提高開發效率所用的結構

    a) 使用RPC提高網絡接口編寫效率

    在分布式系統中,如果所有的接口都需要自己定義數據協議報來做交互,這個網絡編程的工作量將會非常的大,因為對於一個普通的通信接口來說,至少包括了:一個請求包結構、一個響應包結構、四段代碼,包括請求響應包的編碼和解碼、一個接收數據做分發的代碼分支、一個發送回應的調用。由於分布式的遊戲伺服器進程非常多,一個類似登錄這樣的操作,可能需要歷經三、四個進程的合作處理,這就導致了接近十個數據結構的定義和無數段類似的代碼。而這些代碼,如果在單進程的環境下,僅僅只是三、四個函數定義而已。

    因此很多開發者投入很大精力,讓網絡通信的編寫過程,盡量簡化成類似函數的編寫一樣。這就是前文所述的遠程調用的方法。在全區全線的遊戲中,如果是比較重度的遊戲,採用RPC方式做開發,會大大降低開發的復雜程度。當然也有一些比較輕度的遊戲,還是採用傳統的協議包編解碼、分發邏輯調用的做法。

    b) 簡化數據處理

    在分布式系統中,對於避免單點、容災、擴容中最復雜的問題,就是在記憶體中的數據。由於記憶體中有遊戲業務的數據,所以一般我們不敢隨便停止進程,也難以把一個進程的服務替換為另外一個進程。然而,遊戲數據對比其他業務,還是非常有特點的:

    l 寫入越不頻繁的數據,價值越高。比如過關、升級、獲得重要裝備。

    l 大量數據都是讀非常頻繁,而寫非常不頻繁的,如玩家的等級、經驗。

    l 大量寫入頻繁的數據,實際上是不太重要,可以有一定損失,比如玩家位置,在某個關卡內的HP/MP等……

    因此,只要我們能按數據的特性,對遊戲中需要處理的數據做一定分類,就能很好的解決分布式中的這些問題。

    l 首先我們要對數據的分布做規劃,一般來說採用按玩家ID做分布,這樣能讓服務進程中記憶體的數據緩存高度命中。常用的手法有用一致性哈希來選擇路由,調用相關的服務進程。

    l 其次對於讀頻繁而寫不頻繁的數據,我們採用讀緩存而寫不緩存的策略。每個服務進程都保留其讀緩存數據,如果需要擴容和容災,僅僅需要修改服務訪問的路由即可。

    l 再次對於讀不頻繁而寫頻繁的數據,我們採用寫緩存和讀不緩存的策略。由於這些數據丟失掉一些是不要緊的,所以容災處理就直接忽略即可,對於擴容,只需要對所有服務進程都做一次回寫即可。

    l 最後,有一些數據是讀和寫都頻繁的數據,比如玩家位置,HP/MP這類,我們採用讀寫都緩存,由於數據重要性不高,只要我們多分幾個服務進程即可降低故障時影響的范圍;在擴容的時候調用全節點清理讀緩存和回寫髒數據即可。

    在和持久化設備打交道的時候,傳統的ORM類庫往往能幫我們把數據存入關系型資料庫,然而,使用一個自帶數據熱備的NOSQL也是很好的選擇。因為這樣能節省大量的分庫分表邏輯代碼。

    c) 自動化部署集群環境

    最新的虛擬化技術給分布式系統提供能更好的部署手段,以Docker為標志的虛擬化平台,可以很好的提高服務化集群的管理。我們可以把每個服務進程打包成一個映像文件,放入Docker虛擬機中運行,也可以把一組互相關聯的服務進程打包運行。這些環境問題都由Docker處理了。

    但是,我們同時需要注意的是,如果我們的進程的資源是靜態分配的(前文提到),在Docker的虛擬機中可能因為記憶體不足等原因直接無法啟動。這就需要我們把完全靜態分配資源的程序,修改為有資源限制,但是動態分配的程序。這樣我們才能在任何可以部署Docker的機器上部署我們的遊戲伺服器。

    三、分布式難點:狀態同步

    a) 分布式接入層

    一般來說,我們全線伺服器系統碰到的第一個問題,就是大量並發的網絡請求。特別是大量玩家都在一起交互,產生了大量由於狀態同步而需要廣播的數據包。這些網絡請求的處理,顯然應該獨立出來成為單獨的進程。同時這些網絡接入進程,還應該是一個集群中的成員。這就誕生了分布式接入服務層。

    這些網路接入進程的第一個功能,就是把並發的連接,代理成為後端一個串行的連接,這可以讓後端服務進程的處理邏輯更簡單,而且網絡處理消耗變得更小。

    其次,網絡接入進程需要支持廣播功能。如果只是普通的廣播實現,很多人會需要拷貝很多次需要廣播的內容,然後挨個對Socket做發送。這其實是一個消耗很高的操作。而單獨的網絡接入進程,可以善用「零拷貝」等技術,大大降低廣播的性能開銷。而且還可以通過多個進程一起做廣播操作,以達到更大的在線同步區域。

    最後,網絡接入進程需要支持一些額外的有用功能,包括通訊的加密、壓縮、流量控制、過載保護等等。有些團隊還把用戶的登錄鑒權也加入網絡接入功能中。

    小霸王土豆伺服器養成史

    b) 使用 P2P

    網絡狀態同步產生的廣播請求中,絕大多數都是客戶端之間的網絡狀態,因此我們在可以使用 P2P 的客戶端之間,直接建立 P2P 的 UDP 數據連接,會比通過伺服器轉發降低非常多的負載。在一些如賽車、音樂、武打類型的著名遊戲中,都有使用 P2P 技術。而接入進程天然的就是一個 P2P 撮合伺服器。

    有些遊戲為了進一步降低延遲,還對所有的玩家狀態,只同步輸入動作,以及死亡、技能等重要狀態,讓怪物和一般狀態通過計算獲得,這樣就更能節省玩家的帶寬,提高及時性。加上一些動作預測技術,在客戶端上能表現的非常流暢。

    展望

    一、可重用的遊戲業務模版

    遊戲服務端的各種架構中,以前往往比較關注那些非功能性的需求:容災性、擴容、承載量,延遲。而在現在手遊時代,開發效率越來越重要,有些團隊甚至不設專門的伺服器端程式設計師。因此遊戲服務端架構應該更多的關注業務開發的效率。

    現代遊戲中,只要是帶 RPG 元素的,角色系統、物品系統、技能系統、任務系統就都會具備,而且都有一批比較穩定的核心邏輯。只要是能在線交互的,就有好友系統、郵件系統、聊天系統、公會系統等。另外商城系統、活動系統、公告系統更是每個遊戲都似乎要重復發明的輪子。

    遊戲的後端應用也有很多可重用的部分,比如客服系統、數據統計平台、官網數據接口等等。這些在遊戲服務端框架中往往是最後再添加進去的。

    如果把以上的問題都統一考慮起來,我們實際上是可以在一個穩定的底層架構上,構造出一整套常用的遊戲業務邏輯模板,用來減少遊戲領域的業務代碼開發。所以這樣一套可以運行各種業務邏輯模版的底層架構,正是遊戲服務端架構發展的方向。

    二、動態資源調度的 PaaS 雲

    現在有的團隊已經在搭建自己的 Docker 雲,這可以讓遊戲伺服器在虛擬雲上動態的生長,從而達到真正的動態擴容和動態容災。加上如果遊戲伺服器不再是一個個服務進程,而是真正意義上的一個個服務,可以動態的加入或者離開雲環境,那麼這就是一個遊戲領域的 PaaS 系統。我熱切的希望能看到,可以用一套 SDK,開發或重用那些成型的業務模版,然後動態注冊到服務雲中就能運行,這樣一種遊戲伺服器架構。

    來源:機核