1. 程式人生 > 實用技巧 >放置類遊戲後端伺服器架構設計與實現

放置類遊戲後端伺服器架構設計與實現

前言:

  停更了一段時間。2020年也接近尾聲了,調整了一下人生狀態,繼續前進。

  今年完全參與了一款放置類遊戲從0到開發上線再到合服。從目前市場上買量遊戲的發展線路來看,合服意味著遊戲走向壓榨玩家的最後一步了。遊戲專案也趨於穩定和成熟,最終能不能繼續運營下去還是未知數,但是還是想從技術上/業務上做一次總結。

  放置類遊戲歸於休閒遊戲類,玩家不需要有太多的操作,只需要點點點即可。因此對於後臺伺服器來說也不需要太累,雖然不需要後臺有太過於酷炫的技術,但是必須要保證不把玩家的資料丟失。不同於競技類遊戲或其他遊戲,我覺得對於放置類遊戲來說資料是最重要的。競技類遊戲或者MMORPG遊戲玩家從操作中、從劇情中得到快感。延遲,同步,資料同樣重要。但是對於放置類遊戲,玩家通過點點點堆積道具,攢積積分,提升排名本身就是這類玩家的爽點所在,如果這些資料丟失了無異於將玩家的時間付出付之一炬。

  對於上述原因,資料傳輸協議必然就選自傳統的TCP可靠傳輸協議,資料持久化方面也就是傳統mysql。當然後臺伺服器並不是為了完全可靠不顧及速度而直接操作mysql,中間還是會有一層記憶體的中間層作為過渡。

  這篇文章會從技術上和業務上做一個總結,也算是對我專案終的總結吧。

一、MySQL資料庫和表結構設計

通用資料庫的設計

  玩家註冊一個賬號,後臺會為該玩家生成一個獨一無二的賬戶標識UID。

  玩家在某個服建立一個角色,後臺會為該角色生成一個獨一無二的角色標識RID。

  因此需要一個數據庫(命名為Common資料庫),裡面存放一些通用的全域性變數。例如上述兩個UID、RID是以遞增的方式為每個賬戶、每個角色分配。因此需要有個表(命名為ID_CTRL),裡面記錄了兩條資料,分別是UID和RID的當前值。

  Common資料庫的所有表及作用:

    1.ID控制表(命名為XXX_ID_CTRL):初始值可以指定一個比較大的數,記錄當前分配到的UID、RID的值。

    2.黑名單表(命名為XXX_Black):該表可以以賬戶標識UID欄位為主鍵,另一個欄位可以為封禁時間。

    3.兌換碼錶(命名為XXX_Exchange_Code):該表可以以兌換碼字串為主鍵,其它欄位一般需要包含:兌換碼的使用者、兌換碼的失效時間、兌換碼的型別、兌換碼的對應的物品掉落ID、兌換碼可用渠道等等。

    4.賬戶-角色資訊表(命名為XXX_Uid_Info):一個賬戶UID下可以在不同服註冊角色,因此一個UID就可能對應多個RID。這個表主要用來記錄UID對應哪些RID,以及這個RID的基本資訊。其中的欄位是以UID、SrvId欄位為主鍵,剩餘欄位包含:RID、創角色時間戳等等。

    5.使用者名稱資訊表(命名為XXX_Role_Mapping):該表記錄了每個角色的名字和對應的伺服器ID。該表的作用可用於玩家起名,一個服不應該有相同的名字就是從這個表裡面做的判斷。但是不同服可以有相同的名字,因此該表的主鍵是以角色名字的字串和伺服器ID作為聯合主鍵。剩下的表字段為RID。

    6.openID-賬戶資訊表(命名為XXX_User_Mapping):OpenID是可以管理員使用者自己指定的賬戶標識,每個普通玩家也會隨機生成但是普通玩家並沒有機會使用。這個表記錄了賬戶的建立資訊。以OpenId和Uid為主鍵,剩餘欄位記錄賬戶生成的時間戳。

分庫分表的設計

  除了通用資料庫外,其他資料庫就是內容資料庫用來存放角色資訊的。既然上述的設計角色的RID是以遞增的形式,那麼為了緩解單個內容資料庫的壓力自然想到的內容資料庫的分庫方式就是以RID的尾數作為分庫的依據。

  這樣內容資料庫就分了10個:XXX_0、XXX_1、XXX_2、XXX_3、XXX_4、XXX_5、XXX_6、XXX_7、XXX_8、XXX_9。依據玩家RID的尾數將它塞入對應的資料庫中。這種分庫的方式自然是最均衡的。

  內容資料庫表的設計及作用:

    1.角色資訊表(命名為:XXX_Basics):該表的作用主要是記錄角色的基本資訊,例如:角色名字、等級、性別、職業、充值數量、幫會等等。以角色的獨一無二的標識RID作為主鍵。

    2.角色內容表(命名為:XXX_Info):該表的作用是記錄角色在遊戲內產生的資料。這個表的內容會是最多的,例如:運營充值活動產生的資料、遊戲副本產生的資料、養成的屬性資料、甚至道具數量等等。該表的欄位以Rid、Type(區分型別)、Id為聯合主鍵。剩餘欄位可自行設定。我們專案內設定是除主鍵外還有10個int欄位。

    3.角色擴充套件內容表(命名為:XXX_Extend_Info):有時候上述的角色內容表的10個int欄位不夠用,這個表的目的就是為擴充套件用的。主鍵依然是Rid、Type、Id為聯合主鍵,剩下的一個欄位是data欄位為252位元組的binary。存什麼應該都夠了。

    4.角色裝備/道具表等等的特殊表(命名為:XXX_Equip):該表存有特殊實現的道具或者裝備。

    5.好友關係表(命名為:XXX_Friend):放置類遊戲必不可少的社交屬性系統。該表存好友之間的對映關係。

    6.郵件表(命名為:XXX_Mail):關於郵件和好友系統的實現可以看另一篇博文:遊戲好友系統與郵件系統實現

    7.幫會表(命名為:XXX_Union):該表記錄每個服的幫會資訊,以幫會ID作為主鍵,其他欄位有幫會等級、幫會貢獻、幫主RId、幫會人數、幫會建立時間等等資訊。

    8.幫會成員表(命名為:XXX_Union_Member):該表記錄每個幫會下面每個成員的資訊。以幫會ID和角色Rid為聯合主鍵。其他欄位有幫會職位、角色基本資訊、幫會貢獻等等。

潛在的問題

  以上生成全域性唯一的Uid或者Rid的方法很明顯需要加鎖或者單程序處理,否則就會出現重複的狀況。例如:系統會在業務邏輯程序裡面為玩家建立角色,此時分配Rid的時候就需要向資料庫取當前的Rid值,然後將該值賦值給角色,最後將該值+1寫回資料庫。業務邏輯程序不止一個的情況下,在第一個業務邏輯程序還未將值寫回資料庫時,另一個邏輯業務程序又從資料庫取Rid的值,這樣這兩個Rid就會重複。

  在我們遊戲中確實存在這個問題,業務邏輯程序和Mysql資料庫之間還有一層中間記憶體快取層。業務邏輯程序向中間快取層取資料寫資料,由中間快取層存入資料庫。可惜我們專案中的這個中間快取層是閉源的,只提供了介面且並沒有鎖設計。因此我們的Rid和Uid有重複的可能,一般有打廣告的用指令碼自動快速註冊角色就會導致,普通玩家暫時沒有出現過。

二、遊戲整體的非同步設計

  遊戲的伺服器結構圖:

  

  一個完整的遊戲流程會經歷多個步驟:

    1.玩家登入遊戲,後臺對賬號的校驗。

    2.登陸成功,後臺維護一條客戶端-服務端的連線。

    3.登陸成功,後臺將該玩家的資料從資料庫載入進記憶體。

    4.遊戲正常遊玩,後臺將玩家產生的資料持久化。

  針對以上功能分別設計了不同的程序來處理:

    1.transit程序:對使用者進行賬戶校驗的,如果校驗成功則走後續的載入資料流程。

    2.Logic程序:業務邏輯程序,主要處理業務邏輯的。玩家的操作主要就是在這裡處理的。也是產生使用者資料的地方。

    3.DbWriter程序:非同步存檔程序。由Logic程序產生的資料會通過Tcp協議傳送到該程序。該程序呼叫Mysql資料庫的中間快取層以同步的方式將資料交給中間快取層。

    4.Cache程序:Mysql資料庫的中間快取層,由該程序呼叫Mysql的介面將資料存入資料庫中。

    5.Cross程序:遊戲的跨服玩法的業務邏輯處理。

    6.Center程序:遊戲的全服玩法的業務邏輯處理。

    7.Access程序:接入伺服器程序,主要做的是維護客戶端的連線,後面會主要講解這個程序的處理。

  上面中存檔資料持久化用的是另外一個程序非同步處理,但是在Logic程序中還需要Load檔操作。走的是另外一個執行緒的同步方式load檔。這麼做的原因是load檔的請求量遠遠少於存檔的請求量,所以簡單地在另一個執行緒實現。另一個原因是非同步存檔可以記錄BINLOG來重現資料庫。

三、網路I/O

  無論是Access程序或者Logic程序還是其他程序,用的都是同一套網路I/O,維持TCP連線,收發資料包處理。這部分主要分享一下Access接入程序和Logic程序的網路I/O設計。

  網路I/O的實現類圖:

 

1.幾個類的功能作用說明

CPollerUnit:

  pollerTable:連線池的頭節點指標。連線池是一片空間連續的連結串列結構。連線池的大小由Access程序的最大連線數maxPollers指定。

  freeSlotList:連線池空閒節點指標。每次有一個新的連線過來以後從取出這個指標的節點,並將節點後移。

  epfd:epoll體系的檔案描述符。

CPollThread:

  CPollThread繼承自CPollerUnit,主要的呼叫函式是ThreadLoop()用來呼叫epoll_wait()返回可讀可寫事件。當有事件發生後呼叫void ProcessPollerEvents(void)來處理事件。

CPollerObject:

  主要的資料成員是新連線的fd檔案描述符,連線池節點的指標以及監聽的事件。作為父類定義了可讀可寫和錯誤處理虛成員函式,其子類會複寫這些函式實現不同的處理邏輯。

CClientAsync:

  繼承自CPollerObject。每當有一個新的客戶端連線的時候,都會new一個該物件。該物件實現了將連線fd納入到epoll體系的函式。複寫了可讀可寫和事件錯誤處理函式。

CBattleAsync:

  同樣繼承自CPollerObect。Access程序作為客戶端會依據配置主動去連線各個區服的程序即Logic程序,來建立一個Tcp連線。連線成功與否都會new一個該物件並將該物件存放在一個map<uint32,CBattleAsync>容器裡面。為什麼這樣呢後面會詳敘。

CListener:

  同樣繼承自CPollerObject,該物件主要是有一個ListenFd來監聽新的客戶端連線。

2.Access接入程序的連線池設計

  接入程序維護了所有客戶端的TCP連線,以及與每一個Logic程序的TCP連線。每個新連線到來時都會向連線池申請資源,如果申請失敗則連線建立失敗。連線池的大小在配置內指定。  

  之前我嘗試過不使用連線池改造過Access程序,發現可行且省去了考慮分配連線池大小的問題,於是和leader討論了連線池存在的必要性。

  得出的結論是連線池還是很有必要的,目的就是為了能對記憶體的使用掌握主動權。Access接入程序作為維護客戶端的連線可能會有成千上萬,那麼記憶體的使用就需要能更好的把握。使用連線池能對Access程序的記憶體使用定量分配,定量掌控,定量分析,定量擴充套件。

  連線池設計:

  連線池是一片形如連結串列結構但空間連續的記憶體。

  連線池中的節點有各種各樣的連線,這些各種各樣的連線都會被定義成不同類別的物件,但這些不同類別的物件都繼承自CPollerObject,這些連線對應的物件大致有以下幾類:

    1.CClientAsync:和玩家客戶端連線的物件

    2.CBattleAsync:和Logic程序連線的物件

    3.CTransitAsync:和Transit程序連線的物件

    4.CDbwriterAsync:和Dbwriter程序連線的物件

    5.CCrossAsync:和Cross程序連線的物件

    6.CCenterAsync:和Center程序連線的物件

  連線池佔用記憶體大小的量化評估:每個節點有兩個指標(64*2=128位)+ 一個int型別(32位這個型別可以省略,歷史遺留問題)=20B。如果一個Access接入程序支援1萬個併發連線數,那麼記憶體池的佔用大小是:20B*10000≈200KB≈0.2MB。

3.Access接入程序網路I/O設計和實現

  對於Access程序的網路I/O,主要是以上三類的物件:

    第一類是CListener物件用來監聽新客戶端的連線。

    第二類是CClientAsync物件:Access作為服務端為每一個新的客戶端連線new一個該物件。

    第三類是CBattleAsync物件:Access作為客戶端依據配置主動去連線每一個區服的程序所new的物件。

  每當一個新的連線建立的時候,就會佔用一個節點,並將上述的子類指標賦值給CPollerObject *poller。當監聽新連線事件(將新的fd納入到epoll體系)的時候,會將該節點的index索引賦值給struct epoll_eventdata成員然後呼叫epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

  

  這樣,如果該連線有事件從epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)中返回,可以從strcut epoll_event中拿到對應物件指標在連線池的索引,進而可以以O(1)的時間複雜度從連線池中拿到物件指標。

  因為不同的XXXAsync子類對可讀可寫錯誤處理的事件有不同的處理,因此分別過載了父類的可讀可寫錯誤處理的呼叫函式:

    virtual void InputNotify (void);
    virtual void OutputNotify (void);
    virtual void HangupNotify (void);

  父類物件指標儲存了子類的物件指標,這裡用了C++語言的多型特性。

  如果不用連線池的設計,那麼呼叫epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)時傳入的struct epoll_eventdata引數中完全可以傳物件的指標。上述中已經討論了連線池存在的必要性。

4.Access接入程序對客戶端資料的轉發

  Access程序的物件功能圖:

  

  

  玩家登入選擇區服登入遊戲開始遊玩。如果你是程式設計師你可能就會覺得玩家客戶端和這個區服的程序建立了一條Tcp連線來收發資料,但是真實情況往往不是這樣的。

  Access程序作為接入程序,有多少個CClientAsync物件就代表有多少個客戶端連線。同時Access接入程序又作為客戶端對每一個服的程序(Logic程序)發起Tcp連線並new一個CBattleAsync物件,這些物件的指標存放在以區服ID為Key的容器map<uint32_t, CBattleAsync*>內。

  客戶端連線建立成功後會傳送第一個資料包就是區服id並記錄在CClienAsync物件的資料成員裡面。

  玩家角色客戶端傳送資料包給伺服器的流程:通過該角色---該客戶端連線---拿到該區服ID---拿到該CBattleAsync物件---拿到該區服的連線,將資料通過區服的連線傳送給玩家所在區服的程序。

  

  Access接入程序和Logic程序是一種多對一的關係,那麼Logic程序如何區分出不同的客戶端連線就是通過原封不動的返回Access接入程序傳送過來的包體內容。

  

  一個客戶端發起Tcp連線,Access程序的epoll事件觸發並由Accep()函式接收檔案描述符。Access程序依據檔案描述符建立一個CClientAsync物件,並對物件的資料成員fd、srvId、time、microTime進行賦值,將該物件的指標以fd為key放入一個map容器內。當客戶端有資料包發給後臺時(其實是將資料傳送給Logic程序),通過epoll event返回的index找到連線池該物件的指標,呼叫CClientAsync物件的可讀處理事件。依據srvId拿到Access接入程序維護的對不同區服Logic程序的連線的物件。傳送給區服前封裝包體以讓Logic程序能標識出不同的客戶端。

  Logic程序在恰當的時候會將包頭的fd、SrvId、time、microTime記錄下來,叫做一條客戶端”連線“。

5.連線關閉

  由以上客戶端的連線可知,如果連線關閉需要做兩件事情。

  一、Access接入程序從Epoll的監聽體系裡面剔除要關閉連線的檔案描述符。

  二、告知Logic程序剔除該客戶端的連線對映,並做角色下線操作。

  連線關閉的情況分兩種:

1.客戶端主動斷開連線

  Access接入程序收到某個客戶端連線recv()返回長度為0代表客戶端—Access接入程序的TCP連線關閉。Access程序可以從Epoll體系裡面移除該檔案描述符的監聽。然後再構建一個包發給Logic程序告知Logic程序對於的連線對映已關閉讓Logic程序對該連線的角色做下線操作。最後Access接入程序將該CClienAsync物件析構。

2.伺服器斷開客戶端連線

  這種情況就是Logic程序掛了,那麼Access接入程序—Logic程序的TCP連線就會被關閉。Access接入程序在要關閉那個連線的CBattleAsync物件下recv()返回長度為0。Access接入程序做的事情是將該檔案描述符從Epoll的監聽體系內移除,但是並不析構CBattleAsync物件,因為Access接入程序作為服務端是在程序一啟動的時候去連的各個區服的Logic程序,如果析構了CBattleAsync物件那麼就變複雜了,Access接入程序需要定時去檢測和各個服的連線是否正常,實屬多餘。

  Access接入程序—Logic程序的TCP連線建立成功以後會將CBattleAsync物件的成員變數m_stat設定為CONNECTED,因此當該TCP連線關閉以後,直接將m_stat變數設定為IDLE狀態即可,且也不將該物件從容器內剔除。

  Access接入程序作為服務端並沒有再去和Logic程序建立TCP連線,那麼當Logic程序重新啟動以後怎麼重建Access接入程序—Logic程序的TCP連線

  答應是由連線到該區服Logic的玩家客戶端來重建,玩家客戶端其實並不知道Access接入程序—Logic程序的TCP連線是個什麼狀態,他會發協議包給到Access接入程序,然後Access接入程序轉交給Logic程序的時候發現該連線的狀態m_statIDLE狀態,於是就會讓Access接入程序重新和該Logic程序發起TCP連線。再將包轉給Logic程序。

6.Epoll觸發方式的選擇:

  上面講了整個網路I/O是用的epoll,那麼對每個fd設定監聽事件採取的觸發方式是什麼。我們這裡使用的是LT(水平觸發)+Non_Blocking(非阻塞)的方式。

  採用這種觸發方式必然也就要會有不同的處理。

  1.對於監聽新連線到來的ListenFd,一般採用非阻塞的原因有:

    a.採用阻塞ListenFd可能會導致其他連線的可讀可寫事件無法被及時處理(單執行緒/單程序的情況下)。Tcp完成三次握手將該連線放入一個佇列裡面。epoll感知到該連線存在返回ListenFd的可讀事件,由Accept()函式拿到該連線的檔案描述符。如果採用了阻塞的ListenFd,就會導致一種情況:如果Tcp完成三次握手後客戶端就傳送RST報文直接斷開連線,該連線在核心內已經被斷開。但是epoll依舊會返回ListenFd的可讀事件,如果是阻塞的ListenFd,此時就緒佇列內並沒有檔案描述符返回,那麼程式就會阻塞在Accept()函式內,直到下一個連線的到來。如果採用非阻塞ListenFd,在Accept()函式之前連線被RST報文斷開,那麼Accept()也會返回並指定錯誤碼。

    b.ET模式下采用非阻塞模式可以防止有連線未被及時處理的情況。在ET模式下,如果多個連線同時到達,ListenFd對應的核心緩衝區積累了多個。但是Epoll只會觸發一次,因此如果要正確及時處理這些堆積的連線就需要在Accept()函式包一層while迴圈。如果採用阻塞的ListenFd,最後一次迴圈呼叫Accept()函式的時候程序就被阻塞了,此時程序就喪失了處理其他事件的能力。正確的方式是採用非阻塞的方式,最後一次Accept()函式返回-1並將errno設定為EAGAIN。

while (true)  //對於非阻塞的ListenFd,這裡也可不採用迴圈。因為如果ListenFd內有待處理的連線,會一直觸發epoll的可讀事件
{
    peerSize = sizeof (peer);
    newfd = accept (netfd, &peer, &peerSize);
    if (newfd == -1)
    {
        //如果不是阻塞的系統呼叫被中斷並且不是繼續嘗試上述函式呼叫,那麼這次的accept函式錯誤有點嚴重呀
        if (errno != EINTR && errno != EAGAIN  )
            LOG_NOTICE("[%s]accept failed, fd=%d, %m", name, netfd);
        //如果錯誤是因為EMFILE達到了程序可開啟的最大檔案描述符
        //如果錯誤是因為ENFILE達到了系統可開啟的最大檔案描述符
        if(errno == EMFILE || errno == ENFILE)
            LOG_NOTICE("max fds reached,rest all,%m");
        break;
    }
            
    CClientAsync* async = new CClientAsync(owerThread, newfd);
    if(async->Attach() == -1)
    {
        delete async;
    }
}

  2.對於客戶端連線有資料到來的可讀事件:只需要指定一個緩衝區讀就行了。如果沒有一次性將核心緩衝區內的資料讀完,那麼下次epoll_wait返回以後繼續讀就完事了。

char buf[4096];
int len = recv(netfd,buf,sizeof(buf),0);
if(len == 0){
    LOG_ERROR("peer close [%s:%d] fd=%d",m_peerAddr,m_peerPort,netfd);
    errorProcess(SRC_INPUT);
    return;
}
else if(len < 0){
    errorProcess(SRC_INPUT);
    return;
}

  3.對於可寫事件:因為採用的是非阻塞的方式,大部分時候核心緩衝區都是空的,即可寫事件一直都會發生。因此對於可寫事件需要做一個類似水龍頭開關的設計,如果有水(即使用者態緩衝區有資料需要傳送),那麼開啟水龍頭(向epoll註冊監聽可寫事件),將水放幹,關閉水龍頭(向epoll解除可寫事件的監聽)

void CClientAsync::OutputNotify (void)
{
    int sendLen = send(netfd,m_outBuf.c_str(),m_outBuf.length(),0);
    if(0 > sendLen){
        {
            LOG_ERROR("errno=%u EAGIN addr [%s:%d],buflen=%u %m",errno,m_peerAddr,m_peerPort,m_outBuf.length());
            return;
        }
        errorProcess(SRC_OUTPUT);
        return;
    }

    m_outBuf.erase(0,sendLen);
    if(m_outBuf.length() == 0){
        DisableOutput();
        ApplyEvents();
    }
}

四、伺服器優化

1.子執行緒空轉浪費cpu資源問題

  Logic程序的子執行緒主要是Load檔操作,加鎖從佇列裡面拿到任務然後工作。如果佇列為空,那麼釋放鎖然後呼叫usleep(1000)睡眠1毫秒。  

  dbwriter程序的子執行緒主要是呼叫資料庫緩衝層的阻塞或者慢操作將資料持久化。同樣也是加鎖從佇列裡面拿到任務然後工作,如果佇列為空,同樣睡眠1毫秒。

  transit程序的子執行緒主要是呼叫阻塞呼叫http介面等待校驗返回,同樣也是佇列為空以後睡眠1毫秒。

  為了避免這些子執行緒多次無意義的加鎖釋放鎖,引入條件變數即可:

int pthread_cond_destroy(pthread_cond_t *cond);

int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

pthread_cond_wait(),

pthread_cond_timedwait(),

pthread_cond_signal(),

pthread_cond_broadcast(),

pthread_cond_destroy()

  子執行緒加鎖以後,判斷佇列是否為空,如果為空,那麼釋放鎖阻塞在條件變數處等待主執行緒往佇列裡面新增任務後再喚醒子執行緒。

2.dbwrter程序的優化

  dbwriter程序是非同步存檔用的。Logic邏輯程序產生資料以後會將資料傳送給dbwriter程序的存檔佇列裡面,dbwriter程序定時從佇列裡面取資料然後呼叫資料庫快取層的介面將資料再轉交給資料庫快取層。

  這裡的問題是,如果某個時間段dbwriter程序的存檔佇列發生了堆積(可能產生的原因是資料庫壓力過大,或者Logic程序產生資料過快)。這個時候恰好遇到版本更新需要殺掉程序,那麼就會導致這些玩家需要存檔的資料丟失。

  解決這個問題的辦法就是利用訊號,之前殺掉程序利用的是kill -9 Pid,現在傳送kill -USR2 Pid自定義訊號,dbwriter捕獲自定義訊號後等待佇列為空以後再退出程序。

3.transit校驗程序的優化

  transit程序的作用是後臺伺服器對玩家賬戶的再一次校驗。由於特殊的原因是通過Http請求向第三方請求校驗結果。所以transit程序的工作很簡單,收到Logic程序轉發過來需要校驗的玩家資料包以後發起一次Http請求並阻塞等待結果,然後將結果返回給Logic程序。

  一開始的transit程序採用的是單執行緒處理,一次Http請求的時延大概是20ms~100ms之間。也就是說一秒鐘最多處理的請求量也就是10~50次。這遠遠不夠呀,一開始遊戲上線就遭遇瓶頸了,大部分玩家點選登入的時候要等待很長時間。於是著手優化此處。

  1.採用多執行緒處理。4核cpu採用4個子執行緒+1個主執行緒處理,這樣就將一秒鐘能處理的請求量提升了4倍至40~200次。但是隻是單純地用多個執行緒去處理,依舊會有瓶頸的存在。於是就搭配了第二種辦法。

  2.增加校驗快取。如果玩家已經走過一遍登入校驗流程,在短時間內重複登入的時候,其實已經完全沒有必要再走一遍向第三方請求校驗的流程了。因此增加一層快取層,可以完全解決瓶頸問題。

  

  Transit程序的子執行緒為了儘可能簡單,所以只負責從快取查詢是否命中以及向Http請求結果。將結果傳送的操作還是交給主執行緒去完成。這一套下來,多了3個鎖的資料成員和3個佇列。

4.定時器實現的優化

  Logic程序的定時器設計是開了一個專門的定時器執行緒,然後定時器執行緒和主執行緒之間建立了一個TCP連線。定時器執行緒在迴圈裡面一直呼叫select()超時返回以後給主執行緒傳送一個空資料包,主執行緒收到資料包以後做定時操作。

  這個蛋疼的定時器設計問題有二個:

    一、新開了一個執行緒不斷迴圈,浪費了CPU資源。

    二、利用TCP連線發包的形式通知主執行緒定時觸發。雖然select()函式的精度可以達到微秒級別,但是引入了TCP/IP,單單是TCP的Nagle特性就足以讓定時器的精度難以確定了。更別說網路傳輸之間的傳輸延遲了。

  這一層利用TCP其實也是為了將定時器納入到主執行緒的Epoll體系裡面。但是要將定時器的時間概念納入到Epoll體系裡面已經有一個更好的實現了就是Timerfd系列:

  timerfd_create, timerfd_settime, timerfd_gettime - timers that notify via file descriptors

  Timerfd的時間精度可以達到納秒級別,將定時器的實現改為Timerfd以後。可以減少一個執行緒帶來的CPU浪費(幾百個服就是幾百個執行緒了),省去了網路傳輸的延遲,定時器的準度更加可控。

  Timerfd系列的實現是2008年Linux核心釋出版本v2.6.25以後才有的。

  當時實現這套定時器功能的時候Timerfd根本就還沒有,這是一套遠古級別的騰訊程式碼流傳至今。