來自騰訊的高效能伺服器架構思路
在伺服器端程式開發領域,效能問題一直是備受關注的重點。業界有大量的框架、元件、類庫都是以效能為賣點而廣為人知。然而,伺服器端程式在效能問題上應該有何種基本思路,這個卻很少被這些專案的文件提及。本文正式希望介紹伺服器端解決效能問題的基本策略和經典實踐,並分為幾個部分來說明:
1. 快取策略的概念和例項
2.快取策略的難點:不同特點的快取資料的清理機制
3.分佈策略的概念和例項
4.分佈策略的難點:共享資料安全性與程式碼複雜度的平衡
快取
快取策略的概念
我們提到伺服器端效能問題的時候,往往會混淆不清。因為當我們訪問一個伺服器時,出現服務卡住不能得到資料,就會認為是“效能問題”。但是實際上這個效能問題可能是有不同的原因,表現出來都是針對客戶請求的延遲很長甚至中斷。我們來看看這些原因有哪些:第一個是所謂併發數不足,也就是同時請求的客戶過多,導致超過容納能力的客戶被拒絕服務,這種情況往往會因為伺服器記憶體耗盡而導致的;第二個是處理延遲過長,也就是有一些客戶的請求處理時間已經超過使用者可以忍受的長度,這種情況常常表現為CPU佔用滿額100%。
我們在伺服器開發的時候,最常用到的有下面這幾種硬體:CPU、記憶體、磁碟、網絡卡。其中CPU是代表計算機處理時間的,硬碟的空間一般很大,主要是讀寫磁碟會帶來比較大的處理延遲,而記憶體、網絡卡則是受儲存、頻寬的容量限制的。所以當我們的伺服器出現效能問題的時候,就是這幾個硬體某一個甚至幾個都出現負荷佔滿的情況。這四個硬體的資源一般可以抽象成兩類:一類是時間資源,比如CPU和磁碟讀寫;一類是空間資源,比如記憶體和網絡卡頻寬。所以當我們的伺服器出現效能問題,有一個最基本的思路,就是——時間空間轉換。我們可以舉幾個例子來說明這個問題。
水壩就是用水庫空間來換流量時間的例子
當我們訪問一個WEB的網站的時候,輸入的URL地址會被伺服器變成對磁碟上某個檔案的讀取。如果有大量的使用者訪問這個網站,每次的請求都會造成對磁碟的讀操作,可能會讓磁碟不堪重負,導致無法即時讀取到檔案內容。但是如果我們寫的程式,會把讀取過一次的檔案內容,長時間的儲存在記憶體中,當有另外一個對同樣檔案的讀取時,就直接從記憶體中把資料返回給客戶端,就無需去讓磁碟讀取了。由於使用者訪問的檔案往往很集中,所以大量的請求可能都能從記憶體中找到儲存的副本,這樣就能大大提高伺服器能承載的訪問量了。這種做法,就是用記憶體的空間,換取了磁碟的讀寫時間,屬於用空間換時間的策略。
方便麵預先快取了大量的烹飪操作
舉另外一個例子:我們寫一個網路遊戲的伺服器端程式,通過讀寫資料庫來提供玩家資料存檔。如果有大量玩家進入這個伺服器,必定有很多玩家的資料資料變化,比如升級、獲得武器等等,這些通過讀寫資料庫來實現的操作,可能會讓資料庫程序負荷過重,導致玩家無法即時完成遊戲操作。我們會發現遊戲中的讀操作,大部分都是針是對一些靜態資料的,比如遊戲中的關卡資料、武器道具的具體資訊;而很多寫操作,實際上是會覆蓋的,比如我的經驗值,可能每打一個怪都會增加幾十點,但是最後記錄的只是最終的一個經驗值,而不會記錄下打怪的每個過程。所以我們也可以使用時空轉換的策略來提供效能:我們可以用記憶體,把那些遊戲中的靜態資料,都一次性讀取並儲存起來,這樣每次讀這些資料,都和資料庫無關了;而玩家的資料資料,則不是每次變化都去寫資料庫,而是先在記憶體中保持一個玩家資料的副本,所有的寫操作都先去寫記憶體中的結構,然後定期再由伺服器主動寫回到資料庫中,這樣可以把多次的寫資料庫操作變成一次寫操作,也能節省很多寫資料庫的消耗。這種做法也是用空間換時間的策略。
拼裝傢俱很省運輸空間,但是安裝很費時
最後說說用時間換空間的例子:假設我們要開發一個企業通訊錄的資料儲存系統,客戶要求我們能儲存下通訊錄的每次新增、修改、刪除操作,也就是這個資料的所有變更歷史,以便可以讓資料回退到任何一個過去的時間點。那麼我們最簡單的做法,就是這個資料在任何變化的時候,都拷貝一份副本。但是這樣會非常的浪費磁碟空間,因為這個資料本身變化的部分可能只有很小一部分,但是要拷貝的副本可能很大。這種情況下,我們就可以在每次資料變化的時候,都記下一條記錄,內容就是資料變化的情況:插入了一條內容是某某的聯絡方法、刪除了一條某某的聯絡方法……,這樣我們記錄的資料,僅僅就是變化的部分,而不需要拷貝很多份副本。當我們需要恢復到任何一個時間點的時候,只需要按這些記錄依次對資料修改一遍,直到指定的時間點的記錄即可。這個恢復的時間可能會有點長,但是卻可以大大節省儲存空間。這就是用CPU的時間來換磁碟的儲存空間的策略。我們現在常見的MySQL InnoDB日誌型資料表,以及SVN原始碼儲存,都是使用這種策略的。
另外,我們的Web伺服器,在傳送HTML檔案內容的時候,往往也會先用ZIP壓縮,然後傳送給瀏覽器,瀏覽器收到後要先解壓,然後才能顯示,這個也是用伺服器和客戶端的CPU時間,來換取網路頻寬的空間。
在我們的計算機體系中,快取的思路幾乎無處不在,比如我們的CPU裡面就有1級快取、2級快取,他們就是為了用這些快速的儲存空間,換取對記憶體這種相對比較慢的儲存空間的等待時間。我們的顯示卡里面也帶有大容量的快取,他們是用來儲存顯示圖形的運算結果的。
通往大空間的郊區路上容易交通堵塞
快取的本質,除了讓“已經處理過的資料,不需要重複處理”以外,還有“以快速的資料儲存讀寫,代替較慢速的儲存讀寫”的策略。我們在選擇快取策略進行時空轉換的時候,必須明確我們要轉換的時間和空間是否合理,是否能達到效果。比如早期有一些人會把WEB檔案快取在分散式磁碟上(例如NFS),但是由於通過網路訪問磁碟本身就是一個比較慢的操作,而且還會佔用可能就不充裕的網路頻寬空間,導致效能可能變得更慢。
在設計快取機制的時候,我們還容易碰到另外一個風險,就是對快取資料的程式設計處理問題。如果我們要快取的資料,並不是完全無需處理直接讀寫的,而是需要讀入記憶體後,以某種語言的結構體或者物件來處理的,這就需要涉及到“序列化”和“反序列化”的問題。如果我們採用直接拷貝記憶體的方式來快取資料,當我們的這些資料需要跨程序、甚至跨語言訪問的時候,會出現那些指標、ID、控制代碼資料的失效。因為在另外一個程序空間裡,這些“標記型”的資料都是不存在的。因此我們需要更深入的對資料快取的方法,我們可能會使用所謂深拷貝的方案,也就是跟著那些指標去找出目標記憶體的資料,一併拷貝。一些更現代的做法,則是使用所謂序列化方案來解決這個問題,也就是用一些明確定義了的“拷貝方法”來定義一個結構體,然後使用者就能明確的知道這個資料會被拷貝,直接取消了指標之類的記憶體地址資料的存在。比如著名的Protocol Buffer就能很方便的進行記憶體、磁碟、網路位置的快取;現在我們常見的JSON,也被一些系統用來作為快取的資料格式。
但是我們需要注意的是,快取的資料和我們程式真正要操作的資料,往往是需要進行一些拷貝和運算的,這就是序列化和反序列化的過程,這個過程很快,也有可能很慢。所以我們在選擇資料快取結構的時候,必須要注意其轉換時間,否則你快取的效果可能被這些資料拷貝、轉換消耗去很多,嚴重的甚至比不快取更差。一般來說,快取的資料越解決使用時的記憶體結構,其轉換速度就越快,在這點上,Protocol Buffer採用TLV編碼,就比不上直接memcpy的一個C結構體,但是比編碼成純文字的XML或者JSON要來的更快。因為編解碼的過程往往要進行復雜的查表對映,列表結構等操作。
快取策略的難點
雖然使用快取思想似乎是一個很簡單的事情,但是快取機制卻有一個核心的難點,就是——快取清理。我們所說的快取,都是儲存一些資料,但是這些資料往往是會變化的,我們要針對這些變化,清理掉儲存的“髒”資料,卻可能不是那麼容易。
首先我們來看看最簡單的快取資料——靜態資料。這種資料往往在程式的執行時是不會變化的,比如Web伺服器記憶體中快取的HTML檔案資料,就是這種。事實上,所有的不是由外部使用者上傳的資料,都屬於這種“執行時靜態資料”。一般來說,我們對這種資料,可以採用兩種建立快取的方法:一是程式一啟動,就一股腦把所有的靜態資料從檔案或者資料庫讀入記憶體;二就是程式啟動的時候並不載入靜態資料,而是等有使用者訪問相關資料的時候,才去載入,這也就是所謂lazy load的做法。第一種方法程式設計比較簡單,程式的記憶體啟動後就穩定了,不太容易出現記憶體漏洞(如果載入的快取太多,程式在啟動後立刻會因記憶體不足而退出,比較容易發現問題);第二種方法程式啟動很快,但要對快取佔用的空間有所限制或者規劃,否則如果要快取的資料太多,可能會耗盡記憶體,導致線上服務中斷。
一般來說,靜態資料是不會“髒”的,因為沒有使用者會去寫快取中的資料。但是在實際工作中,我們的線上服務往往會需要“立刻”變更一些快取資料。比如在入口網站上釋出了一條新聞,我們會希望立刻讓所有訪問的使用者都看到。按最簡單的做法,我們一般只要重啟一下伺服器程序,記憶體中的快取就會消失了。對於靜態快取的變化頻率非常低的業務,這樣是可以的,但是如果是新聞網站,就不能每隔幾分鐘就重啟一下WEB伺服器程序,這樣會影響大量線上使用者的訪問。常見的解決這類問題有兩種處理策略:
第一種是使用控制命令。簡單來說,就是在伺服器程序上,開通一個實時的命令埠,我們可以通過網路資料包(如UDP包),或者Linux系統訊號(如kill SIGUSR2程序號)之類的手段,傳送一個命令訊息給伺服器程序,讓程序開始清理快取。這種清理可能執行的是最簡單的“全部清理”,也有的可以細緻一點的,讓命令訊息中帶有“想清理的資料ID”這樣的資訊,比如我們傳送給WEB伺服器的清理訊息網路包中會帶一個字串URL,表示要清理哪一個HTML檔案的快取。這種做法的好處是清理的操作很精準,可以明確的控制清理的時間和資料。但是缺點就是比較繁瑣,手工去編寫傳送這種命令很煩人,所以一般我們會把清理快取命令的工作,編寫到上傳靜態資料的工具當中,比如結合到網站的內容釋出系統中,一旦編輯提交了一篇新的新聞,釋出系統的程式就自動的傳送一個清理訊息給WEB伺服器。
第二種是使用欄位判斷邏輯。也就是伺服器程序,會在每次讀取快取前,根據一些特徵資料,快速的判斷記憶體中的快取和源資料內容,是否有不一致(是否髒)的地方,如果有不一致的地方,就自動清理這條資料的快取。這種做法會消耗一部分CPU,但是就不需要人工去處理清理快取的事情,自動化程度很高。現在我們的瀏覽器和WEB伺服器之間,就有用這種機制:檢查檔案MD5;或者檢查檔案最後更新時間。具體的做法,就是每次瀏覽器發起對WEB伺服器的請求時,除了傳送URL給伺服器外,還會發送一個快取了此URL對應的檔案內容的MD5校驗串、或者是此檔案在伺服器上的“最後更新時間”(這個校驗串和“最後更新時間”是第一次獲的檔案時一併從伺服器獲得的);伺服器收到之後,就會把MD5校驗串或者最後更新時間,和磁碟上的目標檔案進行對比,如果是一致的,說明這個檔案沒有被修改過(快取不是“髒”的),可以直接使用快取。否則就會讀取目標檔案返回新的內容給瀏覽器。這種做法對於伺服器效能是有一定消耗的,所以如果往往我們還會搭配其他的快取清理機制來用,比如我們會在設定一個“超時檢查”的機制:就是對於所有的快取清理檢查,我們都簡單的看看快取存在的時間是否“超時”了,如果超過了,才進行下一步的檢查,這樣就不用每次請求都去算MD5或者看最後更新時間了。但是這樣就存在“超時”時間內快取變髒的可能性。
WEB伺服器靜態快取例子
上面說了執行時靜態的快取清理,現在說說執行時變化的快取資料。在伺服器程式執行期間,如果使用者和伺服器之間的互動,導致了快取的資料產生了變化,就是所謂“執行時變化快取”。比如我們玩網路遊戲,登入之後的角色資料就會從資料庫裡讀出來,進入伺服器的快取(可能是堆記憶體或者memcached、共享記憶體),在我們不斷進行遊戲操作的時候,對應的角色資料就會產生修改的操作,這種快取資料就是“執行時變化的快取”。這種執行時變化的資料,有讀和寫兩個方面的清理問題:由於快取的資料會變化,如果另外一個程序從資料庫讀你的角色資料,就會發現和當前遊戲裡的資料不一致;如果伺服器程序突然結束了,你在遊戲裡升級,或者撿道具的資料可能會從記憶體快取中消失,導致你白忙活了半天,這就是沒有回寫(快取寫操作的清理)導致的問題。這種情況在電子商務領域也很常見,最典型的就是火車票網上購買的系統,火車票資料快取在記憶體必須有合適的清理機制,否則讓兩個買了同一張票就麻煩了,但如果不快取,大量使用者同時搶票,伺服器也應對不過來。因此在執行時變化的資料快取,應該有一些特別的快取清理策略。
在實際執行業務中,執行變化的資料往往是根據使用使用者的增多而增多的,因此首先要考慮的問題,就是快取空間不夠的可能性。我們不太可能把全部資料都放到快取的空間裡,也不可能清理快取的時候就全部資料一起清理,所以我們一般要對資料進行分割,這種分割的策略常見的有兩種:一種是按重要級來分割,一種是按使用部分分割。
先舉例說說“按重要級分割”,在網路遊戲中,同樣是角色的資料,有些資料的變化可能會每次修改都立刻回寫到資料庫(清理寫快取),其他一些資料的變化會延遲一段時間,甚至有些資料直到角色退出遊戲才回寫,如玩家的等級變化(升級了),武器裝備的獲得和消耗,這些玩家非常看重的資料,基本上會立刻回寫,這些就是所謂最重要的快取資料。而玩家的經驗值變化、當前HP、MP的變化,就會延遲一段時間才寫,因為就算丟失了快取,玩家也不會太過關注。最後有些比如玩家在房間(地區)裡的X/Y座標,對話聊天的記錄,可能會退出時回寫,甚至不回寫。這個例子說的是“寫快取”的清理,下面說說“讀快取”的按重要級分割清理。
假如我們寫一個網店系統,裡面容納了很多產品,這些產品有一些會被使用者頻繁檢索到,比較熱銷,而另外一些商品則沒那麼熱銷。熱銷的商品的餘額、銷量、評價都會比較頻繁的變化,而滯銷的商品則變化很少。所以我們在設計的時候,就應該按照不同商品的訪問頻繁程度,來決定快取哪些商品的資料。我們在設計快取的結構時,就應該構建一個可以統計快取讀寫次數的指標,如果有些資料的讀寫頻率過低,或者空閒(沒有人讀、寫快取)時間超長,快取應該主動清理掉這些資料,以便其他新的資料能進入快取。這種策略也叫做“冷熱交換”策略。實現“冷熱交換”的策略時,關鍵是要定義一個合理的冷熱統計演算法。一些固定的指標和演算法,往往並不能很好的應對不同硬體、不同網路情況下的變化,所以現在人們普遍會用一些動態的演算法,如Redis就採用了5種,他們是:
1.根據過期時間,清理最長時間沒用過的
2.根據過期時間,清理即將過期的
3.根據過期時間,任意清理一個
4.無論是否過期,隨機清理
5.無論是否過期,根據LRU原則清理:所謂LRU,就是Least Recently Used,最近最久未使用過。這個原則的思想是:如果一個數據在最近一段時間沒有被訪問到,那麼在將來他被訪問的可能性也很小。LRU是在作業系統中很常見的一種原則,比如記憶體的頁面置換演算法(也包括FIFO,LFU等),對於LRU的實現,還是非常有技巧的,但是本文就不詳細去說明如何實現,留待大家上網搜尋“LRU”關鍵字學習。
資料快取的清理策略其實遠不止上面所說的這些,要用好快取這個武器,就要仔細研究需要快取的資料特徵,他們的讀寫分佈,資料之中的差別。然後最大化的利用業務領域的知識,來設計最合理的快取清理策略。這個世界上不存在萬能的優化快取清理策略,只存在針對業務領域最優化的策略,這需要我們程式設計師深入理解業務領域,去發現數據背後的規律。
分佈
分佈策略的概念
任何的伺服器的效能都是有極限的,面對海量的網際網路訪問需求,是不可能單靠一臺伺服器或者一個CPU來承擔的。所以我們一般都會在執行時架構設計之初,就考慮如何能利用多個CPU、多臺伺服器來分擔負載,這就是所謂分佈的策略。分散式的伺服器概念很簡單,但是實現起來卻比較複雜。因為我們寫的程式,往往都是以一個CPU,一塊記憶體為基礎來設計的,所以要讓多個程式同時執行,並且協調運作,這需要更多的底層工作。
首先出現能支援分散式概念的技術是多程序。在DOS時代,計算機在一個時間內只能執行一個程式,如果你想一邊寫程式,同時一邊聽mp3,都是不可能的。但是,在WIN95作業系統下,你就可以同時開多個視窗,背後就是同時在執行多個程式。在Unix和後來的Linux作業系統裡面,都普遍支援了多程序的技術。所謂的多程序,就是作業系統可以同時執行我們編寫的多個程式,每個程式執行的時候,都好像自己獨佔著CPU和記憶體一樣。在計算機只有一個CPU的時候,實際上計算機會分時複用的執行多個程序,CPU在多個程序之間切換。但是如果這個計算機有多個CPU或者多個CPU核,則會真正的有幾個程序同時執行。所以程序就好像一個作業系統提供的執行時“程式盒子”,可以用來在執行時,容納任何我們想執行的程式。當我們掌握了作業系統的多程序技術後,我們就可以把伺服器上的執行任務,分為多個部分,然後分別寫到不同的程式裡,利用上多CPU或者多核,甚至是多個伺服器的CPU一起來承擔負載。
多程序利用多CPU
這種劃分多個程序的架構,一般會有兩種策略:一種是按功能來劃分,比如負責網路處理的一個程序,負責資料庫處理的一個程序,負責計算某個業務邏輯的一個程序。另外一種策略是每個程序都是同樣的功能,只是分擔不同的運算任務而已。使用第一種策略的系統,執行的時候,直接根據作業系統提供的診斷工具,就能直觀的監測到每個功能模組的效能消耗,因為作業系統提供程序盒子的同時,也能提供對程序的全方位的監測,比如CPU佔用、記憶體消耗、磁碟和網路I/O等等。但是這種策略的運維部署會稍微複雜一點,因為任何一個程序沒有啟動,或者和其他程序的通訊地址沒配置好,都可能導致整個系統無法運作;而第二種分佈策略,由於每個程序都是一樣的,這樣的安裝部署就非常簡單,效能不夠就多找幾個機器,多啟動幾個程序就完成了,這就是所謂的平行擴充套件。
現在比較複雜的分散式系統,會結合這兩種策略,也就是說系統既按一些功能劃分出不同的具體功能程序,而這些程序又是可以平行擴充套件的。當然這樣的系統在開發和運維上的複雜度,都是比單獨使用“按功能劃分”和“平行劃分”要更高的。由於要管理大量的程序,傳統的依靠配置檔案來配置整個叢集的做法,會顯得越來越不實用:這些執行中的程序,可能和其他很多程序產生通訊關係,當其中一個程序變更通訊地址時,勢必影響所有其他程序的配置。所以我們需要集中的管理所有程序的通訊地址,當有變化的時候,只需要修改一個地方。在大量程序構建的叢集中,我們還會碰到容災和擴容的問題:當叢集中某個伺服器出現故障,可能會有一些程序消失;而當我們需要增加叢集的承載能力時,我們又需要增加新的伺服器以及程序。這些工作在長期執行的伺服器系統中,會是比較常見的任務,如果整個分佈系統有一個執行中的中心程序,能自動化的監測所有的程序狀態,一旦有程序加入或者退出叢集,都能即時的修改所有其他程序的配置,這就形成了一套動態的多程序管理系統。開源的ZooKeeper給我們提供了一個可以充當這種動態叢集中心的實現方案。由於ZooKeeper本身是可以平行擴充套件的,所以它自己也是具備一定容災能力的。現在越來越多的分散式系統都開始使用以ZooKeeper為叢集中心的動態程序管理策略了。
動態程序叢集
在呼叫多程序服務的策略上,我們也會有一定的策略選擇,其中最著名的策略有三個:一個是動態負載均衡策略;一個是讀寫分離策略;一個是一致性雜湊策略。動態負載均衡策略,一般會蒐集多個程序的服務狀態,然後挑選一個負載最輕的程序來分發服務,這種策略對於比較同質化的程序是比較合適的。讀寫分離策略則是關注對持久化資料的效能,比如對資料庫的操作,我們會提供一批程序專門用於提供讀資料的服務,而另外一個(或多個)程序用於寫資料的服務,這些寫資料的程序都會每次寫多份拷貝到“讀服務程序”的資料區(可能就是單獨的資料庫),這樣在對外提供服務的時候,就可以提供更多的硬體資源。一致性雜湊策略是針對任何一個任務,看看這個任務所涉及讀寫的資料,是屬於哪一片的,是否有某種可以快取的特徵,然後按這個資料的ID或者特徵值,進行“一致性雜湊”的計算,分擔給對應的處理程序。這種程序呼叫策略,能非常的利用上程序內的快取(如果存在),比如我們的一個線上遊戲,由100個程序承擔服務,那麼我們就可以把遊戲玩家的ID,作為一致性雜湊的資料ID,作為程序呼叫的KEY,如果目標服務程序有快取遊戲玩家的資料,那麼所有這個玩家的操作請求,都會被轉到這個目標服務程序上,快取的命中率大大提高。而使用“一致性雜湊”,而不是其他雜湊演算法,或者取模演算法,主要是考慮到,如果服務程序有一部分因故障消失,剩下的服務程序的快取依然可以有效,而不會整個叢集所有程序的快取都失效。具體有興趣的讀者可以搜尋“一致性雜湊”一探究竟。
以多程序利用大量的伺服器,以及伺服器上的多個CPU核心,是一個非常有效的手段。但是使用多程序帶來的額外的程式設計複雜度的問題。一般來說我們認為最好是每個CPU核心一個程序,這樣能最好的利用硬體。如果同時執行的程序過多,作業系統會消耗很多CPU時間在不同程序的切換過程上。但是,我們早期所獲得的很多API都是阻塞的,比如檔案I/O,網路讀寫,資料庫操作等。如果我們只用有限的程序來執行帶這些阻塞操作的程式,那麼CPU會大量被浪費,因為阻塞的API會讓有限的這些程序停著等待結果。那麼,如果我們希望能處理更多的任務,就必須要啟動更多的程序,以便充分利用那些阻塞的時間,但是由於程序是作業系統提供的“盒子”,這個盒子比較大,切換耗費的時間也比較多,所以大量並行的程序反而會無謂的消耗伺服器資源。加上程序之間的記憶體一般是隔離的,程序間如果要交換一些資料,往往需要使用一些作業系統提供的工具,比如網路socket,這些都會額外消耗伺服器效能。因此,我們需要一種切換代價更少,通訊方式更便捷,程式設計方法更簡單的並行技術,這個時候,多執行緒技術出現了。
[在程序盒子裡面的執行緒盒子]
多執行緒的特點是切換代價少,可以同時訪問記憶體。我們可以在程式設計的時候,任意讓某個函式放入新的執行緒去執行,這個函式的引數可以是任何的變數或指標。如果我們希望和這些執行時的執行緒通訊,只要讀、寫這些指標指向的變數即可。在需要大量阻塞操作的時候,我們可以啟動大量的執行緒,這樣就能較好的利用CPU的空閒時間;執行緒的切換代價比程序低得多,所以我們能利用的CPU也會多很多。執行緒是一個比程序更小的“程式盒子”,他可以放入某一個函式呼叫,而不是一個完整的程式。一般來說,如果多個執行緒只是在一個程序裡面執行,那其實是沒有利用到多核CPU的並行好處的,僅僅是利用了單個空閒的CPU核心。但是,在JAVA和C#這類帶虛擬機器的語言中,多執行緒的實現底層,會根據具體的作業系統的任務排程單位(比如程序),儘量讓執行緒也成為作業系統可以排程的單位,從而利用上多個CPU核心。比如Linux2.6之後,提供了NPTL的核心執行緒模型,JVM就提供了JAVA執行緒到NPTL核心執行緒的對映,從而利用上多核CPU。而Windows系統中,據說本身執行緒就是系統的最小排程單位,所以多執行緒也是利用上多核CPU的。所以我們在使用JAVA\C#程式設計的時候,多執行緒往往已經同時具備了多程序利用多核CPU、以及切換開銷低的兩個好處。
早期的一些網路聊天室服務,結合了多執行緒和多程序使用的例子。一開始程式會啟動多個廣播聊天的程序,每個程序都代表一個房間;每個使用者連線到聊天室,就為他啟動一個執行緒,這個執行緒會阻塞的讀取使用者的輸入流。這種模型在使用阻塞API的環境下,非常簡單,但也非常有效。
當我們在廣泛使用多執行緒的時候,我們發現,儘管多執行緒有很多優點,但是依然會有明顯的兩個缺點:一個記憶體佔用比較大且不太可控;第二個是多個執行緒對於用一個數據使用時,需要考慮複雜的“鎖”問題。由於多執行緒是基於對一個函式呼叫的並行執行,這個函式裡面可能會呼叫很多個子函式,每呼叫一層子函式,就會要在棧上佔用新的記憶體,大量執行緒同時在執行的時候,就會同時存在大量的棧,這些棧加在一起,可能會形成很大的記憶體佔用。並且,我們編寫伺服器端程式,往往希望資源佔用儘量可控,而不是動態變化太大,因為你不知道什麼時候會因為記憶體用完而當機,在多執行緒的程式中,由於程式執行的內容導致棧的伸縮幅度可能很大,有可能超出我們預期的記憶體佔用,導致服務的故障。而對於記憶體的“鎖”問題,一直是多執行緒中複雜的課題,很多多執行緒工具庫,都推出了大量的“無鎖”容器,或者“執行緒安全”的容器,並且還大量設計了很多協調執行緒運作的類庫。但是這些複雜的工具,無疑都是證明了多執行緒對於記憶體使用上的問題。
同時排多條隊就是並行
由於多執行緒還是有一定的缺點,所以很多程式設計師想到了一個釜底抽薪的方法:使用多執行緒往往是因為阻塞式API的存在,比如一個read()操作會一直停止當前執行緒,那麼我們能不能讓這些操作變成不阻塞呢?——selector/epoll就是Linux退出的非阻塞式API。如果我們使用了非阻塞的操作函式,那麼我們也無需用多執行緒來併發的等待阻塞結果。我們只需要用一個執行緒,迴圈的檢查操作的狀態,如果有結果就處理,無結果就繼續迴圈。這種程式的結果往往會有一個大的死迴圈,稱為主迴圈。在主迴圈體內,程式設計師可以安排每個操作事件、每個邏輯狀態的處理邏輯。這樣CPU既無需在多執行緒間切換,也無需處理複雜的並行資料鎖的問題——因為只有一個執行緒在執行。這種就是被稱為“併發”的方案。
[服務員兼了點菜、上菜就是併發]
實際上計算機底層早就有使用併發的策略,我們知道計算機對於外部裝置(比如磁碟、網絡卡、顯示卡、音效卡、鍵盤、滑鼠),都使用了一種叫“中斷”的技術,早期的電腦使用者可能還被要求配置IRQ號。這個中斷技術的特點,就是CPU不會阻塞的一直停在等待外部裝置資料的狀態,而是外部資料準備好後,給CPU發一個“中斷訊號”,讓CPU轉去處理這些資料。非阻塞的程式設計實際上也是類似這種行為,CPU不會一直阻塞的等待某些I/O的API呼叫,而是先處理其他邏輯,然後每次主迴圈去主動檢查一下這些I/O操作的狀態。
多執行緒和非同步的例子,最著名就是Web伺服器領域的Apache和Nginx的模型。Apache是多程序/多執行緒模型的,它會在啟動的時候啟動一批程序,作為程序池,當用戶請求到來的時候,從程序池中分配處理程序給具體的使用者請求,這樣可以節省多程序/執行緒的建立和銷燬開銷,但是如果同時有大量的請求過來,還是需要消耗比較高的程序/執行緒切換。而Nginx則是採用epoll技術,這種非阻塞的做法,可以讓一個程序同時處理大量的併發請求,而無需反覆切換。對於大量的使用者訪問場景下,apache會存在大量的程序,而nginx則可以僅用有限的程序(比如按CPU核心數來啟動),這樣就會比apache節省了不少“程序切換”的消耗,所以其併發效能會更好。
[Nginx的固定多程序,一個程序非同步處理多個客戶端]
[Apache的多型多程序,一個程序處理一個客戶]
在現代伺服器端軟體中,nginx這種模型的運維管理會更簡單,效能消耗也會稍微更小一點,所以成為最流行的程序架構。但是這種好處,會付出一些另外的代價:非阻塞程式碼在程式設計的複雜度變大。
分散式程式設計複雜度
以前我們的程式碼,從上往下執行,每一行都會佔用一定的CPU時間,這些程式碼的直接順序,也是和編寫的順序基本一致,任何一行程式碼,都是唯一時刻的執行任務。當我們在編寫分散式程式的時候,我們的程式碼將不再好像那些單程序、單執行緒的程式一樣簡單。我們要把同時執行的不同程式碼,在同一段程式碼中編寫。就好像我們要把整個交響樂團的每個樂器的樂譜,全部寫到一張紙上。為了解決這種程式設計的複雜度,業界發展出了多種編碼形式。
在多程序的編碼模型上,fork()函式可以說一個非常典型的代表。在一段程式碼中,fork()呼叫之後的部分,可能會被新的程序中執行。要區分當前程式碼的所在程序,要靠fork()的返回值變數。這種做法,等於把多個程序的程式碼都合併到一塊,然後通過某些變數作為標誌來劃分。這樣的寫法,對於不同程序程式碼大部份相同的“同質程序”來說,還是比較方便的,最怕就是有大量的不同邏輯要用不同的程序來處理,這種情況下,我們就只能自己通過規範fork()附近的程式碼,來控制混亂的局面。比較典型的是把fork()附近的程式碼弄成一個類似分發器(dispatcher)的形式,把不同功能的程式碼放到不同的函式中,以fork之前的標記變數來決定如何呼叫。
[動態多程序的程式碼模式]
在我們使用多執行緒的API時,情況就會好很多,我們可以用一個函式指標,或者一個帶回調方法的物件,作為執行緒執行的主體,並且以控制代碼或者物件的形式來控制這些執行緒。作為開發人員,我們只要掌握了對執行緒的啟動、停止等有限的幾個API,就能很好的對並行的多執行緒進行控制。這對比多程序的fork()來說,從程式碼上看會更直觀,只是我們必須要分清楚呼叫一個函式,和新建一個執行緒去呼叫一個函式,之間的差別:新建執行緒去呼叫函式,這個操作會很快的結束,並不會依序去執行那個函式,而是代表著,那個函式中的程式碼,可能和執行緒呼叫之後的程式碼,交替的執行。
由於多執行緒把“並行的任務”作為一個明確的程式設計概念定義了出來,以控制代碼、物件的形式封裝好,那麼我們自然會希望對多執行緒能更多複雜而細緻的控制。因此出現了很多多執行緒相關的工具。比較典型的程式設計工具有執行緒池、執行緒安全容器、鎖這三類。執行緒池提供給我們以“池”的形態,自動管理執行緒的能力:我們不需要自己去考慮怎麼建立執行緒、回收執行緒,而是給執行緒池一個策略,然後輸入需要執行的任務函式,執行緒池就會自動操作,比如它會維持一個同時執行執行緒數量,或者保持一定的空閒執行緒以節省建立、銷燬執行緒的消耗。在多執行緒操作中,不像多程序在記憶體上完全是區分開的,所以可以訪問同一份記憶體,也就是對堆裡面的同一個變數進行讀寫,這就可能產生程式設計師所預計不到的情況(因為我們寫程式只考慮程式碼是順序執行的)。還有一些物件容器,比如雜湊表和佇列,如果被多個執行緒同時操作,可能還會因為內部資料對不上,造成嚴重的錯誤,所以很多人開發了一些可以被多個執行緒同時操作的容器,以及所謂“原子”操作的工具,以解決這樣的問題。有些語言如Java,在語法層面,就提供了關鍵字來對某個變數進行“上鎖”,以保障只有一個執行緒能操作它。多執行緒的程式設計中,很多並行任務,是有一定的阻塞順序的,所以有各種各樣的鎖被髮明出來,比如倒數鎖、排隊鎖等等。java.concurrent庫就是多執行緒工具的一個大集合,非常值得學習。然而,多執行緒的這些五花八門的武器,其實也是證明了多執行緒本身,是一種不太容易使用的順手的技術,但是我們一下子還沒有更好的替代方案罷了。
[多執行緒的物件模型]
在多執行緒的程式碼下,除了啟動執行緒的地方,是和正常的執行順序不同以外,其他的基本都還是比較近似單執行緒程式碼的。但是如果在非同步併發的程式碼下,你會發現,程式碼一定要裝入一個個“回撥函式”裡。這些回撥函式,從程式碼的組織形態上,幾乎完全無法看出來其預期的執行順序,一般只能在執行的時候通過斷點或者日誌來分析。這就對程式碼閱讀帶來了極大的障礙。因此現在有越來越多的程式設計師關注“協程”這種技術:可以用類似同步的方法來寫非同步程式,而無需把程式碼塞到不同的回撥函式裡面。協程技術最大的特點,就是加入了一個叫yield的概念,這個關鍵字所在的程式碼行,是一個類似return的作用,但是又代表著後續某個時刻,程式會從yield的地方繼續往下執行。這樣就把那些需要回調的程式碼,從函式中得以解放出來,放到yield的後面了。在很多客戶端遊戲引擎中,我們寫的程式碼都是由一個框架,以每秒30幀的速度在反覆執行,為了讓一些任務,可以分別放在各幀中執行,而不是一直阻塞導致“卡幀”,使用協程就是最自然和方便的了——Unity3D就自帶了協程的支援。
在多執行緒同步程式中,我們的函式呼叫棧就代表了一系列同屬一個執行緒的處理。但是在單執行緒的非同步回撥的程式設計模式下,我們的一個回撥函式是無法簡單的知道,是在處理哪一個請求的序列中。所以我們往往需要自己寫程式碼去維持這樣的狀態,最常見的做法是,每個併發任務啟動的時候,就產生一個序列號(seqid),然後在所有的對這個併發任務處理的回撥函式中,都傳入這個seqid引數,這樣每個回撥函式,都可以通過這個引數,知道自己在處理哪個任務。如果有些不同的回撥函式,希望交換資料,比如A函式的處理結果希望B函式能得到,還可以用seqid作為key把結果存放到一個公共的雜湊表容器中,這樣B函式根據傳入的seqid就能去雜湊表中獲得A函式存入的結果了,這樣的一份資料我們往往叫做“會話”。如果我們使用協程,那麼這些會話可能都不需要自己來維持了,因為協程中的棧代表了會話容器,當執行序列切換到某個協程中的時候,棧上的區域性變數正是之前的處理過程的內容結果。
[協程的程式碼特徵]
為了解決非同步程式設計的回撥這種複雜的操作,業界還發明瞭很多其他的手段,比如lamda表示式、閉包、promise模型等等,這些都是希望我們,能從程式碼的表面組織上,把在多個不同時間段上執行的程式碼,以業務邏輯的形式組織到一起。
最後我想說說函數語言程式設計,在多執行緒的模型下,並行程式碼帶來最大的複雜性,就是對堆記憶體的同時操作。所以我們才弄出來鎖的機制,以及一大批對付死鎖的策略。而函數語言程式設計,由於根本不使用堆記憶體,所以就無需處理什麼鎖,反而讓整個事情變得非常簡單。唯一需要改變的,就是我們習慣於把狀態放到堆裡面的程式設計思路。函數語言程式設計的語言,比如LISP或者Erlang,其核心資料結果是連結串列——一種可以表示任何資料結構的結構。我們可以把所有的狀態,都放到連結串列這個資料列車中,然後讓一個個函式去處理這串資料,這樣同樣也可以傳遞程式的狀態。這是一種用棧來代替堆的程式設計思路,在多執行緒併發的環境下,非常的有價值。
分散式程式的編寫,一直都伴隨著大量的複雜性,影響我們對程式碼的閱讀和維護,所以我們才有各種各樣的技術和概念,試圖簡化這種複雜性。也許我們無法找到任何一個通用的解決方案,但是我們可以通過理解各種方案的目標,來選擇最適合我們的場景:
· 動態多程序fork——同質的並行任務
· 多執行緒——能明確劃的邏輯複雜的並行任務
· 非同步併發回撥——對效能要求高,但中間會被阻塞的處理較少的並行任務
· 協程——以同步的寫法編寫併發的任務,但是不合適發起複雜的動態並行操作。
· 函數語言程式設計——以資料流為模型的並行處理任務
分散式資料通訊
分散式的程式設計中,對於CPU時間片的切分本身不是難點,最困難的地方在於並行的多個程式碼片段,如何進行通訊。因為任何一個程式碼段,都不可能完全單獨的運作,都需要和其他程式碼產生一定的依賴。在動態多程序中,我們往往只能通過父程序的記憶體提供共享的初始資料,執行中則只能通過作業系統間的通訊方式了:Socket、訊號、共享記憶體、管道等等。無論那種做法,這些都帶來了一堆複雜的編碼。這些方式大部分都類似於檔案操作:一個程序寫入、另外一個程序讀出。所以很多人設計了一種叫“訊息佇列”的模型,提供“放入”訊息和“取出”訊息的介面,底層則是可以用Socket、共享記憶體、甚至是檔案來實現。這種做法幾乎能夠處理任何狀況下的資料通訊,而且有些還能儲存訊息。但是缺點是每個通訊訊息,都必須經過編碼、解碼、收包、發包這些過程,對處理延遲有一定的消耗。
如果我們在多執行緒中進行通訊,那麼我們可以直接對某個堆裡面的變數直接進行讀寫,這樣的效能是最高的,使用也非常方便。但是缺點是可能出現幾個執行緒同時使用變數,產生了不可預期的結果,為了對付這個問題,我們設計了對變數的“鎖”機制,而如何使用鎖又成為另外一個問題,因為可能出現所謂的“死鎖”問題。所以我們一般會用一些“執行緒安全”的容器,用來作為多執行緒間通訊的方案。為了協調多個執行緒之間的執行順序,還可以使用很多種型別的“工具鎖”。
在單執行緒非同步併發的情況下,多個會話間的通訊,也是可以通過直接對變數進行讀寫操作,而且不會出現“鎖”的問題,因為本質上每個時刻都只有一個段程式碼會操作這個變數。然而,我們還是需要對這些變數進行一定規劃和整理,否則各種指標或全域性變數在程式碼中散佈,也是很出現BUG的。所以我們一般會把“會話”的概念變成一個數據容器,每段程式碼都可以把這個會話容器作為一個“收件箱”,其他的併發任務如果需要在這個任務中通訊,就把資料放入這個“收件箱”即可。在WEB開發領域,和cookie對應的伺服器端Session機制,就是這種概念的典型實現。
分散式快取策略
在分散式程式架構中,如果我們需要整個體系有更高的穩定性,能夠對程序容災或者動態擴容提供支援,那麼最難解決的問題,就是每個程序中的記憶體狀態。因為程序一旦毀滅,記憶體中的狀態會消失,這就很難不影響提供的服務。所以我們需要一種方法,讓程序的記憶體狀態,不太影響整體服務,甚至最好能變成“無狀態”的服務。當然“狀態”如果不寫入磁碟,始終還是需要某些程序來承載的。在現在流行的WEB開發模式中,很多人會使用PHP+Memcached+MySQL這種模型,在這裡,PHP就是無狀態的,因為狀態都是放在Memcached裡面。這種做法對於PHP來說,是可以隨時動態的毀滅或者新建,但是Memcached程序就要保證穩定才行;而且Memcached作為一個額外的程序,和它通訊本身也會消耗更多的延遲時間。因此我們需要一種更靈活和通用的程序狀態儲存方案,我們把這種任務叫做“分散式快取”的策略。我們希望程序在讀取資料的時候,能有最高的效能,最好能和在堆記憶體中讀寫類似,又希望這些快取資料,能被放在多個程序內,以分散式的形態提供高吞吐的服務,其中最關鍵的問題,就是快取資料的同步。
[PHP常用Memached做快取]
為了解決這個問題,我們需要先一步步來分解這個問題:
首先,我們的快取應該是某種特定形式的物件,而不應該是任意型別的變數。因為我們需要對這些快取進行標準化的管理,儘管C++語言提供了運算過載,我們可以對“=”號的寫變數操作進行重新定義,但是現在基本已經沒有人推薦去做這樣的事。而我們手頭就有最常見的一種模型,適合快取這種概念的使用,它就是——雜湊表。所有的雜湊表(或者是Map介面),都是把資料的存放,分為key和value兩個部分,我們可以把想要快取的資料,作為value存放到“表”當中,同時我們也可以用key把對應的資料取出來,而“表”物件就代表了快取。
其次我們需要讓這個“表”能在多個程序中都存在。如果每個程序中的資料都毫無關聯,那問題其實就非常簡單,但是如果我們可能從A程序把資料寫入快取,然後在B程序把資料讀取出來,那麼就比較複雜了。我們的“表”要有能把資料在A、B兩個程序間同步的能力。因此我們一般會用三種策略:租約清理、租約轉發、修改廣播
· 租約清理,一般是指,我們把存放某個key的快取的程序,稱為持有這個key的資料的“租約”,這個租約要登記到一個所有程序都能訪問到的地方,比如是ZooKeeper叢集程序。那麼在讀、寫發生的時候,如果本程序沒有對應的快取,就先去查詢一下對應的租約,如果被其他程序持有,則通知對方“清理”,所謂“清理”,往往是指刪除用來讀的資料,回寫用來寫的資料到資料庫等持久化裝置,等清理完成後,在進行正常的讀寫操作,這些操作可能會重新在新的程序上建立快取。這種策略在快取命中率比較高的情況下,效能是最好的,因為一般無需查詢租約情況,就可以直接操作;但如果快取命中率低,那麼就會出現快取反覆在不同程序間“移動”,會嚴重降低系統的處理效能。
· 租約轉發。同樣,我們把存放某個KEY的快取的程序,稱為持有這個KEY資料的“租約”,同時也要登記到叢集的共享資料程序中。和上面租約清理不同的地方在於,如果發現持有租約的程序不是本次操作的程序,就會把整個資料的讀、寫請求,都通過網路“轉發”個持有租約的程序,然後等待他的操作結果返回。這種做法由於每次操作都需要查詢租約,所以效能會稍微低一些;但如果快取命中率不高,這種做法能把快取的操作分擔到多個程序上,而且也無需清理快取,這比租約清理的策略適應性更好。
· 修改廣播。上面兩種策略,都需要維護一份快取資料的租約,但是本身對於租約的操作,就是一種比較耗費效能的事情。所以有時候可以採用一些更簡單,但可能承受一些不一致性的策略:對於讀操作,每個節點的讀都建立快取,每次讀都判斷是否超過預設的讀冷卻時間x,超過則清理快取從持久化重建;對於寫操作,麼個節點上都判斷是否超過預設的寫冷卻時間y,超過則展開清理操作。清理操作也分兩種,如果資料量小就廣播修改資料;如果資料量大就廣播清理通知回寫到持久化中。這樣雖然可能會有一定的不一致風險,但是如果資料不是那種要求太高的,而且快取命中率又能比較有保障的話(比如根據KEY來進行一致性雜湊訪問快取程序),那麼真正因為寫操作廣播不及時,導致資料不一致的情況還是會比較少的。這種策略實現起來非常簡單,無需一箇中心節點程序維護資料租約,也無需複雜的判斷邏輯進行同步,只要有廣播的能力,加上對於寫操作的一些配置,就能實現高效的快取服務。所以“修改廣播”策略是在大多數需要實時同步,但資料一致性要求不高的領域最常見的手段。著名的DNS系統的快取就是接近這種策略:我們要修改某個域名對應的IP,並不是立刻在全球所有的DNS伺服器上生效,而是需要一定時間廣播修改給其他服務區。而我們每個DSN伺服器,都具備了大量的其他域名的快取資料。
總結
在高效能的伺服器架構中,常用的快取和分佈兩種策略,往往是結合到一起使用的。雖然這兩種策略,都有無數種不同的表現形式,成為各種各樣的技術流派,但是隻有清楚的理解這些技術的原理,並且和實際的業務場景結合起來,才能真正的做出滿足應用要求的高效能架構。