1. 程式人生 > >網路遊戲伺服器架構設計

網路遊戲伺服器架構設計

入手

假如,我現在接手一個新專案,我的身份還是主程式。在下屬人員一一到位之前,在和製作人以及主策劃充分溝通後,我需要先獨自思考以下問題:

1、伺服器跑在什麼樣的作業系統環境下?
2、採用哪幾種語言開發?主要是什麼?
3、伺服器和客戶端以什麼樣的介面通訊?
4、採用哪些第三方的類庫?

除了技術背景之外,考慮這些問題的時候一定要充分考慮專案需求和所能擁有的資源。

我覺得,先不要想一組需要幾臺機器各有什麼功能這樣的問題,也不要想需要多少個daemon程序。假設就一臺伺服器,就一個程序,把所需要的資源往最小了考慮,把架構往最簡單的方向想,直到發現,“哦,這麼做無法滿足策劃要求的併發量”,再去修改設計方案。

作業系統:越單一越好。雖然FreeBSD的網路效能更好、雖然Solaris非常穩定,但選什麼就是什麼,最好別混著來。前端是FreeBSD,後端是Solaris,運營的人會苦死。也不要瞧不起用Windows的人,用Windows照樣也能支援一組一萬人線上,總之,能滿足策劃需求,好招程式設計師,運營成本低是要點。不同的作業系統有不同的特性,如果你真的對它們都很熟悉,那麼必定能找到一個理由,一個足夠充分的理由讓你選擇A而不是B而不是C。但做決策的時候要注意不要因小失大。

Programming Language:傳統來說,基本都是C/C++。但是你也知道,這東西門檻很高,好的C/C++程式設計師很難招。用Perl/Python/Lua行不行?當然可以。但是純指令碼也不好,通常來說是混合著來。你要明白哪些是關鍵部分,我是說執行次數最多的地方而不是說元寶,這些必須用效能高的語言實現(比如C/C++比如Java),其它像節日活動這樣很久才執行一次的,隨便吧。指令碼的好處是,可以快速搭原型。所以,儘早的,在你做完基本的地圖和戰鬥模組之後,立馬跑機器人測試吞吐量。這時候專案開發進度還不到10%,不行就趕緊改。
此處特別舉個例子就是Java GC的問題。既然你要用java,而jvm需要通過執行garbage collection來回收記憶體,而garbage collection會使整個應用停頓,那你不妨試一試,記憶體在達到峰值的時候會停多久?策劃可以接受嗎?如果不可以,你可以採用其它的GC策略再試一試。這個問題應該不是Java獨有的。網遊和網站應用相比它很注重流暢性。這是你務必需要考慮的。

至於選擇什麼樣的指令碼語言,以及指令碼在你的遊戲中究竟是佔80%還是20%?需要根據需求來看。有沒有遊戲完全不用指令碼?有。有沒有遊戲濫用指令碼?也有。如果你引入指令碼的目的是因為策劃不會C/C++而你希望策劃能自己獨立實現更多的遊戲功能。你希望策劃去寫指令碼?指令碼也是程式,策劃寫的指令碼難道就比程式設計師寫指令碼好?還是因為策劃工資便宜?策劃因為指令碼寫錯了導致大故障還少嗎(此處特別以網易的產品舉例)?綜合權衡下,還是算了吧。問問你一起工作的程式設計師哥們兒,他們最喜歡什麼語言,什麼用起來最順手,就用什麼當指令碼。注意不光要考慮開發速度快,還要考慮除錯方便。

總體來說,作業系統和程式語言的選擇,隨大流即可。標新立異沒什麼好處。小地方的實現你可以玩玩,整體還是要越保守越好。

通訊

然後說通訊的問題。伺服器和客戶端怎麼連線上的?

往最下面看,物理和鏈路層。有可能是乙太網,有可能是ADSL,在北京還有很多像歌華寬頻這樣的採用75歐同軸電纜或者電力線上網的。你不要企圖在這一層做什麼優化,你要充分考慮的是不同的網路傳輸媒質網路延遲不一樣。更噁心的是你正常的資料包可能會被某些網咖的SB路由器當做P2P資料包給封掉,或是甚至被解析成Wake-On-Lan這樣的含義。楊建還會給你講,什麼是MTU,把資料包限制在多大才能儘量讓請求在一個包內發完。是的,這些很精細的東西,等咱遊戲做的差不多了再慢慢研究。先略過。

往上看,IP層。再往上,你要考慮用TCP還是UDP或是二者混合。UDP的優勢是overhead小、延遲低,典型的用例就是《天下貳》,據說是純UDP。再比如《龍之谷》,據說是有小部分是UDP。負面的一點呢,就是它太過於簡單所以用起來太過於複雜。你要是對自己沒信心,TCP吧,隨大流就好。

往上,採用什麼樣的應用協議。大多數rpc協議都是既支援TCP又支援UDP的。我所用過的有sun rpc、corba、webservice、json、java RMI以及一些專有協議。如果你有精力,還是自己搞一套吧,網遊所用的東西,還是越專有越好,給抓包做外掛的人加一點門檻。這裡非常強調的一點,你採用什麼樣的序列化方式與你採用什麼樣的網路協議是無關的,你的應用協議和你傳輸協議應該也是無關的(既支援TCP又支援UDP的)。如果做框架的人把自己限制的太死或者耦合太緊,那麼用框架的人會非常痛苦。所以,沒必要在此為了效能做過多優化。結構簡單清晰是王道。

很多人對網路開發的認識還停留在定義一個struct、memcpy到socket buffer、send,然後一個勁的給別人強調遇到指標怎麼辦、陣列的長度不能超過多少、整個包的長度不能超過多少等等。序列化其實是面向物件程式設計的一個很核心的要素。連glib/gtk/Berkeley DB這些純C的框架都是基於OOP設計的,所以我覺得您就算是C程式設計師也沒必要排斥它。我講這個是說,你應當做應用的人儘可能的避免用memcpy/memset這樣的方式初始化資料、傳送資料。如果你是C程式設計師,你多提供一些g_object_new這樣的函式;如果你是C++程式設計師,寫好你的構造和解構函式;如果你是JAVA程式設計師還死活不懂OOP,那算了吧,改行吧。

網路這一層有些很精妙的東西,尤其是當你規模擴大需要分散式擴充套件的時候。你想想看為什麼sun rpc需要先去rpcbind詢問一次然後才連真正的程序呢?RMI返回的時候為什麼需要同時返回IP和埠號呢?web service那麼通用,大部分瀏覽器都支援直接從瀏覽器呼叫web service那麼為什麼主流的方式卻是json呢?

sun rpc是所有RPC機制中歷史最久的吧?它在設計第一版的時候,每個rpc呼叫都是由一問一答來組成,稱為two-way messaging。客戶端在發出請求之後,一直等伺服器的答覆,如果一直到指定時間後依然沒收到答覆,那麼執行timeout邏輯。在第一個請求收到答覆(或者timeout)之前,無法發起第二個答覆。直到某一天,Sun的程式發現他們需要非同步處理一些事情,於是設計了one-way messaging,客戶端在發起請求的時候,只要把這個東西塞到本地的IO佇列裡,就返回。但是如果socket buffer滿了怎麼辦?還是會等在那裡。於是覺得這個還不徹底,於是又做了Non-Blocking Messaging,在kernel的socket buffer前面加了一個使用者態的rpc buffer,大多數時候它都是空的,當socket buffer堆滿了的時候,再往這裡面塞。如果這個buffer也滿了怎麼辦?我覺得無非就三種處理手段:

1、阻塞。如果這麼做,就是說本來是套非阻塞的設計但是某些情況下還是會阻塞?那麼給用的人解釋起來太麻煩用起來也太麻煩。算了。
2、悄然丟棄。 不是所有的資料都可以丟。聊天的無所謂,但是交易的就不行。所以需要在訊息型別上加判斷。
3、關閉連線。 最簡單粗暴,卻也最有效。

在使用two-way messaging的時候,一定要記住設定超時,省得像某些傻瓜一樣因為一個請求把整個server堵死。但是我覺得timeout設多久完全是個經驗值,太大了沒作用,太小了失敗的太多。

至少在有一點我們可以大鬆一口氣,就是不用擔心資料量大到需要多網絡卡同時分擔中斷。通常來說網路遊戲的流量都是很小的,對玩家來說一個56K的貓或者128K的DSL就夠了。如果你的策劃給你提了一個很BT的需求導致要耗費大量頻寬,那麼你最好把這個應用分到單獨的tcp 連線上,省得因為它阻塞而導致關鍵的業務(比如地圖訊息)停滯。

我一直想把rpc的部分實現塞到kernel裡。對客戶端的好處是增加了逆向工程的成本,對伺服器的好處是閘道器可以很高效。就像LVS那樣,前端收完包之後在kernel裡處理完然後立刻轉出去,不用切換到使用者態。而GameServer處理完之後,甚至不用經過閘道器,直接回復。目的不在於分擔閘道器的壓力,而是說降低響應延遲。就算讓GameServer承擔部分加密和壓縮的計算量,它的CPU也足夠用。

不過對於網遊,考慮動態擴容為時太早。一般都是新開幾組伺服器。

資料

我在做伺服器安裝包的時候,分的很清楚:程式、配置檔案、資料庫。

程式,就是編譯好的二進位制檔案。最好是全靜態編譯,因為它簡單。動態連結的優點以及其它一些高階話題我後面講,但是通常來說,動態的複雜的結構得不償失。

配置檔案總體來說可以分為文字檔案和二進位制檔案(廢話)。文字檔案的好處是開發過程中易於除錯和修改,最終釋出後也易於追蹤問題。二進位制檔案的好處是小、精巧、不易把資訊洩露給外人知道。java的打jar包的技術算是一個折衷的優勢吧?我最看重的是易於除錯和修改,所以基本都用文字檔案。而這其中,表現力最強的就是xml,所以基本都是xml。

但是xml多了怎麼管理就是個問題。我得整理份文件,每個xml都是什麼格式,做什麼用途的,最好每個xml再寫一個xsd。事實是配置檔案是隨著需求變化最頻繁的部分,而換個角度說我之前強調的序列化。所以,正確的思路是這樣:

1、程式設計師分析需求文件,確定需要什麼樣的物件來表示配置
2、某套序列化框架,它利用某種xml解析庫把xml變成記憶體中的物件
3、策劃提供xml

只要這個框架做的好,根本不需要文件或xsd來描述xml。我這裡說策劃提供xml,那麼策劃怎麼提供xml呢?按照我所看見的策劃的習慣,他們最喜歡的是兩種方式:

1、對於結構簡單的資料,編輯excel表
2、對於結構複雜的(如涉及樹、環的),提供專門的編輯工具

對於1,我們可以給excel做plugin,或者做一個工具從excel表匯出成xml。對於2,讓編輯工具可以匯出成xml。但是最終很重要很重要很重要的一點就是要讓所有的工具整合在一起,做好版本管理以及跨版本diff和merge。如何管理資料要比如何定義資料如何描述資料更難更重要。

很多同事和我的共識都是:要做一款好遊戲,工具很重要。多個專案做完後,外人能看見的最大的積累就是工具和流程。

資料庫

資料庫在遊戲中的重要性,是一個很令人玩味的東西。你可以聽見很多人告訴你說,我們做遊戲根本不需要資料庫。是的,像單機遊戲那樣,在某個目錄下建立一個檔案,save/load就行了。這就是我所看到的當今的大型網遊的主流做法。

哦,你要反對了。你說你知道某某遊戲用的是mysql,某某遊戲用的是oracle,等等。是的,你手上的資訊可能比我多很多很多倍,但是關鍵點在於,資料庫在整個系統中的角色到底是什麼?

典型的場景是這樣:啟動一個單獨的程序稱之為DB Gate。當用戶登入的時候,邏輯伺服器找DB Gate要資料,DB Gate沒有於是就去找後面的Mysql要,然後讀過來之後就放在這裡,DB Gate就是一個類似於memcached的東西。所以後面無論是用mysql還是oracle還是plain text都可以,但實際上會在其它方面有些細微的差別。

它和網站應用相比,資料更容易做cache,把握好上線和下線這兩個點即可,cache的命中率很容易達到4個9或者更高。但是從另一個方面,網路遊戲的資料關聯邏輯遠遠比網站複雜,而且對原子性、一致性、隔離性要求更高。現在是你自己來管理cache,於是併發控制就沒辦法交給資料庫來做。

問題一:我不自己做cache,我就直接讀寫資料庫。就像php+mysql那樣,中間也不套memcache,行不行? 我不知道。你可以試一試。

問題二:SQL or NoSQL ? 我還是回答不了。你做個demo跑機器人試一試。

總之,東西是活的。沒有必要非要怎麼著非不能怎麼著。檢驗的標準很簡單:1、是否完成了策劃提出的功能需求 2、效率是否達到了預期目標

對於第一個,QA和策劃都會去檢查。對於2,跑機器人以及封測期間調優是王道。

對於資料庫開發,我還是很強調面向物件那套觀點。把資料庫裡的表對映到物件,把物件抽象成介面,每個模組以介面對外提供服務,不同模組不要直接通過表共享資料。或者,你可以讀我的表,但不要寫!因為資料的約束條件未必是可以由DBMS完全保證的,某些約束是難以用資料庫本身的語言表述的。

資料是網遊的核心,網遊基本都是資料驅動的,所以數值策劃才會這麼吃香。

或者換個角度想,DBMS它是什麼?

1、它管理資料。幫助我們高效的讀取和修改資料。因為資料的動態性,所以我們需要Btree這樣的結構,而不是隨便找個TXT追加寫。但是換個角度想,網路遊戲有什麼特點?插入多,但是刪除操作極少極少。那麼是否可以採用其它的結構呢?順序重要嗎?為什麼不用Hash呢?

2、它負責備份和恢復資料。這基本是任何現代的資料庫系統必須提供的基本功能。但是網路遊戲又特殊一點,它要求能按指定時間“回檔”。時間可以有半小時的誤差,但是這個功能必須有。於是資料庫能支援增量備份,或者它的備份能支援版本很重要。

3、它使用logging system保證在突然宕機的時候資料依然是完整和一致的。可是如果我們要自己做cache,那麼就要求我們在應用層面所做的原子性保證必須在cache中也能體現出來。這些cache要麼全刷,要麼全不刷。

4、它提供併發功能。拿傳統的php+mysql架構來說,為什麼同一個應用可以被分散式的部署在多臺機器上?魔力就在資料庫上。

既然有人輕視資料庫,那麼也可反其道重視資料庫。把90%的邏輯都放在資料庫裡完成。多招一些熟悉SQL熟悉儲存過程的,主要的邏輯都由他們完成。

併發

接著說我在併發上的考慮。

一臺機器還是多臺機器?單程序還是多程序?單執行緒還是多執行緒?等等。

我覺得併發問題是最沒章法可循的問題。你可以這麼做也可以那麼做。網路遊戲的重點是在邏輯開發上,而做邏輯開發的人不要關心到底是epoll還是select。總之制定框架的時候需要定好一個規矩:單執行緒還是多執行緒、訪問哪些資料的時候需要加鎖(可能還需要跨程序的加鎖)、誰來做load balancer、如果有一臺機器宕了怎麼辦、哪些任務必須要以特定的順序執行,等等。規矩定下來,一切都順了。可這個規矩要足夠的簡單。

如果是多執行緒,我想過兩種模式:Thread per Connection和Task based thread pool。現在機器的記憶體越來越大了,所以前者的開銷是可以忍受的,1000人線上,就算每個執行緒要被系統佔去2M,那麼也才2G。而一般的3D遊戲做個 3-4千人線上就行了,配個大記憶體的機器,還剩下足夠多的記憶體給應用使用。多簡單啊!網路遊戲中,很多請求都是隻需要訪問單個角色的資料就夠了,反過來說很多資料都可以做成Thread Local的,免去了同步代價。

而Task based thread pool的伸縮性相對來說就好的多,但是併發問題也麻煩一些,況且從rpc請求被unmarshal完到扔到task pool裡面又多了一次執行緒切換,如果換成Leader-Follower那樣的模式,少了切換但是模型又更復雜了一些。

如果是單執行緒的,那麼一切都是事件驅動的並且事件的處理都是非阻塞的。那麼就得避開資料庫讀寫或者在處理的過程中再產生新的rpc請求,否則非常麻煩。

併發問題的瓶頸往往是在於怎麼降低鎖衝突上。Task Pool裡面的所有執行緒都在執行Task,但是都在等同一把鎖,多悲劇啊。難點在於降低模組耦合、採用適當的排隊機制等等。我覺得這裡沒有什麼萬金油,降低模組耦合本來就沒什麼套路可循,而排隊機制有很多種,沒有最好的,各有利弊。

對於死鎖,我的容忍度比以前大了很多。我覺得每臺機器每天的死鎖數量在10個以內都是可以忍受的,要有死鎖檢測、打斷機制並且重做的時候不會產生副作用。對玩家的感受而言就是突然卡了一下,可是網路不也經常會突然卡一下嗎?不頻繁就好。

我最鍾愛的模式就是“生產者-消費者”模式,萬能的利器。例如Task Pool就是基於這樣的模式。它的核心東西無非就是一個佇列,如果要支援定時,那麼就是一個優先佇列(deadline time作為優先順序)。講個細節,我面試的時候問了很多面試者,優先佇列應該用什麼樣的資料結構實現,結果都挺讓我失望的。

順便發個牢騷,Sun JDK的executor的實現,BUG太多了。還那麼巧,都被我遇上了?

其它

說些雜七雜八的東西吧。

我剛入行的時候就一直在問,為什麼網遊伺服器經常要停機維護?為什麼經常都是好幾個小時?為什麼非要分成不同組的伺服器並且資料基本不互通?為什麼不構造一個大世界把所有玩家放在一起?

我現在不問了,這些問題基本都找到了答案。不是技術做不到,而且有很多它以外的東西在左右這些。至少我在盡力不回檔這件事情上已經做的比較好了。

我想說的就是,入這行就得遵守這行的規矩。如果你是個老手了,根本沒必要來看我這一系列的P話。如果你是新手,那麼我是在向你介紹現狀。策劃是甲方,我們是乙方,在盡力滿足策劃的需求且不會顯著增加成本的前提下做有限的創新,這是我給自己定的設計原則。

(支付寶剛通知我,我又收到了5塊錢的捐贈。謝謝,謝謝大家)

如果你是一個受過良好訓練的程式設計師,那麼以下基本規則是懂的:

1、不要把需要翻譯的常量字串寫在程式碼裡

2、不要直接在程式碼中間寫498595這樣的magic number

3、向版本控制系統提交程式碼的時候應該寫註釋

4、需求是經常變的,並且經常是災難性的

可往往知道是一回事兒,做又是另外一回事。尤其是不要相信策劃那張嘴,寫成word文件才算數。

和大家分享一些我在版本控制上的經驗和教訓。

最早接觸這個問題,是在sina的時候,由QA部門的同事以及周琦單獨專門給我講jira、svn。當時受益很大。

周琦一再給我強調,在產品生命週期中,原始碼版本管理和釋出部署是獨立的兩套東西。原始碼版本管理是用subversion這樣的東西來做(更早一點我們還在用cvs)。釋出部署,一是編譯的過程,二是對外推送部署的過程,是一套相對獨立的東西。周琦的特色在於他把這二者通過svn hook指令碼的方式給自動串起來了。

我一直想要做一套OBS這樣的東西找一臺伺服器專門作build server,可惜一直沒時間去寫。就自己寫了一個指令碼(本來是sh的,後來成perl,後來成groovy),它的作用是根據分支名和版本號從subversion下載程式碼,然後編譯,然後放到指定位置。然後通知釋出伺服器從那裡拿東西推到外邊。缺點它缺乏併發控制,並且沒有UI介面。導致做完之後就成個人專屬的了。

為什麼每次要選擇一個空目錄checkout然後編譯,而不是在上次的基礎上svn up然後編譯?這個和Java/Ant有點關係。在寫Makefile的時候,儘管可以指定把當前目錄下的.cpp檔案全部都編譯,但是這是不推薦的做法。因為相比於寫程式碼的時間,把程式碼檔案新增到Makefile中的時間可以忽略不計。而我當時給ant寫build.xml時,是用**/*.java的方式去匹配,於是把src下的所有能編譯的全編譯了。可我在編譯之前會執行一些指令碼用於生成一些程式碼,某些是單獨存放的,但是某些和其它手寫的程式碼放在了一起。所以為了保持最終的jar包乾淨,寧可犧牲編譯的時間。

在提供給QA的測試環境中可以很方便的通過GM指令得到版本號,這個是編譯的時候打包工具寫進去的。而編譯系統務必保證相同版本號的東西每次編譯出來都是相同的東西。雖然二進位制比對結果可能不一致,但是邏輯功能上是一致的。

對於svn的分支管理,有兩種普遍策略:

1、每個人一個單獨的分支。做完自己的功能後往主幹merge

2、都在主幹上工作。需要發版本的時候建立新分支。

前一種需要大家都比較熟悉svn的用法,熟悉版本管理的基本概念。後一種則把所有活堆給一個專門發版本的人。他來建立分支,他來merge(或是誰的功能誰merge)。並且這樣的話,絕大多數程式碼是不需要merge的,所以我根據實際情況選擇了後一種。

於是在正在執行的系統中發現bug的時候,立馬獲取版本號,從那個版本上建立分支並且把分支名喊一聲告訴大家,然後找問題,把補丁merge到過去,編譯,釋出,測試,推到外面。

發版本很累,這件事情在去年秋天上線後,一直到春節,佔去了我90%的精力。其中最重要的就是比對功能和bug列表。經常,你分不清楚這到底算是一個bug呢,還是提需求的時候就沒說清楚所以這是一個新功能,反正都列一起的。挨個和svn提交記錄比對。

部署也是一個很有講究的過程。我的原則是,先刪除老的程式和配置檔案,然後複製新的過去,資料庫的資料和日誌檔案保留,審計日誌保留。這件事情本來還爭論過老的要不要刪,可不可以直接覆蓋,最終他們答應了我的需求。過程挺曲折的,中間有很多噁心的細節問題,比如NFS的本地cache的問題。

對於資料庫,我們能智慧的感知資料庫結構更改並自動生成升級指令碼(天哪,我這算不算洩密)。這居然也是一把雙刃劍。優點是減輕了開發人員的工作量,缺點是更改資料庫變得太隨意,隨意的添表添欄位導致資料膨脹的厲害。

我的遺憾是沒有把上面這些東西和資料編輯器串起來。那麼做有點是數值策劃調整資料更容易看到真實效果,缺點是也很容易亂來。如果這中間要經過svn,那麼太慢太曲折。如果這中間不經過svn,那麼鬼知道他們現在測的是什麼版本的東西,他經常會發現最終出去的東西跟他當時測的還是不一樣,畢竟,是很多人在同一個伺服器上測試。很難給他們解釋這個事情。

所以我當時還漏了一個東西一直想做但是沒做,就是一個很簡單的web gui能讓所有策劃自己啟動、停止伺服器,自己編譯、同步資料。各弄各的,互不干擾。但是吧,策劃畢竟是策劃,它們缺乏基本的QA知識。他們不明白為什麼一個底層功能好好的怎麼突然就不好使了(因為上層某處要加新功能,所以底下的程式碼要重構),他們不明白為了一個bug被改掉之後反覆又出現了,甚至對於分支和版本號這個東西,絕大多數策劃都理解起來困難。但是整個產品的開發、釋出模型就是這樣,所以這些概念必須從一開始就溝通好、貫徹好。相比而下,這些倒和美術沒什麼事兒。

都是些小活兒。

另外我一直在想要不要在配置檔案和game server之間套一個gconf這樣的東西,外部更改配置,gconf通知listener也就是game server,呃,一個很不成熟的想法。

另外很多人一直想,在不重啟程序的情況下,替換掉映像中的某個函式,修BUG。如果這個daemon程式是用C/C++寫的,這個時候用dlopen載入一個so,設定一個引數就可以了。如果是JAVA並且用JDWP開了DEBUG,那麼too easy。如果沒有,那麼unload jar/load jar吧。

我一直在構思一個可動態拆卸/替換/裝載的架構,一個簡單的不像OSGi那麼複雜的東西,可是想法一直不大成熟,因為沒有找到太簡單的方法。我的基本想法是有一個object container,把service抽象成object,service和serivce之間的互動都要去這個object container中通過name lookup的方式得到一個控制代碼,然後通訊。配置檔案不能視成一成不變的,它們也是動態資料的一部分,不能再通過靜態的getInstance獲得,也必須通過這個object container查詢。但是未必是一個global object container,每個module可以有自己的object container。或是module instance持有reference,請求派發給module,module派發給object的時候把需要的reference傳給過去,意思就是module就是一個object container,不過不是被lookup,而是主動構造好塞進去。

(暫且到這裡,想起來什麼再補充)

Please enable JavaScript to view the <a href="http://disqus.com/?ref_noscript">comments powered by Disqus.</a>