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

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

1.緩存策略的概念和實例

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

3.分布策略的概念和實例

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

緩存

緩存策略的概念

我們提到服務(wù)器端性能問題的時候,往往會混淆不清涣易。因為當(dāng)我們訪問一個服務(wù)器時画机,出現(xiàn)服務(wù)卡住不能得到數(shù)據(jù),就會認(rèn)為是“性能問題”新症。但是實際上這個性能問題可能是有不同的原因步氏,表現(xiàn)出來都是針對客戶請求的延遲很長甚至中斷。我們來看看這些原因有哪些:第一個是所謂并發(fā)數(shù)不足徒爹,也就是同時請求的客戶過多荚醒,導(dǎo)致超過容納能力的客戶被拒絕服務(wù),這種情況往往會因為服務(wù)器內(nèi)存耗盡而導(dǎo)致的隆嗅;第二個是處理延遲過長界阁,也就是有一些客戶的請求處理時間已經(jīng)超過用戶可以忍受的長度,這種情況常常表現(xiàn)為CPU占用滿額100%胖喳。

我們在服務(wù)器開發(fā)的時候泡躯,最常用到的有下面這幾種硬件:CPU、內(nèi)存丽焊、磁盤较剃、網(wǎng)卡。其中CPU是代表計算機(jī)處理時間的技健,硬盤的空間一般很大写穴,主要是讀寫磁盤會帶來比較大的處理延遲,而內(nèi)存雌贱、網(wǎng)卡則是受存儲啊送、帶寬的容量限制的弓颈。所以當(dāng)我們的服務(wù)器出現(xiàn)性能問題的時候,就是這幾個硬件某一個甚至幾個都出現(xiàn)負(fù)荷占滿的情況删掀。這四個硬件的資源一般可以抽象成兩類:一類是時間資源,比如CPU和磁盤讀寫导街;一類是空間資源披泪,比如內(nèi)存和網(wǎng)卡帶寬。所以當(dāng)我們的服務(wù)器出現(xiàn)性能問題搬瑰,有一個最基本的思路款票,就是——時間空間轉(zhuǎn)換。我們可以舉幾個例子來說明這個問題泽论。

[水壩就是用水庫空間來換流量時間的例子]

當(dāng)我們訪問一個WEB的網(wǎng)站的時候艾少,輸入的URL地址會被服務(wù)器變成對磁盤上某個文件的讀取。如果有大量的用戶訪問這個網(wǎng)站翼悴,每次的請求都會造成對磁盤的讀操作缚够,可能會讓磁盤不堪重負(fù),導(dǎo)致無法即時讀取到文件內(nèi)容鹦赎。但是如果我們寫的程序谍椅,會把讀取過一次的文件內(nèi)容,長時間的保存在內(nèi)存中古话,當(dāng)有另外一個對同樣文件的讀取時雏吭,就直接從內(nèi)存中把數(shù)據(jù)返回給客戶端,就無需去讓磁盤讀取了陪踩。由于用戶訪問的文件往往很集中杖们,所以大量的請求可能都能從內(nèi)存中找到保存的副本,這樣就能大大提高服務(wù)器能承載的訪問量了肩狂。這種做法摘完,就是用內(nèi)存的空間,換取了磁盤的讀寫時間傻谁,屬于用空間換時間的策略描焰。

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

舉另外一個例子:我們寫一個網(wǎng)絡(luò)游戲的服務(wù)器端程序,通過讀寫數(shù)據(jù)庫來提供玩家資料存檔栅螟。如果有大量玩家進(jìn)入這個服務(wù)器荆秦,必定有很多玩家的數(shù)據(jù)資料變化,比如升級力图、獲得武器等等步绸,這些通過讀寫數(shù)據(jù)庫來實現(xiàn)的操作,可能會讓數(shù)據(jù)庫進(jìn)程負(fù)荷過重吃媒,導(dǎo)致玩家無法即時完成游戲操作瓤介。我們會發(fā)現(xiàn)游戲中的讀操作吕喘,大部分都是針是對一些靜態(tài)數(shù)據(jù)的,比如游戲中的關(guān)卡數(shù)據(jù)刑桑、武器道具的具體信息氯质;而很多寫操作,實際上是會覆蓋的祠斧,比如我的經(jīng)驗值闻察,可能每打一個怪都會增加幾十點,但是最后記錄的只是最終的一個經(jīng)驗值琢锋,而不會記錄下打怪的每個過程辕漂。所以我們也可以使用時空轉(zhuǎn)換的策略來提供性能:我們可以用內(nèi)存,把那些游戲中的靜態(tài)數(shù)據(jù)吴超,都一次性讀取并保存起來钉嘹,這樣每次讀這些數(shù)據(jù),都和數(shù)據(jù)庫無關(guān)了鲸阻;而玩家的資料數(shù)據(jù)跋涣,則不是每次變化都去寫數(shù)據(jù)庫,而是先在內(nèi)存中保持一個玩家數(shù)據(jù)的副本鸟悴,所有的寫操作都先去寫內(nèi)存中的結(jié)構(gòu)仆潮,然后定期再由服務(wù)器主動寫回到數(shù)據(jù)庫中,這樣可以把多次的寫數(shù)據(jù)庫操作變成一次寫操作遣臼,也能節(jié)省很多寫數(shù)據(jù)庫的消耗性置。這種做法也是用空間換時間的策略。

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

最后說說用時間換空間的例子:假設(shè)我們要開發(fā)一個企業(yè)通訊錄的數(shù)據(jù)存儲系統(tǒng)鹏浅,客戶要求我們能保存下通訊錄的每次新增、修改屏歹、刪除操作隐砸,也就是這個數(shù)據(jù)的所有變更歷史,以便可以讓數(shù)據(jù)回退到任何一個過去的時間點蝙眶。那么我們最簡單的做法季希,就是這個數(shù)據(jù)在任何變化的時候,都拷貝一份副本幽纷。但是這樣會非常的浪費(fèi)磁盤空間式塌,因為這個數(shù)據(jù)本身變化的部分可能只有很小一部分,但是要拷貝的副本可能很大友浸。這種情況下峰尝,我們就可以在每次數(shù)據(jù)變化的時候,都記下一條記錄收恢,內(nèi)容就是數(shù)據(jù)變化的情況:插入了一條內(nèi)容是某某的聯(lián)系方法武学、刪除了一條某某的聯(lián)系方法……祭往,這樣我們記錄的數(shù)據(jù),僅僅就是變化的部分火窒,而不需要拷貝很多份副本硼补。當(dāng)我們需要恢復(fù)到任何一個時間點的時候,只需要按這些記錄依次對數(shù)據(jù)修改一遍熏矿,直到指定的時間點的記錄即可已骇。這個恢復(fù)的時間可能會有點長,但是卻可以大大節(jié)省存儲空間曲掰。這就是用CPU的時間來換磁盤的存儲空間的策略。我們現(xiàn)在常見的MySQL InnoDB日志型數(shù)據(jù)表奈辰,以及SVN源代碼存儲栏妖,都是使用這種策略的。

另外奖恰,我們的Web服務(wù)器吊趾,在發(fā)送HTML文件內(nèi)容的時候,往往也會先用ZIP壓縮瑟啃,然后發(fā)送給瀏覽器论泛,瀏覽器收到后要先解壓,然后才能顯示蛹屿,這個也是用服務(wù)器和客戶端的CPU時間屁奏,來換取網(wǎng)絡(luò)帶寬的空間。

在我們的計算機(jī)體系中错负,緩存的思路幾乎無處不在坟瓢,比如我們的CPU里面就有1級緩存、2級緩存犹撒,他們就是為了用這些快速的存儲空間折联,換取對內(nèi)存這種相對比較慢的存儲空間的等待時間。我們的顯示卡里面也帶有大容量的緩存识颊,他們是用來存儲顯示圖形的運(yùn)算結(jié)果的诚镰。

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

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

在設(shè)計緩存機(jī)制的時候究履,我們還容易碰到另外一個風(fēng)險,就是對緩存數(shù)據(jù)的編程處理問題脸狸。如果我們要緩存的數(shù)據(jù)最仑,并不是完全無需處理直接讀寫的,而是需要讀入內(nèi)存后炊甲,以某種語言的結(jié)構(gòu)體或者對象來處理的泥彤,這就需要涉及到“序列化”和“反序列化”的問題。如果我們采用直接拷貝內(nèi)存的方式來緩存數(shù)據(jù)卿啡,當(dāng)我們的這些數(shù)據(jù)需要跨進(jìn)程吟吝、甚至跨語言訪問的時候,會出現(xiàn)那些指針颈娜、ID剑逃、句柄數(shù)據(jù)的失效。因為在另外一個進(jìn)程空間里官辽,這些“標(biāo)記型”的數(shù)據(jù)都是不存在的蛹磺。因此我們需要更深入的對數(shù)據(jù)緩存的方法,我們可能會使用所謂深拷貝的方案同仆,也就是跟著那些指針去找出目標(biāo)內(nèi)存的數(shù)據(jù)萤捆,一并拷貝。一些更現(xiàn)代的做法俗批,則是使用所謂序列化方案來解決這個問題鳖轰,也就是用一些明確定義了的“拷貝方法”來定義一個結(jié)構(gòu)體,然后用戶就能明確的知道這個數(shù)據(jù)會被拷貝扶镀,直接取消了指針之類的內(nèi)存地址數(shù)據(jù)的存在蕴侣。比如著名的Protocol Buffer就能很方便的進(jìn)行內(nèi)存、磁盤臭觉、網(wǎng)絡(luò)位置的緩存昆雀;現(xiàn)在我們常見的JSON,也被一些系統(tǒng)用來作為緩存的數(shù)據(jù)格式蝠筑。

但是我們需要注意的是狞膘,緩存的數(shù)據(jù)和我們程序真正要操作的數(shù)據(jù),往往是需要進(jìn)行一些拷貝和運(yùn)算的什乙,這就是序列化和反序列化的過程挽封,這個過程很快,也有可能很慢臣镣。所以我們在選擇數(shù)據(jù)緩存結(jié)構(gòu)的時候辅愿,必須要注意其轉(zhuǎn)換時間智亮,否則你緩存的效果可能被這些數(shù)據(jù)拷貝、轉(zhuǎn)換消耗去很多点待,嚴(yán)重的甚至比不緩存更差阔蛉。一般來說,緩存的數(shù)據(jù)越解決使用時的內(nèi)存結(jié)構(gòu)癞埠,其轉(zhuǎn)換速度就越快状原,在這點上,Protocol Buffer采用TLV編碼苗踪,就比不上直接memcpy的一個C結(jié)構(gòu)體颠区,但是比編碼成純文本的XML或者JSON要來的更快。因為編解碼的過程往往要進(jìn)行復(fù)雜的查表映射通铲,列表結(jié)構(gòu)等操作毕莱。

緩存策略的難點

雖然使用緩存思想似乎是一個很簡單的事情,但是緩存機(jī)制卻有一個核心的難點测暗,就是——緩存清理央串。我們所說的緩存磨澡,都是保存一些數(shù)據(jù)碗啄,但是這些數(shù)據(jù)往往是會變化的,我們要針對這些變化稳摄,清理掉保存的“臟”數(shù)據(jù)稚字,卻可能不是那么容易。

首先我們來看看最簡單的緩存數(shù)據(jù)——靜態(tài)數(shù)據(jù)厦酬。這種數(shù)據(jù)往往在程序的運(yùn)行時是不會變化的胆描,比如Web服務(wù)器內(nèi)存中緩存的HTML文件數(shù)據(jù),就是這種仗阅。事實上昌讲,所有的不是由外部用戶上傳的數(shù)據(jù),都屬于這種“運(yùn)行時靜態(tài)數(shù)據(jù)”减噪。一般來說短绸,我們對這種數(shù)據(jù),可以采用兩種建立緩存的方法:一是程序一啟動筹裕,就一股腦把所有的靜態(tài)數(shù)據(jù)從文件或者數(shù)據(jù)庫讀入內(nèi)存醋闭;二就是程序啟動的時候并不加載靜態(tài)數(shù)據(jù),而是等有用戶訪問相關(guān)數(shù)據(jù)的時候朝卒,才去加載证逻,這也就是所謂lazy load的做法。第一種方法編程比較簡單抗斤,程序的內(nèi)存啟動后就穩(wěn)定了囚企,不太容易出現(xiàn)內(nèi)存漏洞(如果加載的緩存太多丈咐,程序在啟動后立刻會因內(nèi)存不足而退出,比較容易發(fā)現(xiàn)問題)洞拨;第二種方法程序啟動很快扯罐,但要對緩存占用的空間有所限制或者規(guī)劃,否則如果要緩存的數(shù)據(jù)太多烦衣,可能會耗盡內(nèi)存歹河,導(dǎo)致在線服務(wù)中斷。

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

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

第二種是使用字段判斷邏輯蚀同。也就是服務(wù)器進(jìn)程缅刽,會在每次讀取緩存前,根據(jù)一些特征數(shù)據(jù)蠢络,快速的判斷內(nèi)存中的緩存和源數(shù)據(jù)內(nèi)容衰猛,是否有不一致(是否臟)的地方,如果有不一致的地方刹孔,就自動清理這條數(shù)據(jù)的緩存啡省。這種做法會消耗一部分CPU,但是就不需要人工去處理清理緩存的事情髓霞,自動化程度很高∝远茫現(xiàn)在我們的瀏覽器和WEB服務(wù)器之間,就有用這種機(jī)制:檢查文件MD5方库;或者檢查文件最后更新時間结序。具體的做法暂殖,就是每次瀏覽器發(fā)起對WEB服務(wù)器的請求時舒萎,除了發(fā)送URL給服務(wù)器外,還會發(fā)送一個緩存了此URL對應(yīng)的文件內(nèi)容的MD5校驗串、或者是此文件在服務(wù)器上的“最后更新時間”(這個校驗串和“最后更新時間”是第一次獲的文件時一并從服務(wù)器獲得的)酪穿;服務(wù)器收到之后,就會把MD5校驗串或者最后更新時間晴裹,和磁盤上的目標(biāo)文件進(jìn)行對比被济,如果是一致的,說明這個文件沒有被修改過(緩存不是“臟”的)涧团,可以直接使用緩存只磷。否則就會讀取目標(biāo)文件返回新的內(nèi)容給瀏覽器。這種做法對于服務(wù)器性能是有一定消耗的泌绣,所以如果往往我們還會搭配其他的緩存清理機(jī)制來用钮追,比如我們會在設(shè)置一個“超時檢查”的機(jī)制:就是對于所有的緩存清理檢查,我們都簡單的看看緩存存在的時間是否“超時”了阿迈,如果超過了元媚,才進(jìn)行下一步的檢查,這樣就不用每次請求都去算MD5或者看最后更新時間了。但是這樣就存在“超時”時間內(nèi)緩存變臟的可能性刊棕。

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

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

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

先舉例說說“按重要級分割”细燎,在網(wǎng)絡(luò)游戲中,同樣是角色的數(shù)據(jù)皂甘,有些數(shù)據(jù)的變化可能會每次修改都立刻回寫到數(shù)據(jù)庫(清理寫緩存)玻驻,其他一些數(shù)據(jù)的變化會延遲一段時間,甚至有些數(shù)據(jù)直到角色退出游戲才回寫偿枕,如玩家的等級變化(升級了)璧瞬,武器裝備的獲得和消耗,這些玩家非辰タ洌看重的數(shù)據(jù)嗤锉,基本上會立刻回寫,這些就是所謂最重要的緩存數(shù)據(jù)墓塌。而玩家的經(jīng)驗值變化瘟忱、當(dāng)前HP、MP的變化苫幢,就會延遲一段時間才寫访诱,因為就算丟失了緩存,玩家也不會太過關(guān)注韩肝。最后有些比如玩家在房間(地區(qū))里的X/Y坐標(biāo)触菜,對話聊天的記錄,可能會退出時回寫哀峻,甚至不回寫涡相。這個例子說的是“寫緩存”的清理,下面說說“讀緩存”的按重要級分割清理剩蟀。

假如我們寫一個網(wǎng)店系統(tǒng)催蝗,里面容納了很多產(chǎn)品,這些產(chǎn)品有一些會被用戶頻繁檢索到喻旷,比較熱銷生逸,而另外一些商品則沒那么熱銷牢屋。熱銷的商品的余額且预、銷量、評價都會比較頻繁的變化烙无,而滯銷的商品則變化很少锋谐。所以我們在設(shè)計的時候,就應(yīng)該按照不同商品的訪問頻繁程度截酷,來決定緩存哪些商品的數(shù)據(jù)涮拗。我們在設(shè)計緩存的結(jié)構(gòu)時,就應(yīng)該構(gòu)建一個可以統(tǒng)計緩存讀寫次數(shù)的指標(biāo),如果有些數(shù)據(jù)的讀寫頻率過低三热,或者空閑(沒有人讀鼓择、寫緩存)時間超長,緩存應(yīng)該主動清理掉這些數(shù)據(jù)就漾,以便其他新的數(shù)據(jù)能進(jìn)入緩存呐能。這種策略也叫做“冷熱交換”策略。實現(xiàn)“冷熱交換”的策略時抑堡,關(guān)鍵是要定義一個合理的冷熱統(tǒng)計算法摆出。一些固定的指標(biāo)和算法,往往并不能很好的應(yīng)對不同硬件首妖、不同網(wǎng)絡(luò)情況下的變化偎漫,所以現(xiàn)在人們普遍會用一些動態(tài)的算法,如Redis就采用了5種有缆,他們是:

1.根據(jù)過期時間象踊,清理最長時間沒用過的

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

3.根據(jù)過期時間棚壁,任意清理一個

4.無論是否過期通危,隨機(jī)清理

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

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

分布

分布策略的概念

任何的服務(wù)器的性能都是有極限的偎肃,面對海量的互聯(lián)網(wǎng)訪問需求煞烫,是不可能單靠一臺服務(wù)器或者一個CPU來承擔(dān)的。所以我們一般都會在運(yùn)行時架構(gòu)設(shè)計之初累颂,就考慮如何能利用多個CPU红竭、多臺服務(wù)器來分擔(dān)負(fù)載,這就是所謂分布的策略喘落。分布式的服務(wù)器概念很簡單茵宪,但是實現(xiàn)起來卻比較復(fù)雜。因為我們寫的程序瘦棋,往往都是以一個CPU稀火,一塊內(nèi)存為基礎(chǔ)來設(shè)計的,所以要讓多個程序同時運(yùn)行赌朋,并且協(xié)調(diào)運(yùn)作凰狞,這需要更多的底層工作。

首先出現(xiàn)能支持分布式概念的技術(shù)是多進(jìn)程沛慢。在DOS時代赡若,計算機(jī)在一個時間內(nèi)只能運(yùn)行一個程序,如果你想一邊寫程序团甲,同時一邊聽mp3逾冬,都是不可能的。但是躺苦,在WIN95操作系統(tǒng)下身腻,你就可以同時開多個窗口,背后就是同時在運(yùn)行多個程序匹厘。在Unix和后來的Linux操作系統(tǒng)里面嘀趟,都普遍支持了多進(jìn)程的技術(shù)。所謂的多進(jìn)程愈诚,就是操作系統(tǒng)可以同時運(yùn)行我們編寫的多個程序她按,每個程序運(yùn)行的時候,都好像自己獨(dú)占著CPU和內(nèi)存一樣炕柔。在計算機(jī)只有一個CPU的時候酌泰,實際上計算機(jī)會分時復(fù)用的運(yùn)行多個進(jìn)程,CPU在多個進(jìn)程之間切換汗唱。但是如果這個計算機(jī)有多個CPU或者多個CPU核宫莱,則會真正的有幾個進(jìn)程同時運(yùn)行。所以進(jìn)程就好像一個操作系統(tǒng)提供的運(yùn)行時“程序盒子”哩罪,可以用來在運(yùn)行時授霸,容納任何我們想運(yùn)行的程序。當(dāng)我們掌握了操作系統(tǒng)的多進(jìn)程技術(shù)后际插,我們就可以把服務(wù)器上的運(yùn)行任務(wù)碘耳,分為多個部分,然后分別寫到不同的程序里框弛,利用上多CPU或者多核辛辨,甚至是多個服務(wù)器的CPU一起來承擔(dān)負(fù)載。

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

這種劃分多個進(jìn)程的架構(gòu)瑟枫,一般會有兩種策略:一種是按功能來劃分斗搞,比如負(fù)責(zé)網(wǎng)絡(luò)處理的一個進(jìn)程,負(fù)責(zé)數(shù)據(jù)庫處理的一個進(jìn)程慷妙,負(fù)責(zé)計算某個業(yè)務(wù)邏輯的一個進(jìn)程僻焚。另外一種策略是每個進(jìn)程都是同樣的功能,只是分擔(dān)不同的運(yùn)算任務(wù)而已膝擂。使用第一種策略的系統(tǒng)虑啤,運(yùn)行的時候,直接根據(jù)操作系統(tǒng)提供的診斷工具架馋,就能直觀的監(jiān)測到每個功能模塊的性能消耗狞山,因為操作系統(tǒng)提供進(jìn)程盒子的同時,也能提供對進(jìn)程的全方位的監(jiān)測叉寂,比如CPU占用萍启、內(nèi)存消耗、磁盤和網(wǎng)絡(luò)I/O等等屏鳍。但是這種策略的運(yùn)維部署會稍微復(fù)雜一點伊约,因為任何一個進(jìn)程沒有啟動,或者和其他進(jìn)程的通信地址沒配置好孕蝉,都可能導(dǎo)致整個系統(tǒng)無法運(yùn)作屡律;而第二種分布策略,由于每個進(jìn)程都是一樣的降淮,這樣的安裝部署就非常簡單超埋,性能不夠就多找?guī)讉€機(jī)器,多啟動幾個進(jìn)程就完成了佳鳖,這就是所謂的平行擴(kuò)展霍殴。

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

[動態(tài)進(jìn)程集群]

在調(diào)用多進(jìn)程服務(wù)的策略上,我們也會有一定的策略選擇苗傅,其中最著名的策略有三個:一個是動態(tài)負(fù)載均衡策略抒线;一個是讀寫分離策略;一個是一致性哈希策略渣慕。動態(tài)負(fù)載均衡策略嘶炭,一般會搜集多個進(jìn)程的服務(wù)狀態(tài),然后挑選一個負(fù)載最輕的進(jìn)程來分發(fā)服務(wù)逊桦,這種策略對于比較同質(zhì)化的進(jìn)程是比較合適的眨猎。讀寫分離策略則是關(guān)注對持久化數(shù)據(jù)的性能,比如對數(shù)據(jù)庫的操作强经,我們會提供一批進(jìn)程專門用于提供讀數(shù)據(jù)的服務(wù)睡陪,而另外一個(或多個)進(jìn)程用于寫數(shù)據(jù)的服務(wù),這些寫數(shù)據(jù)的進(jìn)程都會每次寫多份拷貝到“讀服務(wù)進(jìn)程”的數(shù)據(jù)區(qū)(可能就是單獨(dú)的數(shù)據(jù)庫),這樣在對外提供服務(wù)的時候兰迫,就可以提供更多的硬件資源信殊。一致性哈希策略是針對任何一個任務(wù),看看這個任務(wù)所涉及讀寫的數(shù)據(jù)逮矛,是屬于哪一片的鸡号,是否有某種可以緩存的特征转砖,然后按這個數(shù)據(jù)的ID或者特征值须鼎,進(jìn)行“一致性哈希”的計算府蔗,分擔(dān)給對應(yīng)的處理進(jìn)程晋控。這種進(jìn)程調(diào)用策略,能非常的利用上進(jìn)程內(nèi)的緩存(如果存在)姓赤,比如我們的一個在線游戲赡译,由100個進(jìn)程承擔(dān)服務(wù),那么我們就可以把游戲玩家的ID不铆,作為一致性哈希的數(shù)據(jù)ID蝌焚,作為進(jìn)程調(diào)用的KEY,如果目標(biāo)服務(wù)進(jìn)程有緩存游戲玩家的數(shù)據(jù)誓斥,那么所有這個玩家的操作請求只洒,都會被轉(zhuǎn)到這個目標(biāo)服務(wù)進(jìn)程上,緩存的命中率大大提高劳坑。而使用“一致性哈媳锨矗”,而不是其他哈希算法距芬,或者取模算法涝开,主要是考慮到,如果服務(wù)進(jìn)程有一部分因故障消失框仔,剩下的服務(wù)進(jìn)程的緩存依然可以有效舀武,而不會整個集群所有進(jìn)程的緩存都失效。具體有興趣的讀者可以搜索“一致性哈侠胝叮”一探究竟银舱。

以多進(jìn)程利用大量的服務(wù)器,以及服務(wù)器上的多個CPU核心捐腿,是一個非常有效的手段纵朋。但是使用多進(jìn)程帶來的額外的編程復(fù)雜度的問題。一般來說我們認(rèn)為最好是每個CPU核心一個進(jìn)程茄袖,這樣能最好的利用硬件操软。如果同時運(yùn)行的進(jìn)程過多,操作系統(tǒng)會消耗很多CPU時間在不同進(jìn)程的切換過程上宪祥。但是聂薪,我們早期所獲得的很多API都是阻塞的家乘,比如文件I/O,網(wǎng)絡(luò)讀寫藏澳,數(shù)據(jù)庫操作等仁锯。如果我們只用有限的進(jìn)程來執(zhí)行帶這些阻塞操作的程序,那么CPU會大量被浪費(fèi)翔悠,因為阻塞的API會讓有限的這些進(jìn)程停著等待結(jié)果业崖。那么,如果我們希望能處理更多的任務(wù)蓄愁,就必須要啟動更多的進(jìn)程双炕,以便充分利用那些阻塞的時間,但是由于進(jìn)程是操作系統(tǒng)提供的“盒子”撮抓,這個盒子比較大妇斤,切換耗費(fèi)的時間也比較多,所以大量并行的進(jìn)程反而會無謂的消耗服務(wù)器資源丹拯。加上進(jìn)程之間的內(nèi)存一般是隔離的站超,進(jìn)程間如果要交換一些數(shù)據(jù),往往需要使用一些操作系統(tǒng)提供的工具乖酬,比如網(wǎng)絡(luò)socket死相,這些都會額外消耗服務(wù)器性能。因此剑刑,我們需要一種切換代價更少媳纬,通信方式更便捷,編程方法更簡單的并行技術(shù)施掏,這個時候钮惠,多線程技術(shù)出現(xiàn)了。

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

多線程的特點是切換代價少七芭,可以同時訪問內(nèi)存素挽。我們可以在編程的時候,任意讓某個函數(shù)放入新的線程去執(zhí)行狸驳,這個函數(shù)的參數(shù)可以是任何的變量或指針预明。如果我們希望和這些運(yùn)行時的線程通信,只要讀耙箍、寫這些指針指向的變量即可撰糠。在需要大量阻塞操作的時候,我們可以啟動大量的線程辩昆,這樣就能較好的利用CPU的空閑時間阅酪;線程的切換代價比進(jìn)程低得多,所以我們能利用的CPU也會多很多。線程是一個比進(jìn)程更小的“程序盒子”术辐,他可以放入某一個函數(shù)調(diào)用砚尽,而不是一個完整的程序。一般來說辉词,如果多個線程只是在一個進(jìn)程里面運(yùn)行必孤,那其實是沒有利用到多核CPU的并行好處的,僅僅是利用了單個空閑的CPU核心瑞躺。但是敷搪,在JAVA和C#這類帶虛擬機(jī)的語言中,多線程的實現(xiàn)底層隘蝎,會根據(jù)具體的操作系統(tǒng)的任務(wù)調(diào)度單位(比如進(jìn)程)购啄,盡量讓線程也成為操作系統(tǒng)可以調(diào)度的單位襟企,從而利用上多個CPU核心嘱么。比如Linux2.6之后,提供了NPTL的內(nèi)核線程模型顽悼,JVM就提供了JAVA線程到NPTL內(nèi)核線程的映射曼振,從而利用上多核CPU。而Windows系統(tǒng)中蔚龙,據(jù)說本身線程就是系統(tǒng)的最小調(diào)度單位冰评,所以多線程也是利用上多核CPU的。所以我們在使用JAVA\C#編程的時候木羹,多線程往往已經(jīng)同時具備了多進(jìn)程利用多核CPU甲雅、以及切換開銷低的兩個好處。

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

當(dāng)我們在廣泛使用多線程的時候续誉,我們發(fā)現(xiàn),盡管多線程有很多優(yōu)點初肉,但是依然會有明顯的兩個缺點:一個內(nèi)存占用比較大且不太可控酷鸦;第二個是多個線程對于用一個數(shù)據(jù)使用時,需要考慮復(fù)雜的“鎖”問題。由于多線程是基于對一個函數(shù)調(diào)用的并行運(yùn)行井佑,這個函數(shù)里面可能會調(diào)用很多個子函數(shù)属铁,每調(diào)用一層子函數(shù),就會要在棧上占用新的內(nèi)存躬翁,大量線程同時在運(yùn)行的時候焦蘑,就會同時存在大量的棧,這些棧加在一起盒发,可能會形成很大的內(nèi)存占用例嘱。并且,我們編寫服務(wù)器端程序宁舰,往往希望資源占用盡量可控拼卵,而不是動態(tài)變化太大,因為你不知道什么時候會因為內(nèi)存用完而當(dāng)機(jī)蛮艰,在多線程的程序中腋腮,由于程序運(yùn)行的內(nèi)容導(dǎo)致棧的伸縮幅度可能很大胰苏,有可能超出我們預(yù)期的內(nèi)存占用郑气,導(dǎo)致服務(wù)的故障客税。而對于內(nèi)存的“鎖”問題兜粘,一直是多線程中復(fù)雜的課題卵蛉,很多多線程工具庫父丰,都推出了大量的“無鎖”容器斧账,或者“線程安全”的容器玻靡,并且還大量設(shè)計了很多協(xié)調(diào)線程運(yùn)作的類庫著蟹。但是這些復(fù)雜的工具墩蔓,無疑都是證明了多線程對于內(nèi)存使用上的問題。

[同時排多條隊就是并行]

由于多線程還是有一定的缺點萧豆,所以很多程序員想到了一個釜底抽薪的方法:使用多線程往往是因為阻塞式API的存在奸披,比如一個read()操作會一直停止當(dāng)前線程,那么我們能不能讓這些操作變成不阻塞呢炕横?——selector/epoll就是Linux退出的非阻塞式API源内。如果我們使用了非阻塞的操作函數(shù),那么我們也無需用多線程來并發(fā)的等待阻塞結(jié)果份殿。我們只需要用一個線程膜钓,循環(huán)的檢查操作的狀態(tài),如果有結(jié)果就處理卿嘲,無結(jié)果就繼續(xù)循環(huán)颂斜。這種程序的結(jié)果往往會有一個大的死循環(huán),稱為主循環(huán)拾枣。在主循環(huán)體內(nèi)沃疮,程序員可以安排每個操作事件盒让、每個邏輯狀態(tài)的處理邏輯。這樣CPU既無需在多線程間切換司蔬,也無需處理復(fù)雜的并行數(shù)據(jù)鎖的問題——因為只有一個線程在運(yùn)行邑茄。這種就是被稱為“并發(fā)”的方案。

[服務(wù)員兼了點菜俊啼、上菜就是并發(fā)]

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

多線程和異步的例子,最著名就是Web服務(wù)器領(lǐng)域的Apache和Nginx的模型检眯。Apache是多進(jìn)程/多線程模型的厘擂,它會在啟動的時候啟動一批進(jìn)程,作為進(jìn)程池锰瘸,當(dāng)用戶請求到來的時候刽严,從進(jìn)程池中分配處理進(jìn)程給具體的用戶請求,這樣可以節(jié)省多進(jìn)程/線程的創(chuàng)建和銷毀開銷避凝,但是如果同時有大量的請求過來舞萄,還是需要消耗比較高的進(jìn)程/線程切換。而Nginx則是采用epoll技術(shù)管削,這種非阻塞的做法倒脓,可以讓一個進(jìn)程同時處理大量的并發(fā)請求,而無需反復(fù)切換含思。對于大量的用戶訪問場景下崎弃,apache會存在大量的進(jìn)程甘晤,而nginx則可以僅用有限的進(jìn)程(比如按CPU核心數(shù)來啟動),這樣就會比apache節(jié)省了不少“進(jìn)程切換”的消耗饲做,所以其并發(fā)性能會更好线婚。

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

[Apache的多態(tài)多進(jìn)程盆均,一個進(jìn)程處理一個客戶]

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

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

以前我們的代碼奏候,從上往下執(zhí)行,每一行都會占用一定的CPU時間唇敞,這些代碼的直接順序蔗草,也是和編寫的順序基本一致,任何一行代碼疆柔,都是唯一時刻的執(zhí)行任務(wù)咒精。當(dāng)我們在編寫分布式程序的時候,我們的代碼將不再好像那些單進(jìn)程旷档、單線程的程序一樣簡單模叙。我們要把同時運(yùn)行的不同代碼,在同一段代碼中編寫鞋屈。就好像我們要把整個交響樂團(tuán)的每個樂器的樂譜范咨,全部寫到一張紙上。為了解決這種編程的復(fù)雜度厂庇,業(yè)界發(fā)展出了多種編碼形式渠啊。

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

[動態(tài)多進(jìn)程的代碼模式]

在我們使用多線程的API時塘装,情況就會好很多急迂,我們可以用一個函數(shù)指針,或者一個帶回調(diào)方法的對象蹦肴,作為線程執(zhí)行的主體僚碎,并且以句柄或者對象的形式來控制這些線程。作為開發(fā)人員阴幌,我們只要掌握了對線程的啟動勺阐、停止等有限的幾個API,就能很好的對并行的多線程進(jìn)行控制矛双。這對比多進(jìn)程的fork()來說渊抽,從代碼上看會更直觀,只是我們必須要分清楚調(diào)用一個函數(shù)议忽,和新建一個線程去調(diào)用一個函數(shù)懒闷,之間的差別:新建線程去調(diào)用函數(shù),這個操作會很快的結(jié)束徙瓶,并不會依序去執(zhí)行那個函數(shù)毛雇,而是代表著,那個函數(shù)中的代碼侦镇,可能和線程調(diào)用之后的代碼,交替的執(zhí)行织阅。

由于多線程把“并行的任務(wù)”作為一個明確的編程概念定義了出來壳繁,以句柄、對象的形式封裝好荔棉,那么我們自然會希望對多線程能更多復(fù)雜而細(xì)致的控制闹炉。因此出現(xiàn)了很多多線程相關(guān)的工具。比較典型的編程工具有線程池润樱、線程安全容器渣触、鎖這三類。線程池提供給我們以“池”的形態(tài)壹若,自動管理線程的能力:我們不需要自己去考慮怎么建立線程嗅钻、回收線程皂冰,而是給線程池一個策略,然后輸入需要執(zhí)行的任務(wù)函數(shù)养篓,線程池就會自動操作秃流,比如它會維持一個同時運(yùn)行線程數(shù)量,或者保持一定的空閑線程以節(jié)省創(chuàng)建柳弄、銷毀線程的消耗舶胀。在多線程操作中,不像多進(jìn)程在內(nèi)存上完全是區(qū)分開的碧注,所以可以訪問同一份內(nèi)存嚣伐,也就是對堆里面的同一個變量進(jìn)行讀寫,這就可能產(chǎn)生程序員所預(yù)計不到的情況(因為我們寫程序只考慮代碼是順序執(zhí)行的)萍丐。還有一些對象容器轩端,比如哈希表和隊列,如果被多個線程同時操作碉纺,可能還會因為內(nèi)部數(shù)據(jù)對不上船万,造成嚴(yán)重的錯誤,所以很多人開發(fā)了一些可以被多個線程同時操作的容器骨田,以及所謂“原子”操作的工具耿导,以解決這樣的問題。有些語言如Java态贤,在語法層面舱呻,就提供了關(guān)鍵字來對某個變量進(jìn)行“上鎖”,以保障只有一個線程能操作它悠汽。多線程的編程中箱吕,很多并行任務(wù),是有一定的阻塞順序的柿冲,所以有各種各樣的鎖被發(fā)明出來茬高,比如倒數(shù)鎖、排隊鎖等等假抄。java.concurrent庫就是多線程工具的一個大集合怎栽,非常值得學(xué)習(xí)。然而宿饱,多線程的這些五花八門的武器熏瞄,其實也是證明了多線程本身,是一種不太容易使用的順手的技術(shù)谬以,但是我們一下子還沒有更好的替代方案罷了强饮。

[多線程的對象模型]

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

在多線程同步程序中抑片,我們的函數(shù)調(diào)用棧就代表了一系列同屬一個線程的處理。但是在單線程的異步回調(diào)的編程模式下杨赤,我們的一個回調(diào)函數(shù)是無法簡單的知道蓝丙,是在處理哪一個請求的序列中。所以我們往往需要自己寫代碼去維持這樣的狀態(tài)望拖,最常見的做法是,每個并發(fā)任務(wù)啟動的時候挫鸽,就產(chǎn)生一個序列號(seqid)说敏,然后在所有的對這個并發(fā)任務(wù)處理的回調(diào)函數(shù)中,都傳入這個seqid參數(shù)丢郊,這樣每個回調(diào)函數(shù)盔沫,都可以通過這個參數(shù)医咨,知道自己在處理哪個任務(wù)。如果有些不同的回調(diào)函數(shù)架诞,希望交換數(shù)據(jù)拟淮,比如A函數(shù)的處理結(jié)果希望B函數(shù)能得到,還可以用seqid作為key把結(jié)果存放到一個公共的哈希表容器中谴忧,這樣B函數(shù)根據(jù)傳入的seqid就能去哈希表中獲得A函數(shù)存入的結(jié)果了很泊,這樣的一份數(shù)據(jù)我們往往叫做“會話”。如果我們使用協(xié)程沾谓,那么這些會話可能都不需要自己來維持了委造,因為協(xié)程中的棧代表了會話容器,當(dāng)執(zhí)行序列切換到某個協(xié)程中的時候均驶,棧上的局部變量正是之前的處理過程的內(nèi)容結(jié)果昏兆。

[協(xié)程的代碼特征]

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

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

分布式程序的編寫,一直都伴隨著大量的復(fù)雜性秩彤,影響我們對代碼的閱讀和維護(hù)叔扼,所以我們才有各種各樣的技術(shù)和概念,試圖簡化這種復(fù)雜性呐舔。也許我們無法找到任何一個通用的解決方案币励,但是我們可以通過理解各種方案的目標(biāo),來選擇最適合我們的場景:

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

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

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

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

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

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

分布式的編程中澎现,對于CPU時間片的切分本身不是難點仅胞,最困難的地方在于并行的多個代碼片段,如何進(jìn)行通信剑辫。因為任何一個代碼段干旧,都不可能完全單獨(dú)的運(yùn)作,都需要和其他代碼產(chǎn)生一定的依賴妹蔽。在動態(tài)多進(jìn)程中椎眯,我們往往只能通過父進(jìn)程的內(nèi)存提供共享的初始數(shù)據(jù),運(yùn)行中則只能通過操作系統(tǒng)間的通訊方式了:Socket胳岂、信號编整、共享內(nèi)存、管道等等乳丰。無論那種做法掌测,這些都帶來了一堆復(fù)雜的編碼。這些方式大部分都類似于文件操作:一個進(jìn)程寫入产园、另外一個進(jìn)程讀出汞斧。所以很多人設(shè)計了一種叫“消息隊列”的模型,提供“放入”消息和“取出”消息的接口什燕,底層則是可以用Socket粘勒、共享內(nèi)存、甚至是文件來實現(xiàn)屎即。這種做法幾乎能夠處理任何狀況下的數(shù)據(jù)通訊仲义,而且有些還能保存消息。但是缺點是每個通信消息,都必須經(jīng)過編碼埃撵、解碼、收包虽另、發(fā)包這些過程暂刘,對處理延遲有一定的消耗。

如果我們在多線程中進(jìn)行通信捂刺,那么我們可以直接對某個堆里面的變量直接進(jìn)行讀寫谣拣,這樣的性能是最高的,使用也非常方便族展。但是缺點是可能出現(xiàn)幾個線程同時使用變量森缠,產(chǎn)生了不可預(yù)期的結(jié)果,為了對付這個問題仪缸,我們設(shè)計了對變量的“鎖”機(jī)制贵涵,而如何使用鎖又成為另外一個問題,因為可能出現(xiàn)所謂的“死鎖”問題恰画。所以我們一般會用一些“線程安全”的容器宾茂,用來作為多線程間通訊的方案。為了協(xié)調(diào)多個線程之間的執(zhí)行順序拴还,還可以使用很多種類型的“工具鎖”跨晴。

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

分布式緩存策略

在分布式程序架構(gòu)中果复,如果我們需要整個體系有更高的穩(wěn)定性陈莽,能夠?qū)M(jìn)程容災(zāi)或者動態(tài)擴(kuò)容提供支持,那么最難解決的問題,就是每個進(jìn)程中的內(nèi)存狀態(tài)走搁。因為進(jìn)程一旦毀滅独柑,內(nèi)存中的狀態(tài)會消失,這就很難不影響提供的服務(wù)私植。所以我們需要一種方法忌栅,讓進(jìn)程的內(nèi)存狀態(tài),不太影響整體服務(wù)曲稼,甚至最好能變成“無狀態(tài)”的服務(wù)索绪。當(dāng)然“狀態(tài)”如果不寫入磁盤,始終還是需要某些進(jìn)程來承載的贫悄。在現(xiàn)在流行的WEB開發(fā)模式中瑞驱,很多人會使用PHP+Memcached+MySQL這種模型,在這里窄坦,PHP就是無狀態(tài)的唤反,因為狀態(tài)都是放在Memcached里面。這種做法對于PHP來說嫡丙,是可以隨時動態(tài)的毀滅或者新建拴袭,但是Memcached進(jìn)程就要保證穩(wěn)定才行;而且Memcached作為一個額外的進(jìn)程曙博,和它通信本身也會消耗更多的延遲時間拥刻。因此我們需要一種更靈活和通用的進(jìn)程狀態(tài)保存方案,我們把這種任務(wù)叫做“分布式緩存”的策略父泳。我們希望進(jìn)程在讀取數(shù)據(jù)的時候般哼,能有最高的性能,最好能和在堆內(nèi)存中讀寫類似惠窄,又希望這些緩存數(shù)據(jù)蒸眠,能被放在多個進(jìn)程內(nèi),以分布式的形態(tài)提供高吞吐的服務(wù)杆融,其中最關(guān)鍵的問題楞卡,就是緩存數(shù)據(jù)的同步。

[PHP常用Memached做緩存]

為了解決這個問題脾歇,我們需要先一步步來分解這個問題:

首先蒋腮,我們的緩存應(yīng)該是某種特定形式的對象,而不應(yīng)該是任意類型的變量藕各。因為我們需要對這些緩存進(jìn)行標(biāo)準(zhǔn)化的管理池摧,盡管C++語言提供了運(yùn)算重載,我們可以對“=”號的寫變量操作進(jìn)行重新定義激况,但是現(xiàn)在基本已經(jīng)沒有人推薦去做這樣的事作彤。而我們手頭就有最常見的一種模型膘魄,適合緩存這種概念的使用,它就是——哈希表竭讳。所有的哈希表(或者是Map接口)创葡,都是把數(shù)據(jù)的存放,分為key和value兩個部分代咸,我們可以把想要緩存的數(shù)據(jù)蹈丸,作為value存放到“表”當(dāng)中,同時我們也可以用key把對應(yīng)的數(shù)據(jù)取出來呐芥,而“表”對象就代表了緩存。

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

l租約清理诞帐,一般是指,我們把存放某個key的緩存的進(jìn)程爆雹,稱為持有這個key的數(shù)據(jù)的“租約”停蕉,這個租約要登記到一個所有進(jìn)程都能訪問到的地方,比如是ZooKeeper集群進(jìn)程钙态。那么在讀慧起、寫發(fā)生的時候,如果本進(jìn)程沒有對應(yīng)的緩存册倒,就先去查詢一下對應(yīng)的租約蚓挤,如果被其他進(jìn)程持有,則通知對方“清理”驻子,所謂“清理”灿意,往往是指刪除用來讀的數(shù)據(jù),回寫用來寫的數(shù)據(jù)到數(shù)據(jù)庫等持久化設(shè)備拴孤,等清理完成后脾歧,在進(jìn)行正常的讀寫操作,這些操作可能會重新在新的進(jìn)程上建立緩存演熟。這種策略在緩存命中率比較高的情況下鞭执,性能是最好的司顿,因為一般無需查詢租約情況,就可以直接操作兄纺;但如果緩存命中率低大溜,那么就會出現(xiàn)緩存反復(fù)在不同進(jìn)程間“移動”,會嚴(yán)重降低系統(tǒng)的處理性能估脆。

l租約轉(zhuǎn)發(fā)钦奋。同樣,我們把存放某個KEY的緩存的進(jìn)程疙赠,稱為持有這個KEY數(shù)據(jù)的“租約”付材,同時也要登記到集群的共享數(shù)據(jù)進(jìn)程中。和上面租約清理不同的地方在于圃阳,如果發(fā)現(xiàn)持有租約的進(jìn)程不是本次操作的進(jìn)程厌衔,就會把整個數(shù)據(jù)的讀、寫請求捍岳,都通過網(wǎng)絡(luò)“轉(zhuǎn)發(fā)”個持有租約的進(jìn)程富寿,然后等待他的操作結(jié)果返回。這種做法由于每次操作都需要查詢租約锣夹,所以性能會稍微低一些页徐;但如果緩存命中率不高,這種做法能把緩存的操作分擔(dān)到多個進(jìn)程上银萍,而且也無需清理緩存变勇,這比租約清理的策略適應(yīng)性更好。

l修改廣播砖顷。上面兩種策略贰锁,都需要維護(hù)一份緩存數(shù)據(jù)的租約,但是本身對于租約的操作滤蝠,就是一種比較耗費(fèi)性能的事情豌熄。所以有時候可以采用一些更簡單,但可能承受一些不一致性的策略:對于讀操作物咳,每個節(jié)點的讀都建立緩存锣险,每次讀都判斷是否超過預(yù)設(shè)的讀冷卻時間x,超過則清理緩存從持久化重建览闰;對于寫操作芯肤,么個節(jié)點上都判斷是否超過預(yù)設(shè)的寫冷卻時間y,超過則展開清理操作压鉴。清理操作也分兩種崖咨,如果數(shù)據(jù)量小就廣播修改數(shù)據(jù);如果數(shù)據(jù)量大就廣播清理通知回寫到持久化中油吭。這樣雖然可能會有一定的不一致風(fēng)險击蹲,但是如果數(shù)據(jù)不是那種要求太高的署拟,而且緩存命中率又能比較有保障的話(比如根據(jù)KEY來進(jìn)行一致性哈希訪問緩存進(jìn)程),那么真正因為寫操作廣播不及時歌豺,導(dǎo)致數(shù)據(jù)不一致的情況還是會比較少的推穷。這種策略實現(xiàn)起來非常簡單,無需一個中心節(jié)點進(jìn)程維護(hù)數(shù)據(jù)租約类咧,也無需復(fù)雜的判斷邏輯進(jìn)行同步馒铃,只要有廣播的能力,加上對于寫操作的一些配置,就能實現(xiàn)高效的緩存服務(wù)。所以“修改廣播”策略是在大多數(shù)需要實時同步救欧,但數(shù)據(jù)一致性要求不高的領(lǐng)域最常見的手段。著名的DNS系統(tǒng)的緩存就是接近這種策略:我們要修改某個域名對應(yīng)的IP萧锉,并不是立刻在全球所有的DNS服務(wù)器上生效,而是需要一定時間廣播修改給其他服務(wù)區(qū)述寡。而我們每個DSN服務(wù)器,都具備了大量的其他域名的緩存數(shù)據(jù)叶洞。

總結(jié)

在高性能的服務(wù)器架構(gòu)中鲫凶,常用的緩存和分布兩種策略,往往是結(jié)合到一起使用的衩辟。雖然這兩種策略螟炫,都有無數(shù)種不同的表現(xiàn)形式,成為各種各樣的技術(shù)流派艺晴,但是只有清楚的理解這些技術(shù)的原理昼钻,并且和實際的業(yè)務(wù)場景結(jié)合起來,才能真正的做出滿足應(yīng)用要求的高性能架構(gòu)封寞。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末然评,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子狈究,更是在濱河造成了極大的恐慌碗淌,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,858評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件抖锥,死亡現(xiàn)場離奇詭異亿眠,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)磅废,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,372評論 3 395
  • 文/潘曉璐 我一進(jìn)店門纳像,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人拯勉,你說我怎么就攤上這事竟趾°竟海” “怎么了?”我有些...
    開封第一講書人閱讀 165,282評論 0 356
  • 文/不壞的土叔 我叫張陵潭兽,是天一觀的道長倦始。 經(jīng)常有香客問我,道長山卦,這世上最難降的妖魔是什么鞋邑? 我笑而不...
    開封第一講書人閱讀 58,842評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮账蓉,結(jié)果婚禮上枚碗,老公的妹妹穿的比我還像新娘。我一直安慰自己铸本,他們只是感情好肮雨,可當(dāng)我...
    茶點故事閱讀 67,857評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著箱玷,像睡著了一般怨规。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上锡足,一...
    開封第一講書人閱讀 51,679評論 1 305
  • 那天波丰,我揣著相機(jī)與錄音,去河邊找鬼舶得。 笑死掰烟,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的沐批。 我是一名探鬼主播纫骑,決...
    沈念sama閱讀 40,406評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼九孩!你這毒婦竟也來了先馆?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,311評論 0 276
  • 序言:老撾萬榮一對情侶失蹤捻撑,失蹤者是張志新(化名)和其女友劉穎磨隘,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體顾患,經(jīng)...
    沈念sama閱讀 45,767評論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡番捂,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,945評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了江解。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片设预。...
    茶點故事閱讀 40,090評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖犁河,靈堂內(nèi)的尸體忽然破棺而出鳖枕,到底是詐尸還是另有隱情魄梯,我是刑警寧澤,帶...
    沈念sama閱讀 35,785評論 5 346
  • 正文 年R本政府宣布宾符,位于F島的核電站酿秸,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏魏烫。R本人自食惡果不足惜辣苏,卻給世界環(huán)境...
    茶點故事閱讀 41,420評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望哄褒。 院中可真熱鬧稀蟋,春花似錦、人聲如沸呐赡。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,988評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽链嘀。三九已至萌狂,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間怀泊,已是汗流浹背粥脚。 一陣腳步聲響...
    開封第一講書人閱讀 33,101評論 1 271
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留包个,地道東北人。 一個月前我還...
    沈念sama閱讀 48,298評論 3 372
  • 正文 我出身青樓冤留,卻偏偏與公主長得像碧囊,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子纤怒,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,033評論 2 355

推薦閱讀更多精彩內(nèi)容