高性能服務(wù)器架構(gòu)思路「不僅是思路」

2021-02-13    分類: 網(wǎng)站建設(shè)

在服務(wù)器端程序開發(fā)領(lǐng)域,性能問(wèn)題一直是備受關(guān)注的重點(diǎn)。業(yè)界有大量的框架、組件、類庫(kù)都是以性能為賣點(diǎn)而廣為人知。然而,服務(wù)器端程序在性能問(wèn)題上應(yīng)該有何種基本思路,這個(gè)卻很少被這些項(xiàng)目的文檔提及。本文正式希望介紹服務(wù)器端解決性能問(wèn)題的基本策略和經(jīng)典實(shí)踐,并分為幾個(gè)部分來(lái)說(shuō)明:

1.緩存策略的概念和實(shí)例

2.緩存策略的難點(diǎn):不同特點(diǎn)的緩存數(shù)據(jù)的清理機(jī)制

3.分布策略的概念和實(shí)例

4.分布策略的難點(diǎn):共享數(shù)據(jù)安全性與代碼復(fù)雜度的平衡

緩存

緩存策略的概念

我們提到服務(wù)器端性能問(wèn)題的時(shí)候,往往會(huì)混淆不清。因?yàn)楫?dāng)我們?cè)L問(wèn)一個(gè)服務(wù)器時(shí),出現(xiàn)服務(wù)卡住不能得到數(shù)據(jù),就會(huì)認(rèn)為是“性能問(wèn)題”。但是實(shí)際上這個(gè)性能問(wèn)題可能是有不同的原因,表現(xiàn)出來(lái)都是針對(duì)客戶請(qǐng)求的延遲很長(zhǎng)甚至中斷。我們來(lái)看看這些原因有哪些:第一個(gè)是所謂并發(fā)數(shù)不足,也就是同時(shí)請(qǐng)求的客戶過(guò)多,導(dǎo)致超過(guò)容納能力的客戶被拒絕服務(wù),這種情況往往會(huì)因?yàn)榉?wù)器內(nèi)存耗盡而導(dǎo)致的;第二個(gè)是處理延遲過(guò)長(zhǎng),也就是有一些客戶的請(qǐng)求處理時(shí)間已經(jīng)超過(guò)用戶可以忍受的長(zhǎng)度,這種情況常常表現(xiàn)為CPU占用滿額100%。

我們?cè)诜?wù)器開發(fā)的時(shí)候,最常用到的有下面這幾種硬件:CPU、內(nèi)存、磁盤、網(wǎng)卡。其中CPU是代表計(jì)算機(jī)處理時(shí)間的,硬盤的空間一般很大,主要是讀寫磁盤會(huì)帶來(lái)比較大的處理延遲,而內(nèi)存、網(wǎng)卡則是受存儲(chǔ)、帶寬的容量限制的。所以當(dāng)我們的服務(wù)器出現(xiàn)性能問(wèn)題的時(shí)候,就是這幾個(gè)硬件某一個(gè)甚至幾個(gè)都出現(xiàn)負(fù)荷占滿的情況。這四個(gè)硬件的資源一般可以抽象成兩類:一類是時(shí)間資源,比如CPU和磁盤讀寫;一類是空間資源,比如內(nèi)存和網(wǎng)卡帶寬。所以當(dāng)我們的服務(wù)器出現(xiàn)性能問(wèn)題,有一個(gè)最基本的思路,就是——時(shí)間空間轉(zhuǎn)換。我們可以舉幾個(gè)例子來(lái)說(shuō)明這個(gè)問(wèn)題。


水壩就是用水庫(kù)空間來(lái)?yè)Q流量時(shí)間的例子

當(dāng)我們?cè)L問(wèn)一個(gè)WEB的網(wǎng)站的時(shí)候,輸入的URL地址會(huì)被服務(wù)器變成對(duì)磁盤上某個(gè)文件的讀取。如果有大量的用戶訪問(wèn)這個(gè)網(wǎng)站,每次的請(qǐng)求都會(huì)造成對(duì)磁盤的讀操作,可能會(huì)讓磁盤不堪重負(fù),導(dǎo)致無(wú)法即時(shí)讀取到文件內(nèi)容。但是如果我們寫的程序,會(huì)把讀取過(guò)一次的文件內(nèi)容,長(zhǎng)時(shí)間的保存在內(nèi)存中,當(dāng)有另外一個(gè)對(duì)同樣文件的讀取時(shí),就直接從內(nèi)存中把數(shù)據(jù)返回給客戶端,就無(wú)需去讓磁盤讀取了。由于用戶訪問(wèn)的文件往往很集中,所以大量的請(qǐng)求可能都能從內(nèi)存中找到保存的副本,這樣就能大大提高服務(wù)器能承載的訪問(wèn)量了。這種做法,就是用內(nèi)存的空間,換取了磁盤的讀寫時(shí)間,屬于用空間換時(shí)間的策略。


方便面預(yù)先緩存了大量的烹飪操作

舉另外一個(gè)例子:我們寫一個(gè)網(wǎng)絡(luò)游戲的服務(wù)器端程序,通過(guò)讀寫數(shù)據(jù)庫(kù)來(lái)提供玩家資料存檔。如果有大量玩家進(jìn)入這個(gè)服務(wù)器,必定有很多玩家的數(shù)據(jù)資料變化,比如升級(jí)、獲得武器等等,這些通過(guò)讀寫數(shù)據(jù)庫(kù)來(lái)實(shí)現(xiàn)的操作,可能會(huì)讓數(shù)據(jù)庫(kù)進(jìn)程負(fù)荷過(guò)重,導(dǎo)致玩家無(wú)法即時(shí)完成游戲操作。我們會(huì)發(fā)現(xiàn)游戲中的讀操作,大部分都是針是對(duì)一些靜態(tài)數(shù)據(jù)的,比如游戲中的關(guān)卡數(shù)據(jù)、武器道具的具體信息;而很多寫操作,實(shí)際上是會(huì)覆蓋的,比如我的經(jīng)驗(yàn)值,可能每打一個(gè)怪都會(huì)增加幾十點(diǎn),但是最后記錄的只是最終的一個(gè)經(jīng)驗(yàn)值,而不會(huì)記錄下打怪的每個(gè)過(guò)程。所以我們也可以使用時(shí)空轉(zhuǎn)換的策略來(lái)提供性能:我們可以用內(nèi)存,把那些游戲中的靜態(tài)數(shù)據(jù),都一次性讀取并保存起來(lái),這樣每次讀這些數(shù)據(jù),都和數(shù)據(jù)庫(kù)無(wú)關(guān)了;而玩家的資料數(shù)據(jù),則不是每次變化都去寫數(shù)據(jù)庫(kù),而是先在內(nèi)存中保持一個(gè)玩家數(shù)據(jù)的副本,所有的寫操作都先去寫內(nèi)存中的結(jié)構(gòu),然后定期再由服務(wù)器主動(dòng)寫回到數(shù)據(jù)庫(kù)中,這樣可以把多次的寫數(shù)據(jù)庫(kù)操作變成一次寫操作,也能節(jié)省很多寫數(shù)據(jù)庫(kù)的消耗。這種做法也是用空間換時(shí)間的策略。


拼裝家具很省運(yùn)輸空間,但是安裝很費(fèi)時(shí)

最后說(shuō)說(shuō)用時(shí)間換空間的例子:假設(shè)我們要開發(fā)一個(gè)企業(yè)通訊錄的數(shù)據(jù)存儲(chǔ)系統(tǒng),客戶要求我們能保存下通訊錄的每次新增、修改、刪除操作,也就是這個(gè)數(shù)據(jù)的所有變更歷史,以便可以讓數(shù)據(jù)回退到任何一個(gè)過(guò)去的時(shí)間點(diǎn)。那么我們最簡(jiǎn)單的做法,就是這個(gè)數(shù)據(jù)在任何變化的時(shí)候,都拷貝一份副本。但是這樣會(huì)非常的浪費(fèi)磁盤空間,因?yàn)檫@個(gè)數(shù)據(jù)本身變化的部分可能只有很小一部分,但是要拷貝的副本可能很大。這種情況下,我們就可以在每次數(shù)據(jù)變化的時(shí)候,都記下一條記錄,內(nèi)容就是數(shù)據(jù)變化的情況:插入了一條內(nèi)容是某某的聯(lián)系方法、刪除了一條某某的聯(lián)系方法……,這樣我們記錄的數(shù)據(jù),僅僅就是變化的部分,而不需要拷貝很多份副本。當(dāng)我們需要恢復(fù)到任何一個(gè)時(shí)間點(diǎn)的時(shí)候,只需要按這些記錄依次對(duì)數(shù)據(jù)修改一遍,直到指定的時(shí)間點(diǎn)的記錄即可。這個(gè)恢復(fù)的時(shí)間可能會(huì)有點(diǎn)長(zhǎng),但是卻可以大大節(jié)省存儲(chǔ)空間。這就是用CPU的時(shí)間來(lái)?yè)Q磁盤的存儲(chǔ)空間的策略。我們現(xiàn)在常見(jiàn)的MySQL InnoDB日志型數(shù)據(jù)表,以及SVN源代碼存儲(chǔ),都是使用這種策略的。

另外,我們的Web服務(wù)器,在發(fā)送HTML文件內(nèi)容的時(shí)候,往往也會(huì)先用ZIP壓縮,然后發(fā)送給瀏覽器,瀏覽器收到后要先解壓,然后才能顯示,這個(gè)也是用服務(wù)器和客戶端的CPU時(shí)間,來(lái)?yè)Q取網(wǎng)絡(luò)帶寬的空間。


在我們的計(jì)算機(jī)體系中,緩存的思路幾乎無(wú)處不在,比如我們的CPU里面就有1級(jí)緩存、2級(jí)緩存,他們就是為了用這些快速的存儲(chǔ)空間,換取對(duì)內(nèi)存這種相對(duì)比較慢的存儲(chǔ)空間的等待時(shí)間。我們的顯示卡里面也帶有大容量的緩存,他們是用來(lái)存儲(chǔ)顯示圖形的運(yùn)算結(jié)果的。


通往大空間的郊區(qū)路上容易交通堵塞

緩存的本質(zhì),除了讓“已經(jīng)處理過(guò)的數(shù)據(jù),不需要重復(fù)處理”以外,還有“以快速的數(shù)據(jù)存儲(chǔ)讀寫,代替較慢速的存儲(chǔ)讀寫”的策略。我們?cè)谶x擇緩存策略進(jìn)行時(shí)空轉(zhuǎn)換的時(shí)候,必須明確我們要轉(zhuǎn)換的時(shí)間和空間是否合理,是否能達(dá)到效果。比如早期有一些人會(huì)把WEB文件緩存在分布式磁盤上(例如NFS),但是由于通過(guò)網(wǎng)絡(luò)訪問(wèn)磁盤本身就是一個(gè)比較慢的操作,而且還會(huì)占用可能就不充裕的網(wǎng)絡(luò)帶寬空間,導(dǎo)致性能可能變得更慢。

在設(shè)計(jì)緩存機(jī)制的時(shí)候,我們還容易碰到另外一個(gè)風(fēng)險(xiǎn),就是對(duì)緩存數(shù)據(jù)的編程處理問(wèn)題。如果我們要緩存的數(shù)據(jù),并不是完全無(wú)需處理直接讀寫的,而是需要讀入內(nèi)存后,以某種語(yǔ)言的結(jié)構(gòu)體或者對(duì)象來(lái)處理的,這就需要涉及到“序列化”和“反序列化”的問(wèn)題。如果我們采用直接拷貝內(nèi)存的方式來(lái)緩存數(shù)據(jù),當(dāng)我們的這些數(shù)據(jù)需要跨進(jìn)程、甚至跨語(yǔ)言訪問(wèn)的時(shí)候,會(huì)出現(xiàn)那些指針、ID、句柄數(shù)據(jù)的失效。因?yàn)樵诹硗庖粋€(gè)進(jìn)程空間里,這些“標(biāo)記型”的數(shù)據(jù)都是不存在的。因此我們需要更深入的對(duì)數(shù)據(jù)緩存的方法,我們可能會(huì)使用所謂深拷貝的方案,也就是跟著那些指針去找出目標(biāo)內(nèi)存的數(shù)據(jù),一并拷貝。一些更現(xiàn)代的做法,則是使用所謂序列化方案來(lái)解決這個(gè)問(wèn)題,也就是用一些明確定義了的“拷貝方法”來(lái)定義一個(gè)結(jié)構(gòu)體,然后用戶就能明確的知道這個(gè)數(shù)據(jù)會(huì)被拷貝,直接取消了指針之類的內(nèi)存地址數(shù)據(jù)的存在。比如著名的Protocol Buffer就能很方便的進(jìn)行內(nèi)存、磁盤、網(wǎng)絡(luò)位置的緩存;現(xiàn)在我們常見(jiàn)的JSON,也被一些系統(tǒng)用來(lái)作為緩存的數(shù)據(jù)格式。

但是我們需要注意的是,緩存的數(shù)據(jù)和我們程序真正要操作的數(shù)據(jù),往往是需要進(jìn)行一些拷貝和運(yùn)算的,這就是序列化和反序列化的過(guò)程,這個(gè)過(guò)程很快,也有可能很慢。所以我們?cè)谶x擇數(shù)據(jù)緩存結(jié)構(gòu)的時(shí)候,必須要注意其轉(zhuǎn)換時(shí)間,否則你緩存的效果可能被這些數(shù)據(jù)拷貝、轉(zhuǎn)換消耗去很多,嚴(yán)重的甚至比不緩存更差。一般來(lái)說(shuō),緩存的數(shù)據(jù)越解決使用時(shí)的內(nèi)存結(jié)構(gòu),其轉(zhuǎn)換速度就越快,在這點(diǎn)上,Protocol Buffer采用TLV編碼,就比不上直接memcpy的一個(gè)C結(jié)構(gòu)體,但是比編碼成純文本的XML或者JSON要來(lái)的更快。因?yàn)榫幗獯a的過(guò)程往往要進(jìn)行復(fù)雜的查表映射,列表結(jié)構(gòu)等操作。

緩存策略的難點(diǎn)

雖然使用緩存思想似乎是一個(gè)很簡(jiǎn)單的事情,但是緩存機(jī)制卻有一個(gè)核心的難點(diǎn),就是——緩存清理。我們所說(shuō)的緩存,都是保存一些數(shù)據(jù),但是這些數(shù)據(jù)往往是會(huì)變化的,我們要針對(duì)這些變化,清理掉保存的“臟”數(shù)據(jù),卻可能不是那么容易。

首先我們來(lái)看看最簡(jiǎn)單的緩存數(shù)據(jù)——靜態(tài)數(shù)據(jù)。這種數(shù)據(jù)往往在程序的運(yùn)行時(shí)是不會(huì)變化的,比如Web服務(wù)器內(nèi)存中緩存的HTML文件數(shù)據(jù),就是這種。事實(shí)上,所有的不是由外部用戶上傳的數(shù)據(jù),都屬于這種“運(yùn)行時(shí)靜態(tài)數(shù)據(jù)”。一般來(lái)說(shuō),我們對(duì)這種數(shù)據(jù),可以采用兩種建立緩存的方法:一是程序一啟動(dòng),就一股腦把所有的靜態(tài)數(shù)據(jù)從文件或者數(shù)據(jù)庫(kù)讀入內(nèi)存;二就是程序啟動(dòng)的時(shí)候并不加載靜態(tài)數(shù)據(jù),而是等有用戶訪問(wèn)相關(guān)數(shù)據(jù)的時(shí)候,才去加載,這也就是所謂lazy load的做法。第一種方法編程比較簡(jiǎn)單,程序的內(nèi)存啟動(dòng)后就穩(wěn)定了,不太容易出現(xiàn)內(nèi)存漏洞(如果加載的緩存太多,程序在啟動(dòng)后立刻會(huì)因內(nèi)存不足而退出,比較容易發(fā)現(xiàn)問(wèn)題);第二種方法程序啟動(dòng)很快,但要對(duì)緩存占用的空間有所限制或者規(guī)劃,否則如果要緩存的數(shù)據(jù)太多,可能會(huì)耗盡內(nèi)存,導(dǎo)致在線服務(wù)中斷。

一般來(lái)說(shuō),靜態(tài)數(shù)據(jù)是不會(huì)“臟”的,因?yàn)闆](méi)有用戶會(huì)去寫緩存中的數(shù)據(jù)。但是在實(shí)際工作中,我們的在線服務(wù)往往會(huì)需要“立刻”變更一些緩存數(shù)據(jù)。比如在門戶網(wǎng)站上發(fā)布了一條新聞,我們會(huì)希望立刻讓所有訪問(wèn)的用戶都看到。按最簡(jiǎn)單的做法,我們一般只要重啟一下服務(wù)器進(jìn)程,內(nèi)存中的緩存就會(huì)消失了。對(duì)于靜態(tài)緩存的變化頻率非常低的業(yè)務(wù),這樣是可以的,但是如果是新聞網(wǎng)站,就不能每隔幾分鐘就重啟一下WEB服務(wù)器進(jìn)程,這樣會(huì)影響大量在線用戶的訪問(wèn)。常見(jiàn)的解決這類問(wèn)題有兩種處理策略:

第一種是使用控制命令。簡(jiǎn)單來(lái)說(shuō),就是在服務(wù)器進(jìn)程上,開通一個(gè)實(shí)時(shí)的命令端口,我們可以通過(guò)網(wǎng)絡(luò)數(shù)據(jù)包(如UDP包),或者Linux系統(tǒng)信號(hào)(如kill SIGUSR2進(jìn)程號(hào))之類的手段,發(fā)送一個(gè)命令消息給服務(wù)器進(jìn)程,讓進(jìn)程開始清理緩存。這種清理可能執(zhí)行的是最簡(jiǎn)單的“全部清理”,也有的可以細(xì)致一點(diǎn)的,讓命令消息中帶有“想清理的數(shù)據(jù)ID”這樣的信息,比如我們發(fā)送給WEB服務(wù)器的清理消息網(wǎng)絡(luò)包中會(huì)帶一個(gè)字符串URL,表示要清理哪一個(gè)HTML文件的緩存。這種做法的好處是清理的操作很精準(zhǔn),可以明確的控制清理的時(shí)間和數(shù)據(jù)。但是缺點(diǎn)就是比較繁瑣,手工去編寫發(fā)送這種命令很煩人,所以一般我們會(huì)把清理緩存命令的工作,編寫到上傳靜態(tài)數(shù)據(jù)的工具當(dāng)中,比如結(jié)合到網(wǎng)站的內(nèi)容發(fā)布系統(tǒng)中,一旦編輯提交了一篇新的新聞,發(fā)布系統(tǒng)的程序就自動(dòng)的發(fā)送一個(gè)清理消息給WEB服務(wù)器。

第二種是使用字段判斷邏輯。也就是服務(wù)器進(jìn)程,會(huì)在每次讀取緩存前,根據(jù)一些特征數(shù)據(jù),快速的判斷內(nèi)存中的緩存和源數(shù)據(jù)內(nèi)容,是否有不一致(是否臟)的地方,如果有不一致的地方,就自動(dòng)清理這條數(shù)據(jù)的緩存。這種做法會(huì)消耗一部分CPU,但是就不需要人工去處理清理緩存的事情,自動(dòng)化程度很高?,F(xiàn)在我們的瀏覽器和WEB服務(wù)器之間,就有用這種機(jī)制:檢查文件MD5;或者檢查文件最后更新時(shí)間。具體的做法,就是每次瀏覽器發(fā)起對(duì)WEB服務(wù)器的請(qǐng)求時(shí),除了發(fā)送URL給服務(wù)器外,還會(huì)發(fā)送一個(gè)緩存了此URL對(duì)應(yīng)的文件內(nèi)容的MD5校驗(yàn)串、或者是此文件在服務(wù)器上的“最后更新時(shí)間”(這個(gè)校驗(yàn)串和“最后更新時(shí)間”是第一次獲的文件時(shí)一并從服務(wù)器獲得的);服務(wù)器收到之后,就會(huì)把MD5校驗(yàn)串或者最后更新時(shí)間,和磁盤上的目標(biāo)文件進(jìn)行對(duì)比,如果是一致的,說(shuō)明這個(gè)文件沒(méi)有被修改過(guò)(緩存不是“臟”的),可以直接使用緩存。否則就會(huì)讀取目標(biāo)文件返回新的內(nèi)容給瀏覽器。這種做法對(duì)于服務(wù)器性能是有一定消耗的,所以如果往往我們還會(huì)搭配其他的緩存清理機(jī)制來(lái)用,比如我們會(huì)在設(shè)置一個(gè)“超時(shí)檢查”的機(jī)制:就是對(duì)于所有的緩存清理檢查,我們都簡(jiǎn)單的看看緩存存在的時(shí)間是否“超時(shí)”了,如果超過(guò)了,才進(jìn)行下一步的檢查,這樣就不用每次請(qǐng)求都去算MD5或者看最后更新時(shí)間了。但是這樣就存在“超時(shí)”時(shí)間內(nèi)緩存變臟的可能性。


WEB服務(wù)器靜態(tài)緩存例子

上面說(shuō)了運(yùn)行時(shí)靜態(tài)的緩存清理,現(xiàn)在說(shuō)說(shuō)運(yùn)行時(shí)變化的緩存數(shù)據(jù)。在服務(wù)器程序運(yùn)行期間,如果用戶和服務(wù)器之間的交互,導(dǎo)致了緩存的數(shù)據(jù)產(chǎn)生了變化,就是所謂“運(yùn)行時(shí)變化緩存”。比如我們玩網(wǎng)絡(luò)游戲,登錄之后的角色數(shù)據(jù)就會(huì)從數(shù)據(jù)庫(kù)里讀出來(lái),進(jìn)入服務(wù)器的緩存(可能是堆內(nèi)存或者memcached、共享內(nèi)存),在我們不斷進(jìn)行游戲操作的時(shí)候,對(duì)應(yīng)的角色數(shù)據(jù)就會(huì)產(chǎn)生修改的操作,這種緩存數(shù)據(jù)就是“運(yùn)行時(shí)變化的緩存”。這種運(yùn)行時(shí)變化的數(shù)據(jù),有讀和寫兩個(gè)方面的清理問(wèn)題:由于緩存的數(shù)據(jù)會(huì)變化,如果另外一個(gè)進(jìn)程從數(shù)據(jù)庫(kù)讀你的角色數(shù)據(jù),就會(huì)發(fā)現(xiàn)和當(dāng)前游戲里的數(shù)據(jù)不一致;如果服務(wù)器進(jìn)程突然結(jié)束了,你在游戲里升級(jí),或者撿道具的數(shù)據(jù)可能會(huì)從內(nèi)存緩存中消失,導(dǎo)致你白忙活了半天,這就是沒(méi)有回寫(緩存寫操作的清理)導(dǎo)致的問(wèn)題。這種情況在電子商務(wù)領(lǐng)域也很常見(jiàn),最典型的就是火車票網(wǎng)上購(gòu)買的系統(tǒng),火車票數(shù)據(jù)緩存在內(nèi)存必須有合適的清理機(jī)制,否則讓兩個(gè)買了同一張票就麻煩了,但如果不緩存,大量用戶同時(shí)搶票,服務(wù)器也應(yīng)對(duì)不過(guò)來(lái)。因此在運(yùn)行時(shí)變化的數(shù)據(jù)緩存,應(yīng)該有一些特別的緩存清理策略。

在實(shí)際運(yùn)行業(yè)務(wù)中,運(yùn)行變化的數(shù)據(jù)往往是根據(jù)使用用戶的增多而增多的,因此首先要考慮的問(wèn)題,就是緩存空間不夠的可能性。我們不太可能把全部數(shù)據(jù)都放到緩存的空間里,也不可能清理緩存的時(shí)候就全部數(shù)據(jù)一起清理,所以我們一般要對(duì)數(shù)據(jù)進(jìn)行分割,這種分割的策略常見(jiàn)的有兩種:一種是按重要級(jí)來(lái)分割,一種是按使用部分分割。

先舉例說(shuō)說(shuō)“按重要級(jí)分割”,在網(wǎng)絡(luò)游戲中,同樣是角色的數(shù)據(jù),有些數(shù)據(jù)的變化可能會(huì)每次修改都立刻回寫到數(shù)據(jù)庫(kù)(清理寫緩存),其他一些數(shù)據(jù)的變化會(huì)延遲一段時(shí)間,甚至有些數(shù)據(jù)直到角色退出游戲才回寫,如玩家的等級(jí)變化(升級(jí)了),武器裝備的獲得和消耗,這些玩家非??粗氐臄?shù)據(jù),基本上會(huì)立刻回寫,這些就是所謂最重要的緩存數(shù)據(jù)。而玩家的經(jīng)驗(yàn)值變化、當(dāng)前HP、MP的變化,就會(huì)延遲一段時(shí)間才寫,因?yàn)榫退銇G失了緩存,玩家也不會(huì)太過(guò)關(guān)注。最后有些比如玩家在房間(地區(qū))里的X/Y坐標(biāo),對(duì)話聊天的記錄,可能會(huì)退出時(shí)回寫,甚至不回寫。這個(gè)例子說(shuō)的是“寫緩存”的清理,下面說(shuō)說(shuō)“讀緩存”的按重要級(jí)分割清理。


假如我們寫一個(gè)網(wǎng)店系統(tǒng),里面容納了很多產(chǎn)品,這些產(chǎn)品有一些會(huì)被用戶頻繁檢索到,比較熱銷,而另外一些商品則沒(méi)那么熱銷。熱銷的商品的余額、銷量、評(píng)價(jià)都會(huì)比較頻繁的變化,而滯銷的商品則變化很少。所以我們?cè)谠O(shè)計(jì)的時(shí)候,就應(yīng)該按照不同商品的訪問(wèn)頻繁程度,來(lái)決定緩存哪些商品的數(shù)據(jù)。我們?cè)谠O(shè)計(jì)緩存的結(jié)構(gòu)時(shí),就應(yīng)該構(gòu)建一個(gè)可以統(tǒng)計(jì)緩存讀寫次數(shù)的指標(biāo),如果有些數(shù)據(jù)的讀寫頻率過(guò)低,或者空閑(沒(méi)有人讀、寫緩存)時(shí)間超長(zhǎng),緩存應(yīng)該主動(dòng)清理掉這些數(shù)據(jù),以便其他新的數(shù)據(jù)能進(jìn)入緩存。這種策略也叫做“冷熱交換”策略。實(shí)現(xiàn)“冷熱交換”的策略時(shí),關(guān)鍵是要定義一個(gè)合理的冷熱統(tǒng)計(jì)算法。一些固定的指標(biāo)和算法,往往并不能很好的應(yīng)對(duì)不同硬件、不同網(wǎng)絡(luò)情況下的變化,所以現(xiàn)在人們普遍會(huì)用一些動(dòng)態(tài)的算法,如Redis就采用了5種,他們是:

1.根據(jù)過(guò)期時(shí)間,清理最長(zhǎng)時(shí)間沒(méi)用過(guò)的

2.根據(jù)過(guò)期時(shí)間,清理即將過(guò)期的

3.根據(jù)過(guò)期時(shí)間,任意清理一個(gè)

4.無(wú)論是否過(guò)期,隨機(jī)清理

5.無(wú)論是否過(guò)期,根據(jù)LRU原則清理:所謂LRU,就是Least Recently Used,最近最久未使用過(guò)。這個(gè)原則的思想是:如果一個(gè)數(shù)據(jù)在最近一段時(shí)間沒(méi)有被訪問(wèn)到,那么在將來(lái)他被訪問(wèn)的可能性也很小。LRU是在操作系統(tǒng)中很常見(jiàn)的一種原則,比如內(nèi)存的頁(yè)面置換算法(也包括FIFO,LFU等),對(duì)于LRU的實(shí)現(xiàn),還是非常有技巧的,但是本文就不詳細(xì)去說(shuō)明如何實(shí)現(xiàn),留待大家上網(wǎng)搜索“LRU”關(guān)鍵字學(xué)習(xí)。

數(shù)據(jù)緩存的清理策略其實(shí)遠(yuǎn)不止上面所說(shuō)的這些,要用好緩存這個(gè)武器,就要仔細(xì)研究需要緩存的數(shù)據(jù)特征,他們的讀寫分布,數(shù)據(jù)之中的差別。然后大化的利用業(yè)務(wù)領(lǐng)域的知識(shí),來(lái)設(shè)計(jì)最合理的緩存清理策略。這個(gè)世界上不存在萬(wàn)能的優(yōu)化緩存清理策略,只存在針對(duì)業(yè)務(wù)領(lǐng)域最優(yōu)化的策略,這需要我們程序員深入理解業(yè)務(wù)領(lǐng)域,去發(fā)現(xiàn)數(shù)據(jù)背后的規(guī)律。


分布

分布策略的概念

任何的服務(wù)器的性能都是有極限的,面對(duì)海量的互聯(lián)網(wǎng)訪問(wèn)需求,是不可能單靠一臺(tái)服務(wù)器或者一個(gè)CPU來(lái)承擔(dān)的。所以我們一般都會(huì)在運(yùn)行時(shí)架構(gòu)設(shè)計(jì)之初,就考慮如何能利用多個(gè)CPU、多臺(tái)服務(wù)器來(lái)分擔(dān)負(fù)載,這就是所謂分布的策略。分布式的服務(wù)器概念很簡(jiǎn)單,但是實(shí)現(xiàn)起來(lái)卻比較復(fù)雜。因?yàn)槲覀儗懙某绦?,往往都是以一個(gè)CPU,一塊內(nèi)存為基礎(chǔ)來(lái)設(shè)計(jì)的,所以要讓多個(gè)程序同時(shí)運(yùn)行,并且協(xié)調(diào)運(yùn)作,這需要更多的底層工作。

首先出現(xiàn)能支持分布式概念的技術(shù)是多進(jìn)程。在DOS時(shí)代,計(jì)算機(jī)在一個(gè)時(shí)間內(nèi)只能運(yùn)行一個(gè)程序,如果你想一邊寫程序,同時(shí)一邊聽(tīng)mp3,都是不可能的。但是,在WIN95操作系統(tǒng)下,你就可以同時(shí)開多個(gè)窗口,背后就是同時(shí)在運(yùn)行多個(gè)程序。在Unix和后來(lái)的Linux操作系統(tǒng)里面,都普遍支持了多進(jìn)程的技術(shù)。所謂的多進(jìn)程,就是操作系統(tǒng)可以同時(shí)運(yùn)行我們編寫的多個(gè)程序,每個(gè)程序運(yùn)行的時(shí)候,都好像自己獨(dú)占著CPU和內(nèi)存一樣。在計(jì)算機(jī)只有一個(gè)CPU的時(shí)候,實(shí)際上計(jì)算機(jī)會(huì)分時(shí)復(fù)用的運(yùn)行多個(gè)進(jìn)程,CPU在多個(gè)進(jìn)程之間切換。但是如果這個(gè)計(jì)算機(jī)有多個(gè)CPU或者多個(gè)CPU核,則會(huì)真正的有幾個(gè)進(jìn)程同時(shí)運(yùn)行。所以進(jìn)程就好像一個(gè)操作系統(tǒng)提供的運(yùn)行時(shí)“程序盒子”,可以用來(lái)在運(yùn)行時(shí),容納任何我們想運(yùn)行的程序。當(dāng)我們掌握了操作系統(tǒng)的多進(jìn)程技術(shù)后,我們就可以把服務(wù)器上的運(yùn)行任務(wù),分為多個(gè)部分,然后分別寫到不同的程序里,利用上多CPU或者多核,甚至是多個(gè)服務(wù)器的CPU一起來(lái)承擔(dān)負(fù)載。


多進(jìn)程利用多CPU

這種劃分多個(gè)進(jìn)程的架構(gòu),一般會(huì)有兩種策略:一種是按功能來(lái)劃分,比如負(fù)責(zé)網(wǎng)絡(luò)處理的一個(gè)進(jìn)程,負(fù)責(zé)數(shù)據(jù)庫(kù)處理的一個(gè)進(jìn)程,負(fù)責(zé)計(jì)算某個(gè)業(yè)務(wù)邏輯的一個(gè)進(jìn)程。另外一種策略是每個(gè)進(jìn)程都是同樣的功能,只是分擔(dān)不同的運(yùn)算任務(wù)而已。使用第一種策略的系統(tǒng),運(yùn)行的時(shí)候,直接根據(jù)操作系統(tǒng)提供的診斷工具,就能直觀的監(jiān)測(cè)到每個(gè)功能模塊的性能消耗,因?yàn)椴僮飨到y(tǒng)提供進(jìn)程盒子的同時(shí),也能提供對(duì)進(jìn)程的全方位的監(jiān)測(cè),比如CPU占用、內(nèi)存消耗、磁盤和網(wǎng)絡(luò)I/O等等。但是這種策略的運(yùn)維部署會(huì)稍微復(fù)雜一點(diǎn),因?yàn)槿魏我粋€(gè)進(jìn)程沒(méi)有啟動(dòng),或者和其他進(jìn)程的通信地址沒(méi)配置好,都可能導(dǎo)致整個(gè)系統(tǒng)無(wú)法運(yùn)作;而第二種分布策略,由于每個(gè)進(jìn)程都是一樣的,這樣的安裝部署就非常簡(jiǎn)單,性能不夠就多找?guī)讉€(gè)機(jī)器,多啟動(dòng)幾個(gè)進(jìn)程就完成了,這就是所謂的平行擴(kuò)展。

現(xiàn)在比較復(fù)雜的分布式系統(tǒng),會(huì)結(jié)合這兩種策略,也就是說(shuō)系統(tǒng)既按一些功能劃分出不同的具體功能進(jìn)程,而這些進(jìn)程又是可以平行擴(kuò)展的。當(dāng)然這樣的系統(tǒng)在開發(fā)和運(yùn)維上的復(fù)雜度,都是比單獨(dú)使用“按功能劃分”和“平行劃分”要更高的。由于要管理大量的進(jìn)程,傳統(tǒng)的依靠配置文件來(lái)配置整個(gè)集群的做法,會(huì)顯得越來(lái)越不實(shí)用:這些運(yùn)行中的進(jìn)程,可能和其他很多進(jìn)程產(chǎn)生通信關(guān)系,當(dāng)其中一個(gè)進(jìn)程變更通信地址時(shí),勢(shì)必影響所有其他進(jìn)程的配置。所以我們需要集中的管理所有進(jìn)程的通信地址,當(dāng)有變化的時(shí)候,只需要修改一個(gè)地方。在大量進(jìn)程構(gòu)建的集群中,我們還會(huì)碰到容災(zāi)和擴(kuò)容的問(wèn)題:當(dāng)集群中某個(gè)服務(wù)器出現(xiàn)故障,可能會(huì)有一些進(jìn)程消失;而當(dāng)我們需要增加集群的承載能力時(shí),我們又需要增加新的服務(wù)器以及進(jìn)程。這些工作在長(zhǎng)期運(yùn)行的服務(wù)器系統(tǒng)中,會(huì)是比較常見(jiàn)的任務(wù),如果整個(gè)分布系統(tǒng)有一個(gè)運(yùn)行中的中心進(jìn)程,能自動(dòng)化的監(jiān)測(cè)所有的進(jìn)程狀態(tài),一旦有進(jìn)程加入或者退出集群,都能即時(shí)的修改所有其他進(jìn)程的配置,這就形成了一套動(dòng)態(tài)的多進(jìn)程管理系統(tǒng)。開源的ZooKeeper給我們提供了一個(gè)可以充當(dāng)這種動(dòng)態(tài)集群中心的實(shí)現(xiàn)方案。由于ZooKeeper本身是可以平行擴(kuò)展的,所以它自己也是具備一定容災(zāi)能力的?,F(xiàn)在越來(lái)越多的分布式系統(tǒng)都開始使用以ZooKeeper為集群中心的動(dòng)態(tài)進(jìn)程管理策略了。


動(dòng)態(tài)進(jìn)程集群

在調(diào)用多進(jìn)程服務(wù)的策略上,我們也會(huì)有一定的策略選擇,其中最著名的策略有三個(gè):一個(gè)是動(dòng)態(tài)負(fù)載均衡策略;一個(gè)是讀寫分離策略;一個(gè)是一致性哈希策略。動(dòng)態(tài)負(fù)載均衡策略,一般會(huì)搜集多個(gè)進(jìn)程的服務(wù)狀態(tài),然后挑選一個(gè)負(fù)載最輕的進(jìn)程來(lái)分發(fā)服務(wù),這種策略對(duì)于比較同質(zhì)化的進(jìn)程是比較合適的。讀寫分離策略則是關(guān)注對(duì)持久化數(shù)據(jù)的性能,比如對(duì)數(shù)據(jù)庫(kù)的操作,我們會(huì)提供一批進(jìn)程專門用于提供讀數(shù)據(jù)的服務(wù),而另外一個(gè)(或多個(gè))進(jìn)程用于寫數(shù)據(jù)的服務(wù),這些寫數(shù)據(jù)的進(jìn)程都會(huì)每次寫多份拷貝到“讀服務(wù)進(jìn)程”的數(shù)據(jù)區(qū)(可能就是單獨(dú)的數(shù)據(jù)庫(kù)),這樣在對(duì)外提供服務(wù)的時(shí)候,就可以提供更多的硬件資源。一致性哈希策略是針對(duì)任何一個(gè)任務(wù),看看這個(gè)任務(wù)所涉及讀寫的數(shù)據(jù),是屬于哪一片的,是否有某種可以緩存的特征,然后按這個(gè)數(shù)據(jù)的ID或者特征值,進(jìn)行“一致性哈?!钡挠?jì)算,分擔(dān)給對(duì)應(yīng)的處理進(jìn)程。這種進(jìn)程調(diào)用策略,能非常的利用上進(jìn)程內(nèi)的緩存(如果存在),比如我們的一個(gè)在線游戲,由100個(gè)進(jìn)程承擔(dān)服務(wù),那么我們就可以把游戲玩家的ID,作為一致性哈希的數(shù)據(jù)ID,作為進(jìn)程調(diào)用的KEY,如果目標(biāo)服務(wù)進(jìn)程有緩存游戲玩家的數(shù)據(jù),那么所有這個(gè)玩家的操作請(qǐng)求,都會(huì)被轉(zhuǎn)到這個(gè)目標(biāo)服務(wù)進(jìn)程上,緩存的命中率大大提高。而使用“一致性哈?!保皇瞧渌K惴?,或者取模算法,主要是考慮到,如果服務(wù)進(jìn)程有一部分因故障消失,剩下的服務(wù)進(jìn)程的緩存依然可以有效,而不會(huì)整個(gè)集群所有進(jìn)程的緩存都失效。具體有興趣的讀者可以搜索“一致性哈希”一探究竟。

以多進(jìn)程利用大量的服務(wù)器,以及服務(wù)器上的多個(gè)CPU核心,是一個(gè)非常有效的手段。但是使用多進(jìn)程帶來(lái)的額外的編程復(fù)雜度的問(wèn)題。一般來(lái)說(shuō)我們認(rèn)為最好是每個(gè)CPU核心一個(gè)進(jìn)程,這樣能最好的利用硬件。如果同時(shí)運(yùn)行的進(jìn)程過(guò)多,操作系統(tǒng)會(huì)消耗很多CPU時(shí)間在不同進(jìn)程的切換過(guò)程上。但是,我們?cè)缙谒@得的很多API都是阻塞的,比如文件I/O,網(wǎng)絡(luò)讀寫,數(shù)據(jù)庫(kù)操作等。如果我們只用有限的進(jìn)程來(lái)執(zhí)行帶這些阻塞操作的程序,那么CPU會(huì)大量被浪費(fèi),因?yàn)樽枞腁PI會(huì)讓有限的這些進(jìn)程停著等待結(jié)果。那么,如果我們希望能處理更多的任務(wù),就必須要啟動(dòng)更多的進(jìn)程,以便充分利用那些阻塞的時(shí)間,但是由于進(jìn)程是操作系統(tǒng)提供的“盒子”,這個(gè)盒子比較大,切換耗費(fèi)的時(shí)間也比較多,所以大量并行的進(jìn)程反而會(huì)無(wú)謂的消耗服務(wù)器資源。加上進(jìn)程之間的內(nèi)存一般是隔離的,進(jìn)程間如果要交換一些數(shù)據(jù),往往需要使用一些操作系統(tǒng)提供的工具,比如網(wǎng)絡(luò)socket,這些都會(huì)額外消耗服務(wù)器性能。因此,我們需要一種切換代價(jià)更少,通信方式更便捷,編程方法更簡(jiǎn)單的并行技術(shù),這個(gè)時(shí)候,多線程技術(shù)出現(xiàn)了。


在進(jìn)程盒子里面的線程盒子

多線程的特點(diǎn)是切換代價(jià)少,可以同時(shí)訪問(wèn)內(nèi)存。我們可以在編程的時(shí)候,任意讓某個(gè)函數(shù)放入新的線程去執(zhí)行,這個(gè)函數(shù)的參數(shù)可以是任何的變量或指針。如果我們希望和這些運(yùn)行時(shí)的線程通信,只要讀、寫這些指針指向的變量即可。在需要大量阻塞操作的時(shí)候,我們可以啟動(dòng)大量的線程,這樣就能較好的利用CPU的空閑時(shí)間;線程的切換代價(jià)比進(jìn)程低得多,所以我們能利用的CPU也會(huì)多很多。線程是一個(gè)比進(jìn)程更小的“程序盒子”,他可以放入某一個(gè)函數(shù)調(diào)用,而不是一個(gè)完整的程序。一般來(lái)說(shuō),如果多個(gè)線程只是在一個(gè)進(jìn)程里面運(yùn)行,那其實(shí)是沒(méi)有利用到多核CPU的并行好處的,僅僅是利用了單個(gè)空閑的CPU核心。但是,在JAVA和C#這類帶虛擬機(jī)的語(yǔ)言中,多線程的實(shí)現(xiàn)底層,會(huì)根據(jù)具體的操作系統(tǒng)的任務(wù)調(diào)度單位(比如進(jìn)程),盡量讓線程也成為操作系統(tǒng)可以調(diào)度的單位,從而利用上多個(gè)CPU核心。比如Linux2.6之后,提供了NPTL的內(nèi)核線程模型,JVM就提供了JAVA線程到NPTL內(nèi)核線程的映射,從而利用上多核CPU。而Windows系統(tǒng)中,據(jù)說(shuō)本身線程就是系統(tǒng)的最小調(diào)度單位,所以多線程也是利用上多核CPU的。所以我們?cè)谑褂肑AVAC#編程的時(shí)候,多線程往往已經(jīng)同時(shí)具備了多進(jìn)程利用多核CPU、以及切換開銷低的兩個(gè)好處。

早期的一些網(wǎng)絡(luò)聊天室服務(wù),結(jié)合了多線程和多進(jìn)程使用的例子。一開始程序會(huì)啟動(dòng)多個(gè)廣播聊天的進(jìn)程,每個(gè)進(jìn)程都代表一個(gè)房間;每個(gè)用戶連接到聊天室,就為他啟動(dòng)一個(gè)線程,這個(gè)線程會(huì)阻塞的讀取用戶的輸入流。這種模型在使用阻塞API的環(huán)境下,非常簡(jiǎn)單,但也非常有效。

當(dāng)我們?cè)趶V泛使用多線程的時(shí)候,我們發(fā)現(xiàn),盡管多線程有很多優(yōu)點(diǎn),但是依然會(huì)有明顯的兩個(gè)缺點(diǎn):一個(gè)內(nèi)存占用比較大且不太可控;第二個(gè)是多個(gè)線程對(duì)于用一個(gè)數(shù)據(jù)使用時(shí),需要考慮復(fù)雜的“鎖”問(wèn)題。由于多線程是基于對(duì)一個(gè)函數(shù)調(diào)用的并行運(yùn)行,這個(gè)函數(shù)里面可能會(huì)調(diào)用很多個(gè)子函數(shù),每調(diào)用一層子函數(shù),就會(huì)要在棧上占用新的內(nèi)存,大量線程同時(shí)在運(yùn)行的時(shí)候,就會(huì)同時(shí)存在大量的棧,這些棧加在一起,可能會(huì)形成很大的內(nèi)存占用。并且,我們編寫服務(wù)器端程序,往往希望資源占用盡量可控,而不是動(dòng)態(tài)變化太大,因?yàn)槟悴恢朗裁磿r(shí)候會(huì)因?yàn)閮?nèi)存用完而當(dāng)機(jī),在多線程的程序中,由于程序運(yùn)行的內(nèi)容導(dǎo)致棧的伸縮幅度可能很大,有可能超出我們預(yù)期的內(nèi)存占用,導(dǎo)致服務(wù)的故障。而對(duì)于內(nèi)存的“鎖”問(wèn)題,一直是多線程中復(fù)雜的課題,很多多線程工具庫(kù),都推出了大量的“無(wú)鎖”容器,或者“線程安全”的容器,并且還大量設(shè)計(jì)了很多協(xié)調(diào)線程運(yùn)作的類庫(kù)。但是這些復(fù)雜的工具,無(wú)疑都是證明了多線程對(duì)于內(nèi)存使用上的問(wèn)題。


同時(shí)排多條隊(duì)就是并行

由于多線程還是有一定的缺點(diǎn),所以很多程序員想到了一個(gè)釜底抽薪的方法:使用多線程往往是因?yàn)樽枞紸PI的存在,比如一個(gè)read()操作會(huì)一直停止當(dāng)前線程,那么我們能不能讓這些操作變成不阻塞呢?——selector/epoll就是Linux退出的非阻塞式API。如果我們使用了非阻塞的操作函數(shù),那么我們也無(wú)需用多線程來(lái)并發(fā)的等待阻塞結(jié)果。我們只需要用一個(gè)線程,循環(huán)的檢查操作的狀態(tài),如果有結(jié)果就處理,無(wú)結(jié)果就繼續(xù)循環(huán)。這種程序的結(jié)果往往會(huì)有一個(gè)大的死循環(huán),稱為主循環(huán)。在主循環(huán)體內(nèi),程序員可以安排每個(gè)操作事件、每個(gè)邏輯狀態(tài)的處理邏輯。這樣CPU既無(wú)需在多線程間切換,也無(wú)需處理復(fù)雜的并行數(shù)據(jù)鎖的問(wèn)題——因?yàn)橹挥幸粋€(gè)線程在運(yùn)行。這種就是被稱為“并發(fā)”的方案。


服務(wù)員兼了點(diǎn)菜、上菜就是并發(fā)

實(shí)際上計(jì)算機(jī)底層早就有使用并發(fā)的策略,我們知道計(jì)算機(jī)對(duì)于外部設(shè)備(比如磁盤、網(wǎng)卡、顯卡、聲卡、鍵盤、鼠標(biāo)),都使用了一種叫“中斷”的技術(shù),早期的電腦使用者可能還被要求配置IRQ號(hào)。這個(gè)中斷技術(shù)的特點(diǎn),就是CPU不會(huì)阻塞的一直停在等待外部設(shè)備數(shù)據(jù)的狀態(tài),而是外部數(shù)據(jù)準(zhǔn)備好后,給CPU發(fā)一個(gè)“中斷信號(hào)”,讓CPU轉(zhuǎn)去處理這些數(shù)據(jù)。非阻塞的編程實(shí)際上也是類似這種行為,CPU不會(huì)一直阻塞的等待某些I/O的API調(diào)用,而是先處理其他邏輯,然后每次主循環(huán)去主動(dòng)檢查一下這些I/O操作的狀態(tài)。

多線程和異步的例子,最著名就是Web服務(wù)器領(lǐng)域的Apache和Nginx的模型。Apache是多進(jìn)程/多線程模型的,它會(huì)在啟動(dòng)的時(shí)候啟動(dòng)一批進(jìn)程,作為進(jìn)程池,當(dāng)用戶請(qǐng)求到來(lái)的時(shí)候,從進(jìn)程池中分配處理進(jìn)程給具體的用戶請(qǐng)求,這樣可以節(jié)省多進(jìn)程/線程的創(chuàng)建和銷毀開銷,但是如果同時(shí)有大量的請(qǐng)求過(guò)來(lái),還是需要消耗比較高的進(jìn)程/線程切換。而Nginx則是采用epoll技術(shù),這種非阻塞的做法,可以讓一個(gè)進(jìn)程同時(shí)處理大量的并發(fā)請(qǐng)求,而無(wú)需反復(fù)切換。對(duì)于大量的用戶訪問(wèn)場(chǎng)景下,apache會(huì)存在大量的進(jìn)程,而nginx則可以僅用有限的進(jìn)程(比如按CPU核心數(shù)來(lái)啟動(dòng)),這樣就會(huì)比apache節(jié)省了不少“進(jìn)程切換”的消耗,所以其并發(fā)性能會(huì)更好。


Nginx的固定多進(jìn)程,一個(gè)進(jìn)程異步處理多個(gè)客戶端


Apache的多態(tài)多進(jìn)程,一個(gè)進(jìn)程處理一個(gè)客戶

在現(xiàn)代服務(wù)器端軟件中,nginx這種模型的運(yùn)維管理會(huì)更簡(jiǎn)單,性能消耗也會(huì)稍微更小一點(diǎn),所以成為最流行的進(jìn)程架構(gòu)。但是這種好處,會(huì)付出一些另外的代價(jià):非阻塞代碼在編程的復(fù)雜度變大。

分布式編程復(fù)雜度

以前我們的代碼,從上往下執(zhí)行,每一行都會(huì)占用一定的CPU時(shí)間,這些代碼的直接順序,也是和編寫的順序基本一致,任何一行代碼,都是唯一時(shí)刻的執(zhí)行任務(wù)。當(dāng)我們?cè)诰帉懛植际匠绦虻臅r(shí)候,我們的代碼將不再好像那些單進(jìn)程、單線程的程序一樣簡(jiǎn)單。我們要把同時(shí)運(yùn)行的不同代碼,在同一段代碼中編寫。就好像我們要把整個(gè)交響樂(lè)團(tuán)的每個(gè)樂(lè)器的樂(lè)譜,全部寫到一張紙上。為了解決這種編程的復(fù)雜度,業(yè)界發(fā)展出了多種編碼形式。

在多進(jìn)程的編碼模型上,fork()函數(shù)可以說(shuō)一個(gè)非常典型的代表。在一段代碼中,fork()調(diào)用之后的部分,可能會(huì)被新的進(jìn)程中執(zhí)行。要區(qū)分當(dāng)前代碼的所在進(jìn)程,要靠fork()的返回值變量。這種做法,等于把多個(gè)進(jìn)程的代碼都合并到一塊,然后通過(guò)某些變量作為標(biāo)志來(lái)劃分。這樣的寫法,對(duì)于不同進(jìn)程代碼大部份相同的“同質(zhì)進(jìn)程”來(lái)說(shuō),還是比較方便的,最怕就是有大量的不同邏輯要用不同的進(jìn)程來(lái)處理,這種情況下,我們就只能自己通過(guò)規(guī)范fork()附近的代碼,來(lái)控制混亂的局面。比較典型的是把fork()附近的代碼弄成一個(gè)類似分發(fā)器(dispatcher)的形式,把不同功能的代碼放到不同的函數(shù)中,以fork之前的標(biāo)記變量來(lái)決定如何調(diào)用。


動(dòng)態(tài)多進(jìn)程的代碼模式

在我們使用多線程的API時(shí),情況就會(huì)好很多,我們可以用一個(gè)函數(shù)指針,或者一個(gè)帶回調(diào)方法的對(duì)象,作為線程執(zhí)行的主體,并且以句柄或者對(duì)象的形式來(lái)控制這些線程。作為開發(fā)人員,我們只要掌握了對(duì)線程的啟動(dòng)、停止等有限的幾個(gè)API,就能很好的對(duì)并行的多線程進(jìn)行控制。這對(duì)比多進(jìn)程的fork()來(lái)說(shuō),從代碼上看會(huì)更直觀,只是我們必須要分清楚調(diào)用一個(gè)函數(shù),和新建一個(gè)線程去調(diào)用一個(gè)函數(shù),之間的差別:新建線程去調(diào)用函數(shù),這個(gè)操作會(huì)很快的結(jié)束,并不會(huì)依序去執(zhí)行那個(gè)函數(shù),而是代表著,那個(gè)函數(shù)中的代碼,可能和線程調(diào)用之后的代碼,交替的執(zhí)行。

由于多線程把“并行的任務(wù)”作為一個(gè)明確的編程概念定義了出來(lái),以句柄、對(duì)象的形式封裝好,那么我們自然會(huì)希望對(duì)多線程能更多復(fù)雜而細(xì)致的控制。因此出現(xiàn)了很多多線程相關(guān)的工具。比較典型的編程工具有線程池、線程安全容器、鎖這三類。線程池提供給我們以“池”的形態(tài),自動(dòng)管理線程的能力:我們不需要自己去考慮怎么建立線程、回收線程,而是給線程池一個(gè)策略,然后輸入需要執(zhí)行的任務(wù)函數(shù),線程池就會(huì)自動(dòng)操作,比如它會(huì)維持一個(gè)同時(shí)運(yùn)行線程數(shù)量,或者保持一定的空閑線程以節(jié)省創(chuàng)建、銷毀線程的消耗。在多線程操作中,不像多進(jìn)程在內(nèi)存上完全是區(qū)分開的,所以可以訪問(wèn)同一份內(nèi)存,也就是對(duì)堆里面的同一個(gè)變量進(jìn)行讀寫,這就可能產(chǎn)生程序員所預(yù)計(jì)不到的情況(因?yàn)槲覀儗懗绦蛑豢紤]代碼是順序執(zhí)行的)。還有一些對(duì)象容器,比如哈希表和隊(duì)列,如果被多個(gè)線程同時(shí)操作,可能還會(huì)因?yàn)閮?nèi)部數(shù)據(jù)對(duì)不上,造成嚴(yán)重的錯(cuò)誤,所以很多人開發(fā)了一些可以被多個(gè)線程同時(shí)操作的容器,以及所謂“原子”操作的工具,以解決這樣的問(wèn)題。有些語(yǔ)言如Java,在語(yǔ)法層面,就提供了關(guān)鍵字來(lái)對(duì)某個(gè)變量進(jìn)行“上鎖”,以保障只有一個(gè)線程能操作它。多線程的編程中,很多并行任務(wù),是有一定的阻塞順序的,所以有各種各樣的鎖被發(fā)明出來(lái),比如倒數(shù)鎖、排隊(duì)鎖等等。java.concurrent庫(kù)就是多線程工具的一個(gè)大集合,非常值得學(xué)習(xí)。然而,多線程的這些五花八門的武器,其實(shí)也是證明了多線程本身,是一種不太容易使用的順手的技術(shù),但是我們一下子還沒(méi)有更好的替代方案罷了。


多線程的對(duì)象模型

在多線程的代碼下,除了啟動(dòng)線程的地方,是和正常的執(zhí)行順序不同以外,其他的基本都還是比較近似單線程代碼的。但是如果在異步并發(fā)的代碼下,你會(huì)發(fā)現(xiàn),代碼一定要裝入一個(gè)個(gè)“回調(diào)函數(shù)”里。這些回調(diào)函數(shù),從代碼的組織形態(tài)上,幾乎完全無(wú)法看出來(lái)其預(yù)期的執(zhí)行順序,一般只能在運(yùn)行的時(shí)候通過(guò)斷點(diǎn)或者日志來(lái)分析。這就對(duì)代碼閱讀帶來(lái)了極大的障礙。因此現(xiàn)在有越來(lái)越多的程序員關(guān)注“協(xié)程”這種技術(shù):可以用類似同步的方法來(lái)寫異步程序,而無(wú)需把代碼塞到不同的回調(diào)函數(shù)里面。協(xié)程技術(shù)大的特點(diǎn),就是加入了一個(gè)叫yield的概念,這個(gè)關(guān)鍵字所在的代碼行,是一個(gè)類似return的作用,但是又代表著后續(xù)某個(gè)時(shí)刻,程序會(huì)從yield的地方繼續(xù)往下執(zhí)行。這樣就把那些需要回調(diào)的代碼,從函數(shù)中得以解放出來(lái),放到y(tǒng)ield的后面了。在很多客戶端游戲引擎中,我們寫的代碼都是由一個(gè)框架,以每秒30幀的速度在反復(fù)執(zhí)行,為了讓一些任務(wù),可以分別放在各幀中運(yùn)行,而不是一直阻塞導(dǎo)致“卡幀”,使用協(xié)程就是最自然和方便的了——Unity3D就自帶了協(xié)程的支持。

在多線程同步程序中,我們的函數(shù)調(diào)用棧就代表了一系列同屬一個(gè)線程的處理。但是在單線程的異步回調(diào)的編程模式下,我們的一個(gè)回調(diào)函數(shù)是無(wú)法簡(jiǎn)單的知道,是在處理哪一個(gè)請(qǐng)求的序列中。所以我們往往需要自己寫代碼去維持這樣的狀態(tài),最常見(jiàn)的做法是,每個(gè)并發(fā)任務(wù)啟動(dòng)的時(shí)候,就產(chǎn)生一個(gè)序列號(hào)(seqid),然后在所有的對(duì)這個(gè)并發(fā)任務(wù)處理的回調(diào)函數(shù)中,都傳入這個(gè)seqid參數(shù),這樣每個(gè)回調(diào)函數(shù),都可以通過(guò)這個(gè)參數(shù),知道自己在處理哪個(gè)任務(wù)。如果有些不同的回調(diào)函數(shù),希望交換數(shù)據(jù),比如A函數(shù)的處理結(jié)果希望B函數(shù)能得到,還可以用seqid作為key把結(jié)果存放到一個(gè)公共的哈希表容器中,這樣B函數(shù)根據(jù)傳入的seqid就能去哈希表中獲得A函數(shù)存入的結(jié)果了,這樣的一份數(shù)據(jù)我們往往叫做“會(huì)話”。如果我們使用協(xié)程,那么這些會(huì)話可能都不需要自己來(lái)維持了,因?yàn)閰f(xié)程中的棧代表了會(huì)話容器,當(dāng)執(zhí)行序列切換到某個(gè)協(xié)程中的時(shí)候,棧上的局部變量正是之前的處理過(guò)程的內(nèi)容結(jié)果。


協(xié)程的代碼特征

為了解決異步編程的回調(diào)這種復(fù)雜的操作,業(yè)界還發(fā)明了很多其他的手段,比如lamda表達(dá)式、閉包、promise模型等等,這些都是希望我們,能從代碼的表面組織上,把在多個(gè)不同時(shí)間段上運(yùn)行的代碼,以業(yè)務(wù)邏輯的形式組織到一起。

最后我想說(shuō)說(shuō)函數(shù)式編程,在多線程的模型下,并行代碼帶來(lái)大的復(fù)雜性,就是對(duì)堆內(nèi)存的同時(shí)操作。所以我們才弄出來(lái)鎖的機(jī)制,以及一大批對(duì)付死鎖的策略。而函數(shù)式編程,由于根本不使用堆內(nèi)存,所以就無(wú)需處理什么鎖,反而讓整個(gè)事情變得非常簡(jiǎn)單。唯一需要改變的,就是我們習(xí)慣于把狀態(tài)放到堆里面的編程思路。函數(shù)式編程的語(yǔ)言,比如LISP或者Erlang,其核心數(shù)據(jù)結(jié)果是鏈表——一種可以表示任何數(shù)據(jù)結(jié)構(gòu)的結(jié)構(gòu)。我們可以把所有的狀態(tài),都放到鏈表這個(gè)數(shù)據(jù)列車中,然后讓一個(gè)個(gè)函數(shù)去處理這串?dāng)?shù)據(jù),這樣同樣也可以傳遞程序的狀態(tài)。這是一種用棧來(lái)代替堆的編程思路,在多線程并發(fā)的環(huán)境下,非常的有價(jià)值。

分布式程序的編寫,一直都伴隨著大量的復(fù)雜性,影響我們對(duì)代碼的閱讀和維護(hù),所以我們才有各種各樣的技術(shù)和概念,試圖簡(jiǎn)化這種復(fù)雜性。也許我們無(wú)法找到任何一個(gè)通用的解決方案,但是我們可以通過(guò)理解各種方案的目標(biāo),來(lái)選擇最適合我們的場(chǎng)景:

l 動(dòng)態(tài)多進(jìn)程fork——同質(zhì)的并行任務(wù)

l 多線程——能明確劃的邏輯復(fù)雜的并行任務(wù)

l 異步并發(fā)回調(diào)——對(duì)性能要求高,但中間會(huì)被阻塞的處理較少的并行任務(wù)

l 協(xié)程——以同步的寫法編寫并發(fā)的任務(wù),但是不合適發(fā)起復(fù)雜的動(dòng)態(tài)并行操作。

l 函數(shù)式編程——以數(shù)據(jù)流為模型的并行處理任務(wù)

分布式數(shù)據(jù)通信

分布式的編程中,對(duì)于CPU時(shí)間片的切分本身不是難點(diǎn),最困難的地方在于并行的多個(gè)代碼片段,如何進(jìn)行通信。因?yàn)槿魏我粋€(gè)代碼段,都不可能完全單獨(dú)的運(yùn)作,都需要和其他代碼產(chǎn)生一定的依賴。在動(dòng)態(tài)多進(jìn)程中,我們往往只能通過(guò)父進(jìn)程的內(nèi)存提供共享的初始數(shù)據(jù),運(yùn)行中則只能通過(guò)操作系統(tǒng)間的通訊方式了:Socket、信號(hào)、共享內(nèi)存、管道等等。無(wú)論那種做法,這些都帶來(lái)了一堆復(fù)雜的編碼。這些方式大部分都類似于文件操作:一個(gè)進(jìn)程寫入、另外一個(gè)進(jìn)程讀出。所以很多人設(shè)計(jì)了一種叫“消息隊(duì)列”的模型,提供“放入”消息和“取出”消息的接口,底層則是可以用Socket、共享內(nèi)存、甚至是文件來(lái)實(shí)現(xiàn)。這種做法幾乎能夠處理任何狀況下的數(shù)據(jù)通訊,而且有些還能保存消息。但是缺點(diǎn)是每個(gè)通信消息,都必須經(jīng)過(guò)編碼、解碼、收包、發(fā)包這些過(guò)程,對(duì)處理延遲有一定的消耗。

如果我們?cè)诙嗑€程中進(jìn)行通信,那么我們可以直接對(duì)某個(gè)堆里面的變量直接進(jìn)行讀寫,這樣的性能是高的,使用也非常方便。但是缺點(diǎn)是可能出現(xiàn)幾個(gè)線程同時(shí)使用變量,產(chǎn)生了不可預(yù)期的結(jié)果,為了對(duì)付這個(gè)問(wèn)題,我們?cè)O(shè)計(jì)了對(duì)變量的“鎖”機(jī)制,而如何使用鎖又成為另外一個(gè)問(wèn)題,因?yàn)榭赡艹霈F(xiàn)所謂的“死鎖”問(wèn)題。所以我們一般會(huì)用一些“線程安全”的容器,用來(lái)作為多線程間通訊的方案。為了協(xié)調(diào)多個(gè)線程之間的執(zhí)行順序,還可以使用很多種類型的“工具鎖”。

在單線程異步并發(fā)的情況下,多個(gè)會(huì)話間的通信,也是可以通過(guò)直接對(duì)變量進(jìn)行讀寫操作,而且不會(huì)出現(xiàn)“鎖”的問(wèn)題,因?yàn)楸举|(zhì)上每個(gè)時(shí)刻都只有一個(gè)段代碼會(huì)操作這個(gè)變量。然而,我們還是需要對(duì)這些變量進(jìn)行一定規(guī)劃和整理,否則各種指針或全局變量在代碼中散布,也是很出現(xiàn)BUG的。所以我們一般會(huì)把“會(huì)話”的概念變成一個(gè)數(shù)據(jù)容器,每段代碼都可以把這個(gè)會(huì)話容器作為一個(gè)“收件箱”,其他的并發(fā)任務(wù)如果需要在這個(gè)任務(wù)中通訊,就把數(shù)據(jù)放入這個(gè)“收件箱”即可。在WEB開發(fā)領(lǐng)域,和cookie對(duì)應(yīng)的服務(wù)器端Session機(jī)制,就是這種概念的典型實(shí)現(xiàn)。

分布式緩存策略

在分布式程序架構(gòu)中,如果我們需要整個(gè)體系有更高的穩(wěn)定性,能夠?qū)M(jìn)程容災(zāi)或者動(dòng)態(tài)擴(kuò)容提供支持,那么最難解決的問(wèn)題,就是每個(gè)進(jìn)程中的內(nèi)存狀態(tài)。因?yàn)檫M(jìn)程一旦毀滅,內(nèi)存中的狀態(tài)會(huì)消失,這就很難不影響提供的服務(wù)。所以我們需要一種方法,讓進(jìn)程的內(nèi)存狀態(tài),不太影響整體服務(wù),甚至最好能變成“無(wú)狀態(tài)”的服務(wù)。當(dāng)然“狀態(tài)”如果不寫入磁盤,始終還是需要某些進(jìn)程來(lái)承載的。在現(xiàn)在流行的WEB開發(fā)模式中,很多人會(huì)使用PHP+Memcached+MySQL這種模型,在這里,PHP就是無(wú)狀態(tài)的,因?yàn)闋顟B(tài)都是放在Memcached里面。這種做法對(duì)于PHP來(lái)說(shuō),是可以隨時(shí)動(dòng)態(tài)的毀滅或者新建,但是Memcached進(jìn)程就要保證穩(wěn)定才行;而且Memcached作為一個(gè)額外的進(jìn)程,和它通信本身也會(huì)消耗更多的延遲時(shí)間。因此我們需要一種更靈活和通用的進(jìn)程狀態(tài)保存方案,我們把這種任務(wù)叫做“分布式緩存”的策略。我們希望進(jìn)程在讀取數(shù)據(jù)的時(shí)候,能有高的性能,最好能和在堆內(nèi)存中讀寫類似,又希望這些緩存數(shù)據(jù),能被放在多個(gè)進(jìn)程內(nèi),以分布式的形態(tài)提供高吞吐的服務(wù),其中最關(guān)鍵的問(wèn)題,就是緩存數(shù)據(jù)的同步。


PHP常用Memached做緩存

為了解決這個(gè)問(wèn)題,我們需要先一步步來(lái)分解這個(gè)問(wèn)題:

首先,我們的緩存應(yīng)該是某種特定形式的對(duì)象,而不應(yīng)該是任意類型的變量。因?yàn)槲覀冃枰獙?duì)這些緩存進(jìn)行標(biāo)準(zhǔn)化的管理,盡管C++語(yǔ)言提供了運(yùn)算重載,我們可以對(duì)“=”號(hào)的寫變量操作進(jìn)行重新定義,但是現(xiàn)在基本已經(jīng)沒(méi)有人推薦去做這樣的事。而我們手頭就有最常見(jiàn)的一種模型,適合緩存這種概念的使用,它就是——哈希表。所有的哈希表(或者是Map接口),都是把數(shù)據(jù)的存放,分為key和value兩個(gè)部分,我們可以把想要緩存的數(shù)據(jù),作為value存放到“表”當(dāng)中,同時(shí)我們也可以用key把對(duì)應(yīng)的數(shù)據(jù)取出來(lái),而“表”對(duì)象就代表了緩存。

其次我們需要讓這個(gè)“表”能在多個(gè)進(jìn)程中都存在。如果每個(gè)進(jìn)程中的數(shù)據(jù)都毫無(wú)關(guān)聯(lián),那問(wèn)題其實(shí)就非常簡(jiǎn)單,但是如果我們可能從A進(jìn)程把數(shù)據(jù)寫入緩存,然后在B進(jìn)程把數(shù)據(jù)讀取出來(lái),那么就比較復(fù)雜了。我們的“表”要有能把數(shù)據(jù)在A、B兩個(gè)進(jìn)程間同步的能力。因此我們一般會(huì)用三種策略:租約清理、租約轉(zhuǎn)發(fā)、修改廣播

l 租約清理,一般是指,我們把存放某個(gè)key的緩存的進(jìn)程,稱為持有這個(gè)key的數(shù)據(jù)的“租約”,這個(gè)租約要登記到一個(gè)所有進(jìn)程都能訪問(wèn)到的地方,比如是ZooKeeper集群進(jìn)程。那么在讀、寫發(fā)生的時(shí)候,如果本進(jìn)程沒(méi)有對(duì)應(yīng)的緩存,就先去查詢一下對(duì)應(yīng)的租約,如果被其他進(jìn)程持有,則通知對(duì)方“清理”,所謂“清理”,往往是指刪除用來(lái)讀的數(shù)據(jù),回寫用來(lái)寫的數(shù)據(jù)到數(shù)據(jù)庫(kù)等持久化設(shè)備,等清理完成后,在進(jìn)行正常的讀寫操作,這些操作可能會(huì)重新在新的進(jìn)程上建立緩存。這種策略在緩存命中率比較高的情況下,性能是最好的,因?yàn)橐话銦o(wú)需查詢租約情況,就可以直接操作;但如果緩存命中率低,那么就會(huì)出現(xiàn)緩存反復(fù)在不同進(jìn)程間“移動(dòng)”,會(huì)嚴(yán)重降低系統(tǒng)的處理性能。


l 租約轉(zhuǎn)發(fā)。同樣,我們把存放某個(gè)KEY的緩存的進(jìn)程,稱為持有這個(gè)KEY數(shù)據(jù)的“租約”,同時(shí)也要登記到集群的共享數(shù)據(jù)進(jìn)程中。和上面

分享名稱:高性能服務(wù)器架構(gòu)思路「不僅是思路」
分享路徑:http://www.muchs.cn/news47/100647.html

成都網(wǎng)站建設(shè)公司_創(chuàng)新互聯(lián),為您提供定制網(wǎng)站、定制開發(fā)、網(wǎng)站維護(hù)、外貿(mào)網(wǎng)站建設(shè)、App開發(fā)、企業(yè)建站

廣告

聲明:本網(wǎng)站發(fā)布的內(nèi)容(圖片、視頻和文字)以用戶投稿、用戶轉(zhuǎn)載內(nèi)容為主,如果涉及侵權(quán)請(qǐng)盡快告知,我們將會(huì)在第一時(shí)間刪除。文章觀點(diǎn)不代表本網(wǎng)站立場(chǎng),如需處理請(qǐng)聯(lián)系客服。電話:028-86922220;郵箱:631063699@qq.com。內(nèi)容未經(jīng)允許不得轉(zhuǎn)載,或轉(zhuǎn)載時(shí)需注明來(lái)源: 創(chuàng)新互聯(lián)

手機(jī)網(wǎng)站建設(shè)