伺服器結構探討 -- 最簡單的結構 

  所謂伺服器結構,也就是如何將伺服器各部分合理地安排,以實現最初的功能需求。所以,結構本無所謂正確與錯誤;當然,優秀的結構更有助於系統的搭建,對系統的可擴充套件性及可維護性也有更大的幫助。 

  好的結構不是一蹴而就的,而且每個設計者心中的那把尺都不相同,所以這個優秀結構的定義也就沒有定論。在這裡,我們不打算對現有遊戲結構做評價,而是試著從頭開始搭建一個我們需要的MMOG結構。 

  對於一個最簡單的遊戲伺服器來說,它只需要能夠接受來自客戶端的連線請求,然後處理客戶端在遊戲世界中的移動及互動,也即遊戲邏輯處理即可。如果我們把這兩項功能整合到一個服務程序中,則最終的結構很簡單: 

  client ----- server 

  嗯,太簡單了點,這樣也敢叫伺服器結構?好吧,現在我們來往裡面稍稍加點東西,讓它看起來更像是伺服器結構一些。 

  一般來說,我們在接入遊戲伺服器的時候都會要提供一個帳號和密碼,驗證通過後才能進入。關於為什麼要提供使用者名稱和密碼才能進入的問題我們這裡不打算做過多討論,雲風曾對此也提出過類似的疑問,並給出了只用一個標識串就能進入的設想,有興趣的可以去看看他們的討論。但不管是採用何種方式進入,照目前看來我們的伺服器起碼得提供一個帳號驗證的功能。 

  我們把觀察點先集中在一個大區內。在大多數情況下,一個大區內都會有多組遊戲服,也就是多個遊戲世界可供選擇。簡單點來實現,我們完全可以拋棄這個大區的概念,認為一個大區也就是放在同一個機房的多臺伺服器組,各伺服器組間沒有什麼關係。這樣,我們可為每組伺服器單獨配備一臺登入服。最後的結構圖應該像這樣: 

  loginServer   gameServer 
     |           / 
     |         / 
     client 

  該結構下的玩家操作流程為,先選擇大區,再選擇大區下的某臺伺服器,即某個遊戲世界,點選進入後開始帳號驗證過程,驗證成功則進入了該遊戲世界。但是,如果玩家想要切換遊戲世界,他只能先退出當前遊戲世界,然後進入新的遊戲世界重新進行帳號驗證。 

  早期的遊戲大都採用的是這種結構,有些遊戲在實現時採用了一些技術手段使得在切換遊戲服時不需要再次驗證帳號,但整體結構還是未做改變。 

  該結構存在一個伺服器資源配置的問題。因為登入服處理的邏輯相對來說比較簡單,就是將玩家提交的帳號和密碼送到資料庫進行驗證,和生成會話金鑰傳送給遊戲服和客戶端,操作完成後連線就會立即斷開,而且玩家在以後的遊戲過程中不會再與登入服打任何交道。這樣處理短連線的過程使得系統在大多數情況下都是比較空閒的,但是在某些時候,由於請求比較密集,比如開新服的時候,登入服的負載又會比較大,甚至會處理不過來。 

  另外在實際的遊戲運營中,有些遊戲世界很火爆,而有些遊戲世界卻非常冷清,甚至沒有多少人玩的情況也是很常見的。所以,我們能否更合理地配置登入服資源,使得整個大區內的登入服可以共享就成了下一步改進的目標。 

伺服器結構探討 -- 登入服的負載均衡 

  回想一下我們在玩wow時的操作流程:執行wow.exe進入遊戲後,首先就會要求我們輸入使用者名稱和密碼進行驗證,驗證成功後才會出來遊戲世界列表,之後是排隊進入遊戲世界,開始遊戲... 

  可以看到跟前面的描述有個很明顯的不同,那就是要先驗證帳號再選擇遊戲世界。這種結構也就使得登入服不是固定配備給個遊戲世界,而是全區共有的。 

  我們可以試著從實際需求的角度來考慮一下這個問題。正如我們之前所描述過的那樣,登入服在大多數情況下都是比較空閒的,也許我們的一個擁有20個遊戲世界的大區僅僅使用10臺或更少的登入服即可滿足需求。而當在開新區的時候,或許要配備40臺登入服才能應付那如潮水般湧入的玩家登入請求。所以,登入服在設計上應該能滿足這種動態增刪的需求,我們可以在任何時候為大區增加或減少登入服的部署。 

  當然,在這裡也不會存在要求新增太多登入服的情況。還是拿開新區的情況來說,即使新增加登入服滿足了玩家登入的請求,遊戲世界服的承載能力依然有限,玩家一樣只能在排隊系統中等待,或者是進入到遊戲世界中導致大家都卡。 

  另外,當我們在增加或移除登入服的時候不應該需要對遊戲世界服有所改動,也不會要求重啟世界服,當然也不應該要求客戶端有什麼更新或者修改,一切都是在背後自動完成。 

  最後,有關資料持久化的問題也在這裡考慮一下。一般來說,使用現有的商業資料庫系統比自己手工技術先進要明智得多。我們需要持久化的資料有玩家的帳號及密碼,玩家建立的角色相關資訊,另外還有一些遊戲世界全域性共有資料也需要持久化。 

  好了,需求已經提出來了,現在來考慮如何將其實現。 

  對於負載均衡來說,已有了成熟的解決方案。一般最常用,也最簡單部署的應該是基於DNS的負載均衡系統了,其通過在DNS中為一個域名配置多個IP地址來實現。最新的DNS服務已實現了根據伺服器系統狀態來實現的動態負載均衡,也就是實現了真正意義上的負載均衡,這樣也就有效地解決了當某臺登入服當機後,DNS伺服器不能立即做出反應的問題。當然,如果找不到這樣的解決方案,自己從頭打造一個也並不難。而且,通過DNS來實現的負載均衡已經包含了所做的修改對登入服及客戶端的透明。 

  而對於資料庫的應用,在這種結構下,登入服及遊戲世界服都會需要連線資料庫。從資料庫伺服器的部署上來說,可以將帳號和角色資料都放在一箇中心資料庫中,也可分為兩個不同的庫分別來處理,基到從物理上分到兩臺不同的伺服器上去也行。 

  但是對於不同的遊戲世界來說,其角色及遊戲內資料都是互相獨立的,所以一般情況下也就為每個遊戲世界單獨配備一臺資料庫伺服器,以減輕資料庫的壓力。所以,整體的伺服器結構應該是一個大區有一臺帳號資料庫伺服器,所有的登入服都連線到這裡。而每個遊戲世界都有自己的遊戲資料庫伺服器,只允許本遊戲世界內的伺服器連線。 

  最後,我們的伺服器結構就像這樣: 

               大區伺服器        
          /     |       \ 
            /       |        \ 
            登入服1   登入服2   世界服1   世界服2 
         \         |         |       |   
          \       |         |         | 
          帳號資料庫         DBS     DBS 

  這裡既然討論到了大區及帳號資料庫,所以順帶也說一下關於啟用大區的概念。wow中一共有八個大區,我們想要進入某個大區遊戲之前,必須到官網上啟用這個區,這是為什麼呢? 

  一般來說,在各個大區帳號資料庫之上還有一個總的帳號資料庫,我們可以稱它為中心資料庫。比如我們在官網上註冊了一個帳號,這時帳號資料是隻儲存在中心資料庫上的。而當我們要到一區去建立角色開始遊戲的時候,在一區的帳號資料庫中並沒有我們的帳號資料,所以,我們必須先到官網上做一次啟用操作。這個啟用的過程也就是從中心庫上把我們的帳號資料拷貝到所要到的大區帳號資料庫中。 

伺服器結構探討 -- 簡單的世界服實現 

  討論了這麼久我們一直都還沒有進入遊戲世界伺服器內部,現在就讓我們來窺探一下里面的結構吧。 

  對於現在大多數MMORPG來說,遊戲伺服器要處理的基本邏輯有移動、聊天、技能、物品、任務和生物等,另外還有地圖管理與訊息廣播來對其他高階功能做支撐。如縱隊、好友、公會、戰場和副本等,這些都是通過基本邏輯功能組合或擴充套件而成。 

  在所有這些基礎邏輯中,與我們要討論的伺服器結構關係最緊密的當屬地圖管理方式。決定了地圖的管理方式也就決定了我們的伺服器結構,我們仍然先從最簡單的實現方式開始說起。 

  回想一下我們曾戰鬥過無數個夜晚的暗黑破壞神,整個暗黑的世界被分為了若干個獨立的小地圖,當我們在地圖間穿越時,一般都要經過一個叫做傳送門的裝置。世界中有些地圖間雖然在地理上是直接相連的,但我們發現其遊戲內部的邏輯卻是完全隔離的。可以這樣認為,一塊地圖就是一個獨立的資料處理單元。 

  既然如此,我們就把每塊地圖都當作是一臺獨立的伺服器,他提供了在這塊地圖上游戲時的所有邏輯功能,至於內部結構如何劃分我們暫不理會,先把他當作一個黑盒子吧。 

  當兩個人合作做一件事時,我們可以以對等的關係相互協商著來做,而且一般也都不會有什麼問題。當人數增加到三個時,我們對等的合作關係可能會有些複雜,因為我們每個人都同時要與另兩個人合作協商。正如俗語所說的那樣,三個和尚可能會碰到沒水喝的情況。當人數繼續增加,情況就變得不那麼簡單了,我們得需要一個管理者來對我們的工作進行分工、協調。遊戲的地圖伺服器之間也是這麼回事。 

  一般來說,我們的遊戲世界不可能會只有一塊或者兩塊小地圖,那順理成章的,也就需要一個地圖管理者。先稱它為遊戲世界的中心伺服器吧,畢竟是管理者嘛,大家都以它為中心。 

  中心伺服器主要維護一張地圖ID到地圖伺服器地址的對映表。當我們要進入某張地圖時,會從中心服上取得該地圖的IP和port告訴客戶端,客戶端主動去連線,這樣進入他想要去的遊戲地圖。在整個遊戲過程中,客戶端始終只會與一臺地圖伺服器保持連線,當要切換地圖的時候,在獲取到新地圖的地址後,會先與當前地圖斷開連線,再進入新的地圖,這樣保證玩家資料在伺服器上只有一份。 

  我們來看看結構圖是怎樣的: 

              中心伺服器 
           /       \         \ 
         /         \         \ 
       登入服     地圖1     地圖2   地圖n 
         \         |         /       / 
           \       |         /       / 
                客戶端 

  很簡單,不是嗎。但是簡單並不表示功能上會有什麼損失,簡單也更不能表示遊戲不能賺錢。早期不少遊戲也確實採用的就是這種簡單結構。 

伺服器結構探討 -- 繼續世界服 

  都已經看出來了,這種每切換一次地圖就要重新連線伺服器的方式實在是不夠優雅,而且在實際遊戲運營中也發現,地圖切換導致的卡號,複製裝備等問題非常多,這裡完全就是一個事故多發地段,如何避免這種頻繁的連線操作呢? 

  最直接的方法就是把那個圖倒轉過來就行了。客戶端只需要連線到中心服上,所有到地圖伺服器的資料都由中心服來轉發。很完美的解決方案,不是嗎? 

  這種結構在實際的部署中也遇到了一些挑戰。對於一般的MMORPG伺服器來說,單臺伺服器的承載量平均在2000左右,如果你的伺服器很不幸地只能帶1000人,沒關係,不少遊戲都是如此;如果你的伺服器上跑了3000多玩家依然比較流暢,那你可以自豪地告訴你的策劃,多設計些大量消耗伺服器資源的玩法吧,比如大型國戰、公會戰爭等。 

  2000人,似乎我們的策劃朋友們不大願意接受這個數字。我們將地圖伺服器分開來原來也是想將負載分開,以多帶些客戶端,現在要所有的連線都從中心服上轉發,那連線數又遇到單臺伺服器的可最大承載量的瓶頸了。 

  這裡有必要再解釋下這個數字。我知道,有人一定會說,才帶2000人,那是你水平不行,我隨便寫個TCP伺服器都可帶個五六千連線。問題恰恰在於你是隨便寫的,而MMORPG的伺服器是複雜設計的。如果一個演示socket API用的echo伺服器就能滿足MMOG伺服器的需求,那寫伺服器該是件多麼愜意的事啊。 

  但我們所遇到的事實是,伺服器收到一個移動包後,要向周圍所有人廣播,而不是echo伺服器那樣簡單的迴應;伺服器在收到一個連線斷開通知時要向很多人通知玩家退出事件,並將該玩家的資料寫入資料庫,而不是echo伺服器那樣什麼都不需要做;伺服器在收到一個物品使用請求包後要做一系列的邏輯判斷以檢查玩家有沒有作弊;伺服器上還啟動著很多定時器用來更新遊戲世界的各種狀態...... 

  其實這麼一比較,我們也看出資源消耗的所在了:伺服器上大量的複雜的邏輯處理。再回過頭來看看我們想要實現的結構,我們既想要有一個唯一的入口,使得客戶端不用頻繁改變連線,又希望這個唯一入口的負載不會太大,以致於接受不了多少連線。 

  仔細看一看這個需求,我們想要的僅僅只是一臺管理連線的伺服器,並不打算讓他承擔太多的遊戲邏輯。既然如此,那五六千個連線也還有滿足我們的要求。至少在現在來說,一個遊戲世界內,也就是一組伺服器內同時有五六千個線上的玩家還是件讓人很興奮的事。事實上,在大多數遊戲的大部分時間裡,這個數字也是很讓人眼紅的。 

  什麼?你說夢幻、魔獸還有史先生的那個什麼征途遠不止這麼點人了!噢,我說的是大多數,是大多數,不包括那些明星。你知道大陸現在有多少遊戲在運營嗎?或許你又該說,我們不該在一開始就把自己的目標定的太低!好吧,我們還是先不談這個。 

  繼續我們的結構討論。一般來說,我們把這臺負責連線管理的伺服器稱為閘道器伺服器,因為內部的資料都要通過這個閘道器才能出去,不過從這臺伺服器提供的功能來看,稱其為反向代理伺服器可能更合適。我們也不在這個名字上糾纏了,就按大家通用的叫法,還是稱他為閘道器伺服器吧。 

  閘道器之後的結構我們依然可以採用之前描述的方案,只是,似乎並沒有必要為每一個地圖都開一個獨立的監聽埠了。我們可以試著對地圖進行一些劃分,由一個Master Server來管理一些更小的Zone Server,玩家通過閘道器連線到Master Server上,而實際與地圖有關的邏輯是分派給更小的Zone Server去處理。 

  最後的結構看起來大概是這樣的: 

         Zone Server         Zone Server 
                 \             / 
                 \           / 
                 Master Server           Master Server 
                     /       \                   / 
                   /         \                 / 
         Gateway Server         \               / 
             |         \         \             / 
             |         \         \           / 
             |               Center Server 
             | 
             | 
           Client 

伺服器結構探討 -- 最終的結構 

  如果我們就此打住,可能馬上就會有人要嗤之以鼻了,就這點古董級的技術也敢出來現。好吧,我們還是把之前留下的問題拿出來解決掉吧。 

  一般來說,當某一部分能力達不到我們的要求時,最簡單的解決方法就是在此多投入一點資源。既然想要更多的連線數,那就再加一臺閘道器伺服器吧。新增加了閘道器服後需要在大區服上做相應的支援,或者再簡單點,有一臺主要的閘道器服,當其負載較高時,主動將新到達的連線重定向到其他閘道器服上。 

  而對於遊戲服來說,有一臺還是多臺閘道器服是沒有什麼區別的。每個代表客戶端玩家的物件內部都保留一個代表其連線的物件,訊息廣播時要求每個玩家物件使用自己的連線物件傳送資料即可,至於連線是在什麼地方,那是完全透明的。當然,這只是一種簡單的實現,也是普通使用的一種方案,如果後期想對訊息廣播做一些優化的話,那可能才需要多考慮一下。 

  既然說到了優化,我們也稍稍考慮一下現在結構下可能採用的優化方案。 

  首先是當前的Zone Server要做的事情太多了,以至於他都處理不了多少連線。這其中最消耗系統資源的當屬生物的AI處理了,尤其是那些複雜的尋路演算法,所以我們可以考慮把這部分AI邏輯獨立出來,由一臺單獨的AI伺服器來承擔。 

  然後,我們可以試著把一些與地圖資料無關的公共邏輯放到Master Server上去實現,這樣Zone Server上只保留了與地圖資料緊密相關的邏輯,如生物管理,玩家移動和狀態更新等。 

  還有聊天處理邏輯,這部分與遊戲邏輯沒有任何關聯,我們也完全可以將其獨立出來,放到一臺單獨的聊天伺服器上去實現。 

  最後是資料庫了,為了減輕資料庫的壓力,提高資料請求的響應速度,我們可以在資料庫之前建立一個數據庫快取伺服器,將一些常用資料快取在此,伺服器與資料庫的通訊都要通過這臺伺服器進行代理。快取的資料會定時的寫入到後臺資料庫中。 

  好了,做完這些優化我們的伺服器結構大體也就定的差不多了,暫且也不再繼續深入,更細化的內容等到各個部分實現的時候再探討。 

  好比我們去看一場晚會,舞臺上演員們按著預定的節目單有序地上演著,但這就是整場晚會的全部嗎?顯然不止,在幕後還有太多太多的人在忙碌著,甚至在晚會前和晚會後都有。我們的遊戲伺服器也如此。 

  在之前描述的部分就如同舞臺上的演員,是我們能直接看到的,幕後的工作人員我們也來認識一下。 

  現實中有警察來維護秩序,遊戲中也如此,這就是我們常說的GM。GM可以採用跟普通玩家一樣的拉入方式來進入遊戲,當然許可權會比普通玩家高一些,也可以提供一臺GM伺服器專門用來處理GM命令,這樣可以有更高的安全性,GM服一般接在中心伺服器上。 

  在以時間收費的遊戲中,我們還需要一臺計費的伺服器,這臺伺服器一般接在閘道器伺服器上,註冊玩家登入和退出事件以記錄玩家的遊戲時間。 

  任何為使用者提供服務的地方都會有日誌記錄,遊戲伺服器當然也不例外。從記錄玩家登入的時間,地址,機器資訊到遊戲過程中的每一項操作都可以作為日誌記錄下來,以備查錯及資料探勘用。至於蒐集玩家機器資料所涉及到的法律問題不是我們該考慮的。 

  差不多就這麼多了吧,接下來我們會按照這個大致的結構來詳細討論各部分的實現。 

伺服器結構探討 -- 一點雜談 

  再強調一下,伺服器結構本無所謂好壞,只有是否適合自己。我們在前面探討了一些在現在的遊戲中見到過的結構,並盡我所知地分析了各自存在的一些問題和可以做的一些改進,希望其中沒有謬誤,如果能給大家也帶來些啟發那自然更好。 

  突然發現自己一旦羅嗦起來還真是沒完沒了。接下來先說說我在開發中遇到過的一些困惑和一基礎問題探討吧,這些問題可能有人與我一樣,也曾遇到過,或者正在被困擾中,而所要探討的這些基礎問題向來也是爭論比較多的,我們也不評價其中的好與壞,只做簡單的描述。 

  首先是伺服器作業系統,linux與windows之爭隨處可見,其實在大多數情況下這不是我們所能決定的,似乎各大公司也基本都有了自己的傳統,如網易的freebsd,騰訊的linux等。如果真有權利去選擇的話,選自己最熟悉的吧。 

  決定了OS也就基本上確定了網路IO模型,windows上的IOCP和linux下的epool,或者直接使用現有的網路框架,如ACE和asio等,其他還有些商業的網路庫在國內的使用好像沒有見到,不符合中國國情嘛。:) 

  然後是網路協議的選擇,以前的選擇大多傾向於UDP,為了可靠傳輸一般自己都會在上面實現一層封裝,而現在更普通的是直接採用本身就很可靠的TCP,或者TCP與UDP的混用。早期選擇UDP的主要原因還是頻寬限制,現在寬頻普通的情況下TCP比UDP多出來的一點點開銷與開發的便利性相比已經不算什麼了。當然,如果已有了成熟的可靠UDP庫,那也可以繼續使用著。 

  還有訊息包格式的定義,這個曾在雲風的blog上展開過激烈的爭論。訊息包格式定義包括三段,包長、訊息碼和包體,爭論的焦點在於應該是訊息碼在前還是包長在前,我們也把這個當作是信仰問題吧,有興趣的去雲風的blog上看看,論論。 

  另外早期有些遊戲的包格式定義是以特殊字元作分隔的,這樣一個好處是其中某個包出現錯誤後我們的遊戲還能繼續。但實際上,我覺得這是完全沒有必要的,真要出現這樣的錯誤,直接斷開這個客戶端的連線可能更安全。而且,以特殊字元做分隔的訊息包定義還加大了一點點網路資料量。 

  最後是一個純技術問題,有關socket連線數的最大限制。開始學習網路程式設計的時候我犯過這樣的錯誤,以為port的定義為unsigned short,所以想當然的認為伺服器的最大連線數為65535,這會是一個硬性的限制。而實際上,一個socket描述符在windows上的定義是unsigned int,因此要有限制那也是四十多億,放心好了。 

  在伺服器上port是監聽用的,想象這樣一種情況,web server在80埠上監聽,當一個連線到來時,系統會為這個連線分配一個socket控制代碼,同時與其在80埠上進行通訊;當另一個連線到來時,伺服器仍然在80埠與之通訊,只是分配的socket控制代碼不一樣。這個socket控制代碼才是描述每個連線的唯一標識。按windows網路程式設計第二版上的說法,這個上限值配置影響。 

  好了,廢話說完了,下一篇,我們開始進入登入服的設計吧。 

登入服的設計 -- 功能需求 

  正如我們在前面曾討論過的,登入服要實現的功能相當簡單,就是帳號驗證。為了便於描述,我們暫不引入那些討論過的優化手段,先以最簡單的方式實現,另外也將基本以mangos的程式碼作為參考來進行描述。 

  想象一下帳號驗證的實現方法,最容易的那就是把使用者輸入的明文用帳號和密碼直接發給登入服,伺服器根據帳號從資料庫中取出密碼,與使用者輸入的密碼相比較。 

  這個方法存在的安全隱患實在太大,明文的密碼傳輸太容易被截獲了。那我們試著在傳輸之前先加一下密,為了伺服器能進行密碼比較,我們應該採用一個可逆的加密演算法,在伺服器端把這個加密後的字串還原為原始的明文密碼,然後與資料庫密碼進行比較。既然是一個可逆的過程,那外掛製作者總有辦法知道我們的加密過程,所以,這個方法仍不夠安全。 

  哦,如果我們只是希望密碼不可能被還原出來,那還不容易嗎,使用一個不可逆的雜湊演算法就行了。使用者在登入時傳送給伺服器的是明文的帳號和經雜湊後的不可逆密碼串,伺服器取出密碼後也用同樣的演算法進行雜湊後再進行比較。比如,我們就用使用最廣泛的md5演算法吧。噢,不要管那個王小云的什麼論文,如果我真有那麼好的運氣,早中500w了,還用在這考慮該死的伺服器設計嗎? 

  似乎是一個很完美的方案,外掛製作者再也偷不到我們的密碼了。慢著,外掛偷密碼的目的是什麼?是為了能用我們的帳號進遊戲!如果我們總是用一種固定的演算法來對密碼做雜湊,那外掛只需要記住這個雜湊後的字串就行了,用這個做密碼就可以成功登入。 

  嗯,這個問題好解決,我們不要用固定的演算法進行雜湊就是了。只是,問題在於伺服器與客戶端採用的雜湊演算法得出的字串必須是相同的,或者是可驗證其是否匹配的。很幸運的是,偉大的數學字們早就為我們準備好了很多優秀的這類演算法,而且經理論和實踐都證明他們也確實是足夠安全的。 

  這其中之一是一個叫做SRP的演算法,全稱叫做Secure Remote Password,即安全遠端密碼。wow使用的是第6版,也就是SRP6演算法。有關其中的數學證明,如果有人能向我解釋清楚,並能讓我真正弄明白的話,我將非常感激。不過其程式碼實現步驟倒是並不複雜,mangos中的程式碼也還算清晰,我們也不再贅述。 

  登入服除了帳號驗證外還得提供另一項功能,就是在玩家的帳號驗證成功後返回給他一個伺服器列表讓他去選擇。這個列表的狀態要定時重新整理,可能有新的遊戲世界開放了,也可能有些遊戲世界非常不幸地停止運轉了,這些狀態的變化都要儘可能及時地讓玩家知道。不管發生了什麼事,使用者都有權利知道,特別是對於付過費的使用者來說,我們不該藏著掖著,不是嗎? 

  這個遊戲世界列表的功能將由大區服來提供,具體的結構我們在之前也描述過,這裡暫不做討論。登入服將從大區服上獲取到的遊戲世界列表發給已驗證通過的客戶端即可。好了,登入服要實現的功能就這些,很簡單,是吧。 

  確實是太簡單了,不過簡單的結構正好更適合我們來看一看遊戲伺服器內部的模組結構,以及一些伺服器共有元件的實現方法。這就留作下一篇吧。 

伺服器公共元件實現 -- mangos的遊戲主迴圈 

  當閱讀一項工程的原始碼時,我們大概會選擇從main函式開始,而當開始一項新的工程時,第一個寫下的函式大多也是main。那我們就先來看看,遊戲伺服器程式碼實現中,main函式都做了些什麼。 

  由於我在讀技術文章時最不喜看到的就是大段大段的程式碼,特別是那些直接Ctrl+C再Ctrl+V後未做任何修改的程式碼,用句時髦的話說,一點技術含量都沒有!所以在我們今後所要討論的內容中,儘量會避免出現直接的程式碼,在有些地方確實需要程式碼來表述時,也將會選擇使用偽碼。 

  先從mangos的登入服程式碼開始。mangos的登入服是一個單執行緒的結構,雖然在資料庫連線中可以開啟一個獨立的執行緒,但這個執行緒也只是對無返回結果的執行類SQL做緩衝,而對需要有返回結果的查詢類SQL還是在主邏輯執行緒中阻塞呼叫的。 

  登入服中唯一的這一個執行緒,也就是主迴圈執行緒對監聽的socket做select操作,為每個連線進來的客戶端讀取其上的資料並立即進行處理,直到伺服器收到SIGABRT或SIGBREAK訊號時結束。 

  所以,mangos登入服主迴圈的邏輯,也包括後面遊戲服的邏輯,主迴圈的關鍵程式碼其實是在SocketHandler中,也就是那個Select函式中。檢查所有的連線,對新到來的連線呼叫OnAccept方法,有資料到來的連線則呼叫OnRead方法,然後socket處理器自己定義對接收到的資料如何處理。 

  很簡單的結構,也比較容易理解。 


  只是,在對效能要求比較高的伺服器上,select一般不會是最好的選擇。如果我們使用windows平臺,那IOCP將是首選;如果是linux,epool將是不二選擇。我們也不打算討論基於IOCP或是基於epool的伺服器實現,如果僅僅只是要實現伺服器功能,很簡單的幾個API呼叫即可,而且網上已有很多好的教程;如果是要做一個成熟的網路伺服器產品,不是我幾篇簡單的技術介紹文章所能達到。 

  另外,在伺服器實現上,網路IO與邏輯處理一般會放在不同的執行緒中,以免耗時較長的IO過程阻塞住了需要立即反應的遊戲邏輯。 

  資料庫的處理也類似,會使用非同步的方式,也是避免耗時的查詢過程將遊戲伺服器主迴圈阻塞住。想象一下,因某個玩家上線而發起的一次資料庫查詢操作導致伺服器內所有線上玩家都卡住不動將是多麼恐怖的一件事! 

  另外還有一些如事件、指令碼、訊息佇列、狀態機、日誌和異常處理等公共元件,我們也會在接下來的時間裡進行探討。 

伺服器公共元件實現 -- 繼續來說主迴圈 

  前面我們只簡單瞭解了下mangos登入服的程式結構,也發現了一些不足之處,現在我們就來看看如何提供一個更好的方案。 

  正如我們曾討論過的,為了遊戲主邏輯迴圈的流暢執行,所有比較耗時的IO操作都會分享到單獨的執行緒中去做,如網路IO,資料庫IO和日誌IO等。當然,也有把這些分享到單獨的程序中去做的。 

  另外對於大多數伺服器程式來說,在執行時都是作為精靈程序或服務程序的,所以我們並不需要伺服器能夠處理控制檯使用者輸入,我們所要處理的資料來源都來自網路。 

  這樣,主邏輯迴圈所要做的就是不停要取訊息包來處理,當然這些訊息包不僅有來自客戶端的玩家操作資料包,也有來自GM伺服器的管理命令,還包括來自資料庫查詢執行緒的返回結果訊息包。這個迴圈將一直持續,直到收到一個通知伺服器關閉的訊息包。 

  主邏輯迴圈的結構還是很簡單的,複雜的部分都在如何處理這些訊息包的邏輯上。我們可以用一段簡單的偽碼來描述這個迴圈過程: 

    while (Message* msg = getMessage()) 
    { 
      if (msg為伺服器關閉訊息) 
        break; 
      處理msg訊息; 
    } 

  這裡就有一個問題需要探討了,在getMessage()的時候,我們應該去哪裡取訊息?前面我們考慮過,至少會有三個訊息來源,而我們還討論過,這些訊息源的IO操作都是在獨立的執行緒中進行的,我們這裡的主執行緒不應該直接去那幾處訊息源進行阻塞式的IO操作。 

  很簡單,讓那些獨立的IO執行緒在接收完資料後自己送過來就是了。好比是,我這裡提供了一個倉庫,有很多的供貨商,他們有貨要給我的時候只需要交到倉庫,然後我再到倉庫去取就是了,這個倉庫也就是訊息佇列。訊息佇列是一個普通的佇列實現,當然必須要提供多執行緒互斥訪問的安全性支援,其基本的介面定義大概類似這樣: 

    IMessageQueue 
    { 
      void putMessage(Message*); 
      Message* getMessage(); 
    } 

  網路IO,資料庫IO執行緒把整理好的訊息包都加入到主邏輯迴圈執行緒的這個訊息佇列中便返回。有關訊息佇列的實現和執行緒間訊息的傳遞在ACE中有比較完全的程式碼實現及描述,還有一些使用示例,是個很好的參考。 

  這樣的話,我們的主迴圈就很清晰了,從主執行緒的訊息佇列中取訊息,處理訊息,再取下一條訊息...... 

伺服器公共元件實現 -- 訊息佇列 

  既然說到了訊息佇列,那我們繼續來稍微多聊一點吧。 

  我們所能想到的最簡單的訊息佇列可能就是使用stl的list來實現了,即訊息佇列內部維護一個list和一個互斥鎖,putMessage時將message加入到佇列尾,getMessage時從佇列頭取一個message返回,同時在getMessage和putMessage之前都要求先獲取鎖資源。 

  實現雖然簡單,但功能是絕對滿足需求的,只是效能上可能稍稍有些不盡如人意。其最大的問題在頻繁的鎖競爭上。 

  對於如何減少鎖競爭次數的優化方案,Ghost Cheng提出了一種。提供一個佇列容器,裡面有多個佇列,每個佇列都可固定存放一定數量的訊息。網路IO執行緒要給邏輯執行緒投遞訊息時,會從佇列容器中取一個空佇列來使用,直到將該佇列填滿後再放回容器中換另一個空佇列。而邏輯執行緒取訊息時是從佇列容器中取一個有訊息的佇列來讀取,處理完後清空佇列再放回到容器中。 

  這樣便使得只有在對佇列容器進行操作時才需要加鎖,而IO執行緒和邏輯執行緒在操作自己當前使用的佇列時都不需要加鎖,所以鎖競爭的機會大大減少了。 

  這裡為每個佇列設了個最大訊息數,看來好像是打算只有當IO執行緒寫滿佇列時才會將其放回到容器中換另一個佇列。那這樣有時也會出現IO執行緒未寫滿一個佇列,而邏輯執行緒又沒有資料可處理的情況,特別是當資料量很少時可能會很容易出現。Ghost Cheng在他的描述中沒有講到如何解決這種問題,但我們可以先來看看另一個方案。 

  這個方案與上一個方案基本類似,只是不再提供佇列容器,因為在這個方案中只使用了兩個佇列,arthur在他的一封郵件中描述了這個方案的實現及部分程式碼。兩個佇列,一個給邏輯執行緒讀,一個給IO執行緒用來寫,當邏輯執行緒讀完佇列後會將自己的佇列與IO執行緒的佇列相調換。所以,這種方案下加鎖的次數會比較多一些,IO執行緒每次寫佇列時都要加鎖,邏輯執行緒在調換佇列時也需要加鎖,但邏輯執行緒在讀佇列時是不需要加鎖的。 

  雖然看起來鎖的呼叫次數是比前一種方案要多很多,但實際上大部分鎖呼叫都是不會引起阻塞的,只有在邏輯執行緒調換佇列的那一瞬間可能會使得某個執行緒阻塞一下。另外對於鎖呼叫過程本身來說,其開銷是完全可以忽略的,我們所不能忍受的僅僅是因為鎖呼叫而引起的阻塞而已。 

  兩種方案都是很優秀的優化方案,但也都是有其適用範圍的。Ghost Cheng的方案因為提供了多個佇列,可以使得多個IO執行緒可以總工程師的,互不干擾的使用自己的佇列,只是還有一個遺留問題我們還不瞭解其解決方法。arthur的方案很好的解決了上一個方案遺留的問題,但因為只有一個寫佇列,所以當想要提供多個IO執行緒時,執行緒間互斥地寫入資料可能會增大競爭的機會,當然,如果只有一個IO執行緒那將是非常完美的。