放置类游戏后端服务器架构设计与实现
前言:
停更了一段時(shí)間。2020年也接近尾聲了,調(diào)整了一下人生狀態(tài),繼續(xù)前進(jìn)。
今年完全參與了一款放置類(lèi)游戲從0到開(kāi)發(fā)上線再到合服。從目前市場(chǎng)上買(mǎi)量游戲的發(fā)展線路來(lái)看,合服意味著游戲走向壓榨玩家的最后一步了。游戲項(xiàng)目也趨于穩(wěn)定和成熟,最終能不能繼續(xù)運(yùn)營(yíng)下去還是未知數(shù),但是還是想從技術(shù)上/業(yè)務(wù)上做一次總結(jié)。
放置類(lèi)游戲歸于休閑游戲類(lèi),玩家不需要有太多的操作,只需要點(diǎn)點(diǎn)點(diǎn)即可。因此對(duì)于后臺(tái)服務(wù)器來(lái)說(shuō)也不需要太累,雖然不需要后臺(tái)有太過(guò)于酷炫的技術(shù),但是必須要保證不把玩家的數(shù)據(jù)丟失。不同于競(jìng)技類(lèi)游戲或其他游戲,我覺(jué)得對(duì)于放置類(lèi)游戲來(lái)說(shuō)數(shù)據(jù)是最重要的。競(jìng)技類(lèi)游戲或者M(jìn)MORPG游戲玩家從操作中、從劇情中得到快感。延遲,同步,數(shù)據(jù)同樣重要。但是對(duì)于放置類(lèi)游戲,玩家通過(guò)點(diǎn)點(diǎn)點(diǎn)堆積道具,攢積積分,提升排名本身就是這類(lèi)玩家的爽點(diǎn)所在,如果這些數(shù)據(jù)丟失了無(wú)異于將玩家的時(shí)間付出付之一炬。
對(duì)于上述原因,數(shù)據(jù)傳輸協(xié)議必然就選自傳統(tǒng)的TCP可靠傳輸協(xié)議,數(shù)據(jù)持久化方面也就是傳統(tǒng)mysql。當(dāng)然后臺(tái)服務(wù)器并不是為了完全可靠不顧及速度而直接操作mysql,中間還是會(huì)有一層內(nèi)存的中間層作為過(guò)渡。
這篇文章會(huì)從技術(shù)上和業(yè)務(wù)上做一個(gè)總結(jié),也算是對(duì)我項(xiàng)目終的總結(jié)吧。
一、MySQL數(shù)據(jù)庫(kù)和表結(jié)構(gòu)設(shè)計(jì)
通用數(shù)據(jù)庫(kù)的設(shè)計(jì)
玩家注冊(cè)一個(gè)賬號(hào),后臺(tái)會(huì)為該玩家生成一個(gè)獨(dú)一無(wú)二的賬戶(hù)標(biāo)識(shí)UID。
玩家在某個(gè)服創(chuàng)建一個(gè)角色,后臺(tái)會(huì)為該角色生成一個(gè)獨(dú)一無(wú)二的角色標(biāo)識(shí)RID。
因此需要一個(gè)數(shù)據(jù)庫(kù)(命名為Common數(shù)據(jù)庫(kù)),里面存放一些通用的全局變量。例如上述兩個(gè)UID、RID是以遞增的方式為每個(gè)賬戶(hù)、每個(gè)角色分配。因此需要有個(gè)表(命名為ID_CTRL),里面記錄了兩條數(shù)據(jù),分別是UID和RID的當(dāng)前值。
Common數(shù)據(jù)庫(kù)的所有表及作用:
1.ID控制表(命名為XXX_ID_CTRL):初始值可以指定一個(gè)比較大的數(shù),記錄當(dāng)前分配到的UID、RID的值。
2.黑名單表(命名為XXX_Black):該表可以以賬戶(hù)標(biāo)識(shí)UID字段為主鍵,另一個(gè)字段可以為封禁時(shí)間。
3.兌換碼表(命名為XXX_Exchange_Code):該表可以以?xún)稉Q碼字符串為主鍵,其它字段一般需要包含:兌換碼的使用者、兌換碼的失效時(shí)間、兌換碼的類(lèi)型、兌換碼的對(duì)應(yīng)的物品掉落ID、兌換碼可用渠道等等。
4.賬戶(hù)-角色信息表(命名為XXX_Uid_Info):一個(gè)賬戶(hù)UID下可以在不同服注冊(cè)角色,因此一個(gè)UID就可能對(duì)應(yīng)多個(gè)RID。這個(gè)表主要用來(lái)記錄UID對(duì)應(yīng)哪些RID,以及這個(gè)RID的基本信息。其中的字段是以UID、SrvId字段為主鍵,剩余字段包含:RID、創(chuàng)角色時(shí)間戳等等。
5.用戶(hù)名信息表(命名為XXX_Role_Mapping):該表記錄了每個(gè)角色的名字和對(duì)應(yīng)的服務(wù)器ID。該表的作用可用于玩家起名,一個(gè)服不應(yīng)該有相同的名字就是從這個(gè)表里面做的判斷。但是不同服可以有相同的名字,因此該表的主鍵是以角色名字的字符串和服務(wù)器ID作為聯(lián)合主鍵。剩下的表字段為RID。
6.openID-賬戶(hù)信息表(命名為XXX_User_Mapping):OpenID是可以管理員用戶(hù)自己指定的賬戶(hù)標(biāo)識(shí),每個(gè)普通玩家也會(huì)隨機(jī)生成但是普通玩家并沒(méi)有機(jī)會(huì)使用。這個(gè)表記錄了賬戶(hù)的創(chuàng)建信息。以O(shè)penId和Uid為主鍵,剩余字段記錄賬戶(hù)生成的時(shí)間戳。
分庫(kù)分表的設(shè)計(jì)
除了通用數(shù)據(jù)庫(kù)外,其他數(shù)據(jù)庫(kù)就是內(nèi)容數(shù)據(jù)庫(kù)用來(lái)存放角色信息的。既然上述的設(shè)計(jì)角色的RID是以遞增的形式,那么為了緩解單個(gè)內(nèi)容數(shù)據(jù)庫(kù)的壓力自然想到的內(nèi)容數(shù)據(jù)庫(kù)的分庫(kù)方式就是以RID的尾數(shù)作為分庫(kù)的依據(jù)。
這樣內(nèi)容數(shù)據(jù)庫(kù)就分了10個(gè):XXX_0、XXX_1、XXX_2、XXX_3、XXX_4、XXX_5、XXX_6、XXX_7、XXX_8、XXX_9。依據(jù)玩家RID的尾數(shù)將它塞入對(duì)應(yīng)的數(shù)據(jù)庫(kù)中。這種分庫(kù)的方式自然是最均衡的。
內(nèi)容數(shù)據(jù)庫(kù)表的設(shè)計(jì)及作用:
1.角色信息表(命名為:XXX_Basics):該表的作用主要是記錄角色的基本信息,例如:角色名字、等級(jí)、性別、職業(yè)、充值數(shù)量、幫會(huì)等等。以角色的獨(dú)一無(wú)二的標(biāo)識(shí)RID作為主鍵。
2.角色內(nèi)容表(命名為:XXX_Info):該表的作用是記錄角色在游戲內(nèi)產(chǎn)生的數(shù)據(jù)。這個(gè)表的內(nèi)容會(huì)是最多的,例如:運(yùn)營(yíng)充值活動(dòng)產(chǎn)生的數(shù)據(jù)、游戲副本產(chǎn)生的數(shù)據(jù)、養(yǎng)成的屬性數(shù)據(jù)、甚至道具數(shù)量等等。該表的字段以Rid、Type(區(qū)分類(lèi)型)、Id為聯(lián)合主鍵。剩余字段可自行設(shè)置。我們項(xiàng)目?jī)?nèi)設(shè)置是除主鍵外還有10個(gè)int字段。
3.角色擴(kuò)展內(nèi)容表(命名為:XXX_Extend_Info):有時(shí)候上述的角色內(nèi)容表的10個(gè)int字段不夠用,這個(gè)表的目的就是為擴(kuò)展用的。主鍵依然是Rid、Type、Id為聯(lián)合主鍵,剩下的一個(gè)字段是data字段為252字節(jié)的binary。存什么應(yīng)該都?jí)蛄恕?/p>
4.角色裝備/道具表等等的特殊表(命名為:XXX_Equip):該表存有特殊實(shí)現(xiàn)的道具或者裝備。
5.好友關(guān)系表(命名為:XXX_Friend):放置類(lèi)游戲必不可少的社交屬性系統(tǒng)。該表存好友之間的映射關(guān)系。
6.郵件表(命名為:XXX_Mail):關(guān)于郵件和好友系統(tǒng)的實(shí)現(xiàn)可以看另一篇博文:游戲好友系統(tǒng)與郵件系統(tǒng)實(shí)現(xiàn)
7.幫會(huì)表(命名為:XXX_Union):該表記錄每個(gè)服的幫會(huì)信息,以幫會(huì)ID作為主鍵,其他字段有幫會(huì)等級(jí)、幫會(huì)貢獻(xiàn)、幫主RId、幫會(huì)人數(shù)、幫會(huì)創(chuàng)建時(shí)間等等信息。
8.幫會(huì)成員表(命名為:XXX_Union_Member):該表記錄每個(gè)幫會(huì)下面每個(gè)成員的信息。以幫會(huì)ID和角色Rid為聯(lián)合主鍵。其他字段有幫會(huì)職位、角色基本信息、幫會(huì)貢獻(xiàn)等等。
潛在的問(wèn)題
以上生成全局唯一的Uid或者Rid的方法很明顯需要加鎖或者單進(jìn)程處理,否則就會(huì)出現(xiàn)重復(fù)的狀況。例如:系統(tǒng)會(huì)在業(yè)務(wù)邏輯進(jìn)程里面為玩家創(chuàng)建角色,此時(shí)分配Rid的時(shí)候就需要向數(shù)據(jù)庫(kù)取當(dāng)前的Rid值,然后將該值賦值給角色,最后將該值+1寫(xiě)回?cái)?shù)據(jù)庫(kù)。業(yè)務(wù)邏輯進(jìn)程不止一個(gè)的情況下,在第一個(gè)業(yè)務(wù)邏輯進(jìn)程還未將值寫(xiě)回?cái)?shù)據(jù)庫(kù)時(shí),另一個(gè)邏輯業(yè)務(wù)進(jìn)程又從數(shù)據(jù)庫(kù)取Rid的值,這樣這兩個(gè)Rid就會(huì)重復(fù)。
在我們游戲中確實(shí)存在這個(gè)問(wèn)題,業(yè)務(wù)邏輯進(jìn)程和Mysql數(shù)據(jù)庫(kù)之間還有一層中間內(nèi)存緩存層。業(yè)務(wù)邏輯進(jìn)程向中間緩存層取數(shù)據(jù)寫(xiě)數(shù)據(jù),由中間緩存層存入數(shù)據(jù)庫(kù)。可惜我們項(xiàng)目中的這個(gè)中間緩存層是閉源的,只提供了接口且并沒(méi)有鎖設(shè)計(jì)。因此我們的Rid和Uid有重復(fù)的可能,一般有打廣告的用腳本自動(dòng)快速注冊(cè)角色就會(huì)導(dǎo)致,普通玩家暫時(shí)沒(méi)有出現(xiàn)過(guò)。
二、游戲整體的異步設(shè)計(jì)
游戲的服務(wù)器結(jié)構(gòu)圖:
一個(gè)完整的游戲流程會(huì)經(jīng)歷多個(gè)步驟:
1.玩家登錄游戲,后臺(tái)對(duì)賬號(hào)的校驗(yàn)。
2.登陸成功,后臺(tái)維護(hù)一條客戶(hù)端-服務(wù)端的連接。
3.登陸成功,后臺(tái)將該玩家的數(shù)據(jù)從數(shù)據(jù)庫(kù)載入進(jìn)內(nèi)存。
4.游戲正常游玩,后臺(tái)將玩家產(chǎn)生的數(shù)據(jù)持久化。
針對(duì)以上功能分別設(shè)計(jì)了不同的進(jìn)程來(lái)處理:
1.transit進(jìn)程:對(duì)用戶(hù)進(jìn)行賬戶(hù)校驗(yàn)的,如果校驗(yàn)成功則走后續(xù)的加載數(shù)據(jù)流程。
2.Logic進(jìn)程:業(yè)務(wù)邏輯進(jìn)程,主要處理業(yè)務(wù)邏輯的。玩家的操作主要就是在這里處理的。也是產(chǎn)生用戶(hù)數(shù)據(jù)的地方。
3.DbWriter進(jìn)程:異步存檔進(jìn)程。由Logic進(jìn)程產(chǎn)生的數(shù)據(jù)會(huì)通過(guò)Tcp協(xié)議發(fā)送到該進(jìn)程。該進(jìn)程調(diào)用Mysql數(shù)據(jù)庫(kù)的中間緩存層以同步的方式將數(shù)據(jù)交給中間緩存層。
4.Cache進(jìn)程:Mysql數(shù)據(jù)庫(kù)的中間緩存層,由該進(jìn)程調(diào)用Mysql的接口將數(shù)據(jù)存入數(shù)據(jù)庫(kù)中。
5.Cross進(jìn)程:游戲的跨服玩法的業(yè)務(wù)邏輯處理。
6.Center進(jìn)程:游戲的全服玩法的業(yè)務(wù)邏輯處理。
7.Access進(jìn)程:接入服務(wù)器進(jìn)程,主要做的是維護(hù)客戶(hù)端的連接,后面會(huì)主要講解這個(gè)進(jìn)程的處理。
上面中存檔數(shù)據(jù)持久化用的是另外一個(gè)進(jìn)程異步處理,但是在Logic進(jìn)程中還需要Load檔操作。走的是另外一個(gè)線程的同步方式load檔。這么做的原因是load檔的請(qǐng)求量遠(yuǎn)遠(yuǎn)少于存檔的請(qǐng)求量,所以簡(jiǎn)單地在另一個(gè)線程實(shí)現(xiàn)。另一個(gè)原因是異步存檔可以記錄BINLOG來(lái)重現(xiàn)數(shù)據(jù)庫(kù)。
三、網(wǎng)絡(luò)I/O
無(wú)論是Access進(jìn)程或者Logic進(jìn)程還是其他進(jìn)程,用的都是同一套網(wǎng)絡(luò)I/O,維持TCP連接,收發(fā)數(shù)據(jù)包處理。這部分主要分享一下Access接入進(jìn)程和Logic進(jìn)程的網(wǎng)絡(luò)I/O設(shè)計(jì)。
網(wǎng)絡(luò)I/O的實(shí)現(xiàn)類(lèi)圖:
1.幾個(gè)類(lèi)的功能作用說(shuō)明
CPollerUnit:
pollerTable:連接池的頭節(jié)點(diǎn)指針。連接池是一片空間連續(xù)的鏈表結(jié)構(gòu)。連接池的大小由Access進(jìn)程的最大連接數(shù)maxPollers指定。
freeSlotList:連接池空閑節(jié)點(diǎn)指針。每次有一個(gè)新的連接過(guò)來(lái)以后從取出這個(gè)指針的節(jié)點(diǎn),并將節(jié)點(diǎn)后移。
epfd:epoll體系的文件描述符。
CPollThread:
CPollThread繼承自CPollerUnit,主要的調(diào)用函數(shù)是ThreadLoop()用來(lái)調(diào)用epoll_wait()返回可讀可寫(xiě)事件。當(dāng)有事件發(fā)生后調(diào)用void ProcessPollerEvents(void)來(lái)處理事件。
CPollerObject:
主要的數(shù)據(jù)成員是新連接的fd文件描述符,連接池節(jié)點(diǎn)的指針以及監(jiān)聽(tīng)的事件。作為父類(lèi)定義了可讀可寫(xiě)和錯(cuò)誤處理虛成員函數(shù),其子類(lèi)會(huì)復(fù)寫(xiě)這些函數(shù)實(shí)現(xiàn)不同的處理邏輯。
CClientAsync:
繼承自CPollerObject。每當(dāng)有一個(gè)新的客戶(hù)端連接的時(shí)候,都會(huì)new一個(gè)該對(duì)象。該對(duì)象實(shí)現(xiàn)了將連接fd納入到epoll體系的函數(shù)。復(fù)寫(xiě)了可讀可寫(xiě)和事件錯(cuò)誤處理函數(shù)。
CBattleAsync:
同樣繼承自CPollerObect。Access進(jìn)程作為客戶(hù)端會(huì)依據(jù)配置主動(dòng)去連接各個(gè)區(qū)服的進(jìn)程即Logic進(jìn)程,來(lái)建立一個(gè)Tcp連接。連接成功與否都會(huì)new一個(gè)該對(duì)象并將該對(duì)象存放在一個(gè)map<uint32,CBattleAsync>容器里面。為什么這樣呢后面會(huì)詳敘。
CListener:
同樣繼承自CPollerObject,該對(duì)象主要是有一個(gè)ListenFd來(lái)監(jiān)聽(tīng)新的客戶(hù)端連接。
2.Access接入進(jìn)程的連接池設(shè)計(jì)
接入進(jìn)程維護(hù)了所有客戶(hù)端的TCP連接,以及與每一個(gè)Logic進(jìn)程的TCP連接。每個(gè)新連接到來(lái)時(shí)都會(huì)向連接池申請(qǐng)資源,如果申請(qǐng)失敗則連接建立失敗。連接池的大小在配置內(nèi)指定。
之前我嘗試過(guò)不使用連接池改造過(guò)Access進(jìn)程,發(fā)現(xiàn)可行且省去了考慮分配連接池大小的問(wèn)題,于是和leader討論了連接池存在的必要性。
得出的結(jié)論是連接池還是很有必要的,目的就是為了能對(duì)內(nèi)存的使用掌握主動(dòng)權(quán)。Access接入進(jìn)程作為維護(hù)客戶(hù)端的連接可能會(huì)有成千上萬(wàn),那么內(nèi)存的使用就需要能更好的把握。使用連接池能對(duì)Access進(jìn)程的內(nèi)存使用定量分配,定量掌控,定量分析,定量擴(kuò)展。
連接池設(shè)計(jì):
連接池是一片形如鏈表結(jié)構(gòu)但空間連續(xù)的內(nèi)存。
連接池中的節(jié)點(diǎn)有各種各樣的連接,這些各種各樣的連接都會(huì)被定義成不同類(lèi)別的對(duì)象,但這些不同類(lèi)別的對(duì)象都繼承自CPollerObject,這些連接對(duì)應(yīng)的對(duì)象大致有以下幾類(lèi):
1.CClientAsync:和玩家客戶(hù)端連接的對(duì)象
2.CBattleAsync:和Logic進(jìn)程連接的對(duì)象
3.CTransitAsync:和Transit進(jìn)程連接的對(duì)象
4.CDbwriterAsync:和Dbwriter進(jìn)程連接的對(duì)象
5.CCrossAsync:和Cross進(jìn)程連接的對(duì)象
6.CCenterAsync:和Center進(jìn)程連接的對(duì)象
連接池占用內(nèi)存大小的量化評(píng)估:每個(gè)節(jié)點(diǎn)有兩個(gè)指針(64*2=128位)+ 一個(gè)int類(lèi)型(32位這個(gè)類(lèi)型可以省略,歷史遺留問(wèn)題)=20B。如果一個(gè)Access接入進(jìn)程支持1萬(wàn)個(gè)并發(fā)連接數(shù),那么內(nèi)存池的占用大小是:20B*10000≈200KB≈0.2MB。
3.Access接入進(jìn)程網(wǎng)絡(luò)I/O設(shè)計(jì)和實(shí)現(xiàn)
對(duì)于Access進(jìn)程的網(wǎng)絡(luò)I/O,主要是以上三類(lèi)的對(duì)象:
第一類(lèi)是CListener對(duì)象用來(lái)監(jiān)聽(tīng)新客戶(hù)端的連接。
第二類(lèi)是CClientAsync對(duì)象:Access作為服務(wù)端為每一個(gè)新的客戶(hù)端連接new一個(gè)該對(duì)象。
第三類(lèi)是CBattleAsync對(duì)象:Access作為客戶(hù)端依據(jù)配置主動(dòng)去連接每一個(gè)區(qū)服的進(jìn)程所new的對(duì)象。
每當(dāng)一個(gè)新的連接建立的時(shí)候,就會(huì)占用一個(gè)節(jié)點(diǎn),并將上述的子類(lèi)指針賦值給CPollerObject *poller。當(dāng)監(jiān)聽(tīng)新連接事件(將新的fd納入到epoll體系)的時(shí)候,會(huì)將該節(jié)點(diǎn)的index索引賦值給struct epoll_event的data成員然后調(diào)用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中拿到對(duì)應(yīng)對(duì)象指針在連接池的索引,進(jìn)而可以以O(1)的時(shí)間復(fù)雜度從連接池中拿到對(duì)象指針。
因?yàn)椴煌腦XXAsync子類(lèi)對(duì)可讀可寫(xiě)錯(cuò)誤處理的事件有不同的處理,因此分別重載了父類(lèi)的可讀可寫(xiě)錯(cuò)誤處理的調(diào)用函數(shù):
virtual void InputNotify (void);
virtual void OutputNotify (void);
virtual void HangupNotify (void);
父類(lèi)對(duì)象指針保存了子類(lèi)的對(duì)象指針,這里用了C++語(yǔ)言的多態(tài)特性。
如果不用連接池的設(shè)計(jì),那么調(diào)用epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)時(shí)傳入的struct epoll_event的data參數(shù)中完全可以傳對(duì)象的指針。上述中已經(jīng)討論了連接池存在的必要性。
4.Access接入進(jìn)程對(duì)客戶(hù)端數(shù)據(jù)的轉(zhuǎn)發(fā)
Access進(jìn)程的對(duì)象功能圖:
玩家登入選擇區(qū)服登入游戲開(kāi)始游玩。如果你是程序員你可能就會(huì)覺(jué)得玩家客戶(hù)端和這個(gè)區(qū)服的進(jìn)程建立了一條Tcp連接來(lái)收發(fā)數(shù)據(jù),但是真實(shí)情況往往不是這樣的。
Access進(jìn)程作為接入進(jìn)程,有多少個(gè)CClientAsync對(duì)象就代表有多少個(gè)客戶(hù)端連接。同時(shí)Access接入進(jìn)程又作為客戶(hù)端對(duì)每一個(gè)服的進(jìn)程(Logic進(jìn)程)發(fā)起Tcp連接并new一個(gè)CBattleAsync對(duì)象,這些對(duì)象的指針存放在以區(qū)服ID為Key的容器map<uint32_t, CBattleAsync*>內(nèi)。
客戶(hù)端連接建立成功后會(huì)發(fā)送第一個(gè)數(shù)據(jù)包就是區(qū)服id并記錄在CClienAsync對(duì)象的數(shù)據(jù)成員里面。
玩家角色客戶(hù)端發(fā)送數(shù)據(jù)包給服務(wù)器的流程:通過(guò)該角色---該客戶(hù)端連接---拿到該區(qū)服ID---拿到該CBattleAsync對(duì)象---拿到該區(qū)服的連接,將數(shù)據(jù)通過(guò)區(qū)服的連接發(fā)送給玩家所在區(qū)服的進(jìn)程。
Access接入進(jìn)程和Logic進(jìn)程是一種多對(duì)一的關(guān)系,那么Logic進(jìn)程如何區(qū)分出不同的客戶(hù)端連接就是通過(guò)原封不動(dòng)的返回Access接入進(jìn)程發(fā)送過(guò)來(lái)的包體內(nèi)容。
一個(gè)客戶(hù)端發(fā)起Tcp連接,Access進(jìn)程的epoll事件觸發(fā)并由Accep()函數(shù)接收文件描述符。Access進(jìn)程依據(jù)文件描述符創(chuàng)建一個(gè)CClientAsync對(duì)象,并對(duì)對(duì)象的數(shù)據(jù)成員fd、srvId、time、microTime進(jìn)行賦值,將該對(duì)象的指針以fd為key放入一個(gè)map容器內(nèi)。當(dāng)客戶(hù)端有數(shù)據(jù)包發(fā)給后臺(tái)時(shí)(其實(shí)是將數(shù)據(jù)發(fā)送給Logic進(jìn)程),通過(guò)epoll event返回的index找到連接池該對(duì)象的指針,調(diào)用CClientAsync對(duì)象的可讀處理事件。依據(jù)srvId拿到Access接入進(jìn)程維護(hù)的對(duì)不同區(qū)服Logic進(jìn)程的連接的對(duì)象。發(fā)送給區(qū)服前封裝包體以讓Logic進(jìn)程能標(biāo)識(shí)出不同的客戶(hù)端。
Logic進(jìn)程在恰當(dāng)?shù)臅r(shí)候會(huì)將包頭的fd、SrvId、time、microTime記錄下來(lái),叫做一條客戶(hù)端”連接“。
5.連接關(guān)閉
由以上客戶(hù)端的連接可知,如果連接關(guān)閉需要做兩件事情。
一、Access接入進(jìn)程從Epoll的監(jiān)聽(tīng)體系里面剔除要關(guān)閉連接的文件描述符。
二、告知Logic進(jìn)程剔除該客戶(hù)端的連接映射,并做角色下線操作。
連接關(guān)閉的情況分兩種:
1.客戶(hù)端主動(dòng)斷開(kāi)連接
Access接入進(jìn)程收到某個(gè)客戶(hù)端連接recv()返回長(zhǎng)度為0代表客戶(hù)端—Access接入進(jìn)程的TCP連接關(guān)閉。Access進(jìn)程可以從Epoll體系里面移除該文件描述符的監(jiān)聽(tīng)。然后再構(gòu)建一個(gè)包發(fā)給Logic進(jìn)程告知Logic進(jìn)程對(duì)于的連接映射已關(guān)閉讓Logic進(jìn)程對(duì)該連接的角色做下線操作。最后Access接入進(jìn)程將該CClienAsync對(duì)象析構(gòu)。
2.服務(wù)器斷開(kāi)客戶(hù)端連接
這種情況就是Logic進(jìn)程掛了,那么Access接入進(jìn)程—Logic進(jìn)程的TCP連接就會(huì)被關(guān)閉。Access接入進(jìn)程在要關(guān)閉那個(gè)連接的CBattleAsync對(duì)象下recv()返回長(zhǎng)度為0。Access接入進(jìn)程做的事情是將該文件描述符從Epoll的監(jiān)聽(tīng)體系內(nèi)移除,但是并不析構(gòu)CBattleAsync對(duì)象,因?yàn)锳ccess接入進(jìn)程作為服務(wù)端是在進(jìn)程一啟動(dòng)的時(shí)候去連的各個(gè)區(qū)服的Logic進(jìn)程,如果析構(gòu)了CBattleAsync對(duì)象那么就變復(fù)雜了,Access接入進(jìn)程需要定時(shí)去檢測(cè)和各個(gè)服的連接是否正常,實(shí)屬多余。
Access接入進(jìn)程—Logic進(jìn)程的TCP連接建立成功以后會(huì)將CBattleAsync對(duì)象的成員變量m_stat設(shè)置為CONNECTED,因此當(dāng)該TCP連接關(guān)閉以后,直接將m_stat變量設(shè)置為IDLE狀態(tài)即可,且也不將該對(duì)象從容器內(nèi)剔除。
Access接入進(jìn)程作為服務(wù)端并沒(méi)有再去和Logic進(jìn)程建立TCP連接,那么當(dāng)Logic進(jìn)程重新啟動(dòng)以后怎么重建Access接入進(jìn)程—Logic進(jìn)程的TCP連接?
答應(yīng)是由連接到該區(qū)服Logic的玩家客戶(hù)端來(lái)重建,玩家客戶(hù)端其實(shí)并不知道Access接入進(jìn)程—Logic進(jìn)程的TCP連接是個(gè)什么狀態(tài),他會(huì)發(fā)協(xié)議包給到Access接入進(jìn)程,然后Access接入進(jìn)程轉(zhuǎn)交給Logic進(jìn)程的時(shí)候發(fā)現(xiàn)該連接的狀態(tài)m_stat是IDLE狀態(tài),于是就會(huì)讓Access接入進(jìn)程重新和該Logic進(jìn)程發(fā)起TCP連接。再將包轉(zhuǎn)給Logic進(jìn)程。
6.Epoll觸發(fā)方式的選擇:
上面講了整個(gè)網(wǎng)絡(luò)I/O是用的epoll,那么對(duì)每個(gè)fd設(shè)置監(jiān)聽(tīng)事件采取的觸發(fā)方式是什么。我們這里使用的是LT(水平觸發(fā))+Non_Blocking(非阻塞)的方式。
采用這種觸發(fā)方式必然也就要會(huì)有不同的處理。
1.對(duì)于監(jiān)聽(tīng)新連接到來(lái)的ListenFd,一般采用非阻塞的原因有:
a.采用阻塞ListenFd可能會(huì)導(dǎo)致其他連接的可讀可寫(xiě)事件無(wú)法被及時(shí)處理(單線程/單進(jìn)程的情況下)。Tcp完成三次握手將該連接放入一個(gè)隊(duì)列里面。epoll感知到該連接存在返回ListenFd的可讀事件,由Accept()函數(shù)拿到該連接的文件描述符。如果采用了阻塞的ListenFd,就會(huì)導(dǎo)致一種情況:如果Tcp完成三次握手后客戶(hù)端就發(fā)送RST報(bào)文直接斷開(kāi)連接,該連接在內(nèi)核內(nèi)已經(jīng)被斷開(kāi)。但是epoll依舊會(huì)返回ListenFd的可讀事件,如果是阻塞的ListenFd,此時(shí)就緒隊(duì)列內(nèi)并沒(méi)有文件描述符返回,那么程序就會(huì)阻塞在Accept()函數(shù)內(nèi),直到下一個(gè)連接的到來(lái)。如果采用非阻塞ListenFd,在Accept()函數(shù)之前連接被RST報(bào)文斷開(kāi),那么Accept()也會(huì)返回并指定錯(cuò)誤碼。
b.ET模式下采用非阻塞模式可以防止有連接未被及時(shí)處理的情況。在ET模式下,如果多個(gè)連接同時(shí)到達(dá),ListenFd對(duì)應(yīng)的內(nèi)核緩沖區(qū)積累了多個(gè)。但是Epoll只會(huì)觸發(fā)一次,因此如果要正確及時(shí)處理這些堆積的連接就需要在Accept()函數(shù)包一層while循環(huán)。如果采用阻塞的ListenFd,最后一次循環(huán)調(diào)用Accept()函數(shù)的時(shí)候進(jìn)程就被阻塞了,此時(shí)進(jìn)程就喪失了處理其他事件的能力。正確的方式是采用非阻塞的方式,最后一次Accept()函數(shù)返回-1并將errno設(shè)置為EAGAIN。
while (true) //對(duì)于非阻塞的ListenFd,這里也可不采用循環(huán)。因?yàn)槿绻鸏istenFd內(nèi)有待處理的連接,會(huì)一直觸發(fā)epoll的可讀事件
{
peerSize = sizeof (peer);
newfd = accept (netfd, &peer, &peerSize);
if (newfd == -1)
{
//如果不是阻塞的系統(tǒng)調(diào)用被中斷并且不是繼續(xù)嘗試上述函數(shù)調(diào)用,那么這次的accept函數(shù)錯(cuò)誤有點(diǎn)嚴(yán)重呀
if (errno != EINTR && errno != EAGAIN )
LOG_NOTICE("[%s]accept failed, fd=%d, %m", name, netfd);
//如果錯(cuò)誤是因?yàn)镋MFILE達(dá)到了進(jìn)程可打開(kāi)的最大文件描述符
//如果錯(cuò)誤是因?yàn)镋NFILE達(dá)到了系統(tǒng)可打開(kāi)的最大文件描述符
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.對(duì)于客戶(hù)端連接有數(shù)據(jù)到來(lái)的可讀事件:只需要指定一個(gè)緩沖區(qū)讀就行了。如果沒(méi)有一次性將內(nèi)核緩沖區(qū)內(nèi)的數(shù)據(jù)讀完,那么下次epoll_wait返回以后繼續(xù)讀就完事了。
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.對(duì)于可寫(xiě)事件:因?yàn)椴捎玫氖欠亲枞姆绞剑蟛糠謺r(shí)候內(nèi)核緩沖區(qū)都是空的,即可寫(xiě)事件一直都會(huì)發(fā)生。因此對(duì)于可寫(xiě)事件需要做一個(gè)類(lèi)似水龍頭開(kāi)關(guān)的設(shè)計(jì),如果有水(即用戶(hù)態(tài)緩沖區(qū)有數(shù)據(jù)需要發(fā)送),那么打開(kāi)水龍頭(向epoll注冊(cè)監(jiān)聽(tīng)可寫(xiě)事件),將水放干,關(guān)閉水龍頭(向epoll解除可寫(xiě)事件的監(jiān)聽(tīng))
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();
}
}
四、服務(wù)器優(yōu)化
1.子線程空轉(zhuǎn)浪費(fèi)cpu資源問(wèn)題
Logic進(jìn)程的子線程主要是Load檔操作,加鎖從隊(duì)列里面拿到任務(wù)然后工作。如果隊(duì)列為空,那么釋放鎖然后調(diào)用usleep(1000)睡眠1毫秒。
dbwriter進(jìn)程的子線程主要是調(diào)用數(shù)據(jù)庫(kù)緩沖層的阻塞或者慢操作將數(shù)據(jù)持久化。同樣也是加鎖從隊(duì)列里面拿到任務(wù)然后工作,如果隊(duì)列為空,同樣睡眠1毫秒。
transit進(jìn)程的子線程主要是調(diào)用阻塞調(diào)用http接口等待校驗(yàn)返回,同樣也是隊(duì)列為空以后睡眠1毫秒。
為了避免這些子線程多次無(wú)意義的加鎖釋放鎖,引入條件變量即可:
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()
子線程加鎖以后,判斷隊(duì)列是否為空,如果為空,那么釋放鎖阻塞在條件變量處等待主線程往隊(duì)列里面添加任務(wù)后再喚醒子線程。
2.dbwrter進(jìn)程的優(yōu)化
dbwriter進(jìn)程是異步存檔用的。Logic邏輯進(jìn)程產(chǎn)生數(shù)據(jù)以后會(huì)將數(shù)據(jù)發(fā)送給dbwriter進(jìn)程的存檔隊(duì)列里面,dbwriter進(jìn)程定時(shí)從隊(duì)列里面取數(shù)據(jù)然后調(diào)用數(shù)據(jù)庫(kù)緩存層的接口將數(shù)據(jù)再轉(zhuǎn)交給數(shù)據(jù)庫(kù)緩存層。
這里的問(wèn)題是,如果某個(gè)時(shí)間段dbwriter進(jìn)程的存檔隊(duì)列發(fā)生了堆積(可能產(chǎn)生的原因是數(shù)據(jù)庫(kù)壓力過(guò)大,或者Logic進(jìn)程產(chǎn)生數(shù)據(jù)過(guò)快)。這個(gè)時(shí)候恰好遇到版本更新需要?dú)⒌暨M(jìn)程,那么就會(huì)導(dǎo)致這些玩家需要存檔的數(shù)據(jù)丟失。
解決這個(gè)問(wèn)題的辦法就是利用信號(hào),之前殺掉進(jìn)程利用的是kill -9 Pid,現(xiàn)在發(fā)送kill -USR2 Pid自定義信號(hào),dbwriter捕獲自定義信號(hào)后等待隊(duì)列為空以后再退出進(jìn)程。
3.transit校驗(yàn)進(jìn)程的優(yōu)化
transit進(jìn)程的作用是后臺(tái)服務(wù)器對(duì)玩家賬戶(hù)的再一次校驗(yàn)。由于特殊的原因是通過(guò)Http請(qǐng)求向第三方請(qǐng)求校驗(yàn)結(jié)果。所以transit進(jìn)程的工作很簡(jiǎn)單,收到Logic進(jìn)程轉(zhuǎn)發(fā)過(guò)來(lái)需要校驗(yàn)的玩家數(shù)據(jù)包以后發(fā)起一次Http請(qǐng)求并阻塞等待結(jié)果,然后將結(jié)果返回給Logic進(jìn)程。
一開(kāi)始的transit進(jìn)程采用的是單線程處理,一次Http請(qǐng)求的時(shí)延大概是20ms~100ms之間。也就是說(shuō)一秒鐘最多處理的請(qǐng)求量也就是10~50次。這遠(yuǎn)遠(yuǎn)不夠呀,一開(kāi)始游戲上線就遭遇瓶頸了,大部分玩家點(diǎn)擊登錄的時(shí)候要等待很長(zhǎng)時(shí)間。于是著手優(yōu)化此處。
1.采用多線程處理。4核cpu采用4個(gè)子線程+1個(gè)主線程處理,這樣就將一秒鐘能處理的請(qǐng)求量提升了4倍至40~200次。但是只是單純地用多個(gè)線程去處理,依舊會(huì)有瓶頸的存在。于是就搭配了第二種辦法。
2.增加校驗(yàn)緩存。如果玩家已經(jīng)走過(guò)一遍登錄校驗(yàn)流程,在短時(shí)間內(nèi)重復(fù)登錄的時(shí)候,其實(shí)已經(jīng)完全沒(méi)有必要再走一遍向第三方請(qǐng)求校驗(yàn)的流程了。因此增加一層緩存層,可以完全解決瓶頸問(wèn)題。
Transit進(jìn)程的子線程為了盡可能簡(jiǎn)單,所以只負(fù)責(zé)從緩存查找是否命中以及向Http請(qǐng)求結(jié)果。將結(jié)果發(fā)送的操作還是交給主線程去完成。這一套下來(lái),多了3個(gè)鎖的數(shù)據(jù)成員和3個(gè)隊(duì)列。
4.定時(shí)器實(shí)現(xiàn)的優(yōu)化
Logic進(jìn)程的定時(shí)器設(shè)計(jì)是開(kāi)了一個(gè)專(zhuān)門(mén)的定時(shí)器線程,然后定時(shí)器線程和主線程之間建立了一個(gè)TCP連接。定時(shí)器線程在循環(huán)里面一直調(diào)用select()超時(shí)返回以后給主線程發(fā)送一個(gè)空數(shù)據(jù)包,主線程收到數(shù)據(jù)包以后做定時(shí)操作。
這個(gè)蛋疼的定時(shí)器設(shè)計(jì)問(wèn)題有二個(gè):
一、新開(kāi)了一個(gè)線程不斷循環(huán),浪費(fèi)了CPU資源。
二、利用TCP連接發(fā)包的形式通知主線程定時(shí)觸發(fā)。雖然select()函數(shù)的精度可以達(dá)到微秒級(jí)別,但是引入了TCP/IP,單單是TCP的Nagle特性就足以讓定時(shí)器的精度難以確定了。更別說(shuō)網(wǎng)絡(luò)傳輸之間的傳輸延遲了。
這一層利用TCP其實(shí)也是為了將定時(shí)器納入到主線程的Epoll體系里面。但是要將定時(shí)器的時(shí)間概念納入到Epoll體系里面已經(jīng)有一個(gè)更好的實(shí)現(xiàn)了就是Timerfd系列:
timerfd_create, timerfd_settime, timerfd_gettime - timers that notify via file descriptors
Timerfd的時(shí)間精度可以達(dá)到納秒級(jí)別,將定時(shí)器的實(shí)現(xiàn)改為T(mén)imerfd以后。可以減少一個(gè)線程帶來(lái)的CPU浪費(fèi)(幾百個(gè)服就是幾百個(gè)線程了),省去了網(wǎng)絡(luò)傳輸?shù)难舆t,定時(shí)器的準(zhǔn)度更加可控。
Timerfd系列的實(shí)現(xiàn)是2008年Linux內(nèi)核發(fā)布版本v2.6.25以后才有的。
當(dāng)時(shí)實(shí)現(xiàn)這套定時(shí)器功能的時(shí)候Timerfd根本就還沒(méi)有,這是一套遠(yuǎn)古級(jí)別的騰訊代碼流傳至今。
總結(jié)
以上是生活随笔為你收集整理的放置类游戏后端服务器架构设计与实现的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 交换机 BootROM 下的升级配置
- 下一篇: 嬲怎么读(嬲嫐这两个字念什么)