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

版權聲明:本文由韓偉原創文章,轉載請註明出處: 
文章原文連接:https://www.qcloud.com/community/article/253程序員

來源:騰雲閣 https://www.qcloud.com/communitydocker

 

做者介紹:韓偉,1999年大學實習期加入初創期的網易,成爲第30號員工,8年間從程序員開始,歷任項目經理、產品總監。2007年後創業4年,開發過視頻直播社區,及多款頁遊產品。2011年後就任於騰訊遊戲研發部公共技術中心架構規劃組,專一於通用遊戲技術底層的研發。數據庫

架構的分析模型

一. 討論的背景

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

軟件架構的分析,能夠經過不一樣的層面入手。比較經典的軟件架構描述,包含了如下幾種架構:緩存

  • 運行時架構——這種架構關心如何解決運行效率問題,一般以程序進程圖、數據流圖爲表達方式。在大多數開發團隊的架構設計文檔中,都會包含運行時架構,說明這是一種很是重要的設計方面。這種架構也會顯著的影響軟件代碼的開發效率和部署效率。本文主要討論的是這種架構。服務器

  • 邏輯架構——這種架構關心軟件代碼之間的關係,主要目的是爲了提升軟件應對需求變動的便利性。人們每每會以類圖、模塊圖來表達這種架構。這種架構設計在須要長期運營和重用性高的項目中,有相當重要的做用。由於軟件的可擴展性和可重用度基本是由這個方面的設計決定的。特別是在遊戲領域,需求變動的頻繁程度,在多個互聯網產業領域裏能夠說是最高的。本文會涉及一部分這種架構的內容,但不是本文的討論重點。網絡

  • 物理架構——關心軟件如何部署,以機房、服務器、網絡設備爲主要描述對象。數據結構

  • 數據架構——關心軟件涉及的數據結構的設計,對於數據分析挖掘,多系統協做有較大的意義。多線程

  • 開發架構——關心軟件開發庫之間的關係,以及版本管理、開發工具、編譯構建的設計,主要爲了提升多人協做開發,以及複雜軟件庫引用的開發效率。如今流行的集成構建系統就是一種開發架構的理論。架構

二. 遊戲服務器架構的要素

服務器端軟件的本質,是一個會長期運行的程序,而且它還要服務於多個不定時,不定地點的網絡請求。因此這類軟件的特色是要很是關注穩定性和性能。這類程序若是須要多個協做來提升承載能力,則還要關注部署和擴容的便利性;同時,還須要考慮如何實現某種程度容災需求。因爲多進程協同工做,也帶來了開發的複雜度,這也是須要關注的問題。

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

  • 對於遊戲數據和玩家數據的存儲

  • 對玩家客戶端進行數據廣播

  • 把一部分遊戲邏輯在服務器上運算,便於遊戲更新內容,以及防止外掛。

針對以上的需求特徵,在服務器端軟件開發上,咱們每每會關注軟件對電腦內存和CPU的使用,以求在特定業務代碼下,能儘可能知足承載量和響應延遲的需求。最基本的作法就是「時空轉換」,用各類緩存的方式來開發程序,以求在CPU時間和內存空間上取得合適的平衡。在CPU和內存之上,是另一個約束因素:網卡。網絡帶寬直接限制了服務器的處理能力,因此遊戲服務器架構也一定要考慮這個因素。

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


[圖-遊戲服務器的分析模型]

三. 核心的三個架構

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

  • 內存架構:主要決定服務器如何使用內存,以保證儘可能少的內存泄漏的可能,以及最大化利用服務器端內存來提升承載量,下降服務延遲。

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

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

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

四. 遊戲服務器模型的進化歷程

最先的遊戲服務器是比較簡單的,如UO《網絡創世紀》的服務端一張3.5寸軟盤就能存下。基本上只是一個廣播和存儲文件的服務器程序。後來因爲國內的外掛、盜版流行,各遊戲廠商開始以MUD爲模型,創建主要運行邏輯在服務器端的架構。這種架構在MMORPG類產品的不斷更新中發揚光大,從而出現了以地圖、視野等分佈要素設計的分佈式遊戲服務器。而在另一個領域,休閒遊戲,自然的須要集中超高的在線用戶,因此全區型架構開始出現。現代的遊戲服務器架構,基本上都但願能結合承載量和擴展性的有點來設計,從而造成了更加豐富多樣的形態。
本文的討論主要是選取這些比較典型的遊戲服務器模型,分析其底層各類選擇的優勢和缺點,但願能探討出更具普遍性,更高開發效率的服務器模型。

分服模型

一. 模型描述

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

[圖-分服模型]

二. 調度架構

  1. 單進程遊戲服務器
    最簡單的遊戲服務器只有一個進程,是一個單點。這個進程若是退出,則整個遊戲世界消失。在此進程中,因爲須要處理併發的客戶端的數據包,所以產生了多種選擇方法:

    [圖-單進程調度模型]

    • 同步-動態多線程:每接收一個用戶會話,就創建一個線程。這個用戶會話每每就是由客戶端的TCP鏈接來表明,這樣每次從socket中調用讀取或寫出數據包的時候,均可以使用阻塞模式,編碼直觀而簡單。有多少個遊戲客戶端的鏈接,就有多少個線程。可是這個方案也有很明顯的缺點,就是服務器容易產生大量的線程,這對於內存佔用很差控制,同時線程切換也會形成CPU的性能損失。更重要的多線程下對同一塊數據的讀寫,須要處理鎖的問題,這可能讓代碼變的很是複雜,形成各類死鎖的BUG,影響服務器的穩定性。

    • 同步-多線程池:爲了節約線程的創建和釋放,創建了一個線程池。每一個用戶會話創建的時候,向線程池申請處理線程的使用。在用戶會話結束的時候,線程不退出,而是向線程池「釋放」對此線程的使用。線程池能很好的控制線程數量,能夠防止用戶暴漲下對服務器形成的鏈接衝擊,造成一種排隊進入的機制。可是線程池自己的實現比較複雜,而「申請」、「施放」線程的調用規則須要嚴格遵照,不然會出現線程泄露,耗盡線程池。

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

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

  2. 多進程遊戲服務器
    多進程的遊戲服務器系統,最先起源於對於性能問題需求。因爲單進程架構下,總會存在承載量的極限,越是複雜的遊戲,其單進程承載量就越低,所以開發者們必定要突破進程的限制,才能支撐更復雜的遊戲。
    一旦走上多進程之路,開發者們還發現了多進程系統的其餘一些好處:可以利用上多核CPU能力;利用操做系統的工具能更仔細的監控到運行狀態、更容易進行容災處理。多進程系統比較經典的模型是「三層架構」:
    在多進程架構下,開發者通常傾向於把每一個模塊的功能,都單獨開發成一個進程,而後以使用進程間通訊來協調處理完整的邏輯。這種思想是典型的「管道與過濾器」架構模式思想——把每一個進程當作是一個過濾器,用戶發來的數據包,流經多個過濾器銜接而成的管道,最後被完整的處理完。因爲使用了多進程,因此首選使用單進程單線程來構造其中的每一個進程。這樣對於程序開發來講,結構清晰簡單不少,也能得到更高的性能。

    [圖-經典的三層模型]

    儘管有不少好處,可是多進程系統還有一個須要特別注意的問題——數據存儲。因爲要保證數據的一致性,因此存儲進程通常都難以切分紅多個進程。就算對關係型數據作分庫分表處理,也是很是複雜的,對業務類型有依賴的。並且若是單個邏輯處理進程承載不了,因爲其內存中的數據難以分割和同步,開發者很難去平行的擴展某個特定業務邏輯。他們可能會選擇把業務邏輯進程作成無狀態的,可是這更加加劇了存儲進程的性能壓力,由於每次業務處理都要去存儲進程處拉取或寫入數據。
    除了數據的問題,多進程也架構也帶來了一系列運維和開發上的問題:首先就是整個系統的部署更爲複雜了,由於須要對多個不一樣類型進程進行鏈接配置,形成大量的配置文件須要管理;其次是因爲進程間通信不少,因此須要定義的協議也數量龐大,在單進程下一個函數調用解決的問題,在多進程下就要定義一套請求、應答的協議,這形成整個源代碼規模的數量級的增大;最後是整個系統被肢解爲不少個功能短小的代碼片斷,若是不瞭解總體結構,是很難理解一個完整的業務流程是如何被處理的,這讓代碼的閱讀和交接成本巨高無比,特別是在遊戲領域,因爲業務流程變化很是快,幾經修改後的系統,幾乎沒有人能徹底掌握其內容。

三. 內存架構

因爲服務器進程須要長期自動化運行,因此內存使用的穩定是首要大事。在服務器進程中,就算一個觸發概率很小的內存泄露,都會積累起來變成嚴重的運營事故。須要注意的是,無論你的線程和進程結構如何,內存架構都是須要的,除非是Erlang這種不使用堆的函數式語言。

  1. 動態內存
    在須要的時候申請內存來處理問題,是每一個程序員入門的時候必然要學會的技能。可是,如何控制內存釋放倒是一個大問題。在C/C++語言中,對於堆的控制相當重要。有一些開發者會以樹狀來規劃內存使用,就是通常只new/delete一個主要的類型的對象,其餘對象都是此對象的成員(或者指針成員),只要這棵樹上全部的對象都管理好本身的成員,就不會出現內存漏洞,整個結構也比較清晰簡單。

    [圖-對象樹架構]

    在Objective C語言中,有所謂autorealse的特性,這種特性其實是一種引用計數的技術。因爲能配合在某個調度模型下,因此使用起來會比較簡單。一樣的思想,有些開發者會使用一些智能指針,配合本身寫的框架,在完整的業務邏輯調用後一次性清理相關內存。

    [圖-根據業務處理調度管理內存池]

    在帶虛擬機的語言中,最多見的是JAVA,這個問題通常會簡單一些,由於有自動垃圾回收機制。可是,JAVA中的容器類型、以及static變量依然是可能形成內存泄露的緣由。加上無規劃的使用線程,也有可能形成內存的泄露——有些線程不會退出,並且在不斷增長,最後耗盡內存。因此這些問題都要求開發者專門針對static變量以及線程結構作統一設計、嚴格規範。

  2. 預分配內存
    動態分配內存在當心謹慎的程序員手上,是能發揮很好的效果的。可是遊戲業務每每須要用到的數據結構很是多,變化很是大,這致使了內存管理的風險很高。爲了比較完全的解決內存漏洞的問題,不少團隊採用了預先分配內存的結構。在服務器啓動的時候分配全部的變量,在運行過程當中不調用任何new關鍵字的代碼。

    這樣作的好處除了能夠有效減小內存漏洞的出現機率,也能下降動態分配內存所消耗的性能。同時因爲啓動時分配內存,若是硬件資源不夠的話,進程就會在啓動時失敗,而不是像動態分配內存的程序同樣,可能在任何一個分配內存的時候崩潰。然而,要得到這些好處,在編碼上首先仍是要遵循「動態分配架構」中對象樹的原則,把一類對象構造爲「根」對象,而後用一個內存池來管理這些根對象。而這個內存池能存放的根對象的數目,就是此服務進程的最大承載能力。一切都是在啓動的時候決定,很是的穩妥可靠。

    [圖-預分配內存池]

    不過這樣作,一樣有一些缺點:首先是不太好部署,好比你想在某個資源較小的虛擬機上部署一套用來測試,可能一位內沒改內存池的大小,致使啓動不成功。每次更換環境都須要修改這個配置。其次,是全部的用到的類對象,都要在根節點對象那裏有個指針或者引用,不然就可能泄漏內存。因爲對於非基本類型的對象,咱們通常不喜歡用拷貝的方式來做爲函數的參數和返回值,而指針和應用所指向的內存,若是不能new的話,只能是現成的某個對象的成員屬性。這回致使程序越複雜,這類的成員屬性就越多,這些屬性在代碼維護是一個不小的負擔。

    要解決以上的缺點,能夠修改內存池的實現,爲動態增加,可是具有上限的模型,每次從內存池中「獲取」對象的時候才new。這樣就能避免在小內存機器上啓動不了的問題。對於對象屬性複雜的問題,通常上須要好好的按面向對象的原則規劃代碼,作到儘可能少用僅僅表示函數參數和返回值的屬性,而是主要是記錄對象的「業務狀態」屬性爲主,多花點功夫在構建遊戲的數據模型上。

四. 進程間通信手段

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

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

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

    使用Socket編程的好處是通用性廣,你能夠用來實現任何的功能,和任何的進程進行協做。可是其缺點也異常明顯,就是開發量很大。雖然如今有一些開源組件,能夠幫你簡化Socket Server的編寫工做,簡化鏈接管理和消息分發的處理,可是選擇目標創建鏈接、定製協議編解碼這兩個工做每每仍是要本身去作。遊戲的特色是業務邏輯變化不少,致使協議修改的工做量很是大。所以咱們除了直接使用TCP/IP socket之外,還有不少其餘的方案能夠嘗試。

    [圖-TCP通信]

  2. 消息隊列
    在多進程系統中,若是進程的種類比較多,並且變化比較快,大量編寫和配置進程之間的鏈接是一件很是繁瑣的工做,因此開發者就發明了一種簡易的通信方法——消息隊列。這種方法的底層仍是Socket通信實現,可是使用者只須要好像投遞信件同樣,把消息包投遞到某個「信箱」,也就是隊列裏,目標進程則自動不斷去「收取」屬於本身的「信件」,而後觸發業務處理。

    這種模型的好處是很是簡單易懂,使用者只須要處理「投遞」和「收取」兩個操做便可,對於消息也只須要處理「編碼」和「解碼」兩個部分。在J2EE規範中,就有定義一套消息隊列的規範,叫JMS,Apache ActiveMQ就是一個應用普遍的實現者。在Linux環境下,咱們還能夠利用共享內存,來承擔消息隊列的存儲器,這樣不但性能很高,並且還不怕進程崩潰致使未處理消息丟失。

    [圖-消息隊列]

    須要注意的是,有些開發者缺少經驗,使用了數據庫,如MySQL,或者是NFS這類運行效率比較低的媒介做爲隊列的存儲者。這在功能上雖然能夠行得通,可是操做一頻繁,就難以發揮做用了。如之前有一些手機短信應用系統,就用MySQL來存儲「待發送」的短信。

    消息隊列雖然很是好用,可是咱們仍是要本身對消息進行編解碼,而且分發給所須要的處理程序。在消息處處理程序之間,存在着一個轉換和對應的工做。因爲遊戲邏輯的繁多,這種對應工做徹底靠手工編碼,是比較容易出錯的。因此這裏還有進一步的改進空間。

  3. 遠程調用
    有一些開發者會但願,在編碼的時候徹底屏蔽是否跨進程在進行調用,徹底能夠好像調用本地函數或者本地對象的方法同樣。因而誕生了不少遠程調用的方案,最經典的有Corba方案,它試圖實現能在不一樣語言的代碼直接,實現遠程調用。JAVA虛擬機自帶了RMI方案的支持,在JAVA進程之間遠程調用是比較方便的。在互聯網的環境下,還有各類Web Service方案,以HTTP協議做爲承載,WSDL做爲接口描述。

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


    [圖-遠程調用]

五. 容災和擴容手段

在多進程模型中,因爲能夠採用多臺物理服務器來部署服務進程,因此爲容災和擴容提供了基礎條件。

在單進程模型下,容災經常使用的熱備服務器,依然能夠在多進程模型中使用,可是開着一臺什麼都不作的服務器徹底是爲了作容災,多少有點浪費。因此在多進程環境下,咱們會啓動多個相同功能的服務器進程,在請求的時候,根據某種規則來肯定對哪一個服務進程發起請求。若是這種規則能規避訪問那些「失效」了的服務進程,就自動實現了容災,若是這個規則還包括了「更新新增服務進程」的邏輯,就能夠作到很方便的擴容了。而這兩個規則,統一塊兒來就是一條:對服務進程狀態的集中保存和更新。

爲了實現上面的方案,經常會架設一個「目錄」服務器進程。這個進程專門負責蒐集服務器進程的狀態,而且提供查詢。ZooKeeper就是實現這種目錄服務器的一個優秀工具。

[圖-服務器狀態管理]

儘管用簡單的目錄服務器能夠實現大部分容災和擴容的需求,可是若是被訪問進程的內存中有數據存在,那麼問題就比較複雜了。對於容災來講,新的進程必需要有辦法重建那個「失效」了的進程內存中的數據,纔可能完成容災功能;對於擴容功能來講,新加入的進程,也必須能把須要的數據載入到本身的內存中才行,而這些數據,可能已經存在於其餘平行的進程中,如何把這部分數據轉移過來,是一個比較耗費性能和須要編寫至關多代碼的工做。——因此通常咱們喜歡對「無狀態」的進程來作擴容和容災。

相關文章
相關標籤/搜索