Memcache緩存系統原理

在Web服務開發中,服務端緩存是服務實現中所常常採用的一種提高服務性能的方法。其通過記錄某部分計算結果來嘗試避免再次執行得到該結果所需要的複雜計算,從而提高了服務的運行效率。

除了能夠提高服務的運行效率之外,服務端緩存還常常用來提高服務的擴展性。因此一些大規模的Web應用,如Facebook,常常構建一個龐大的服務端緩存。而它們所最常使用的就是Memcached。

在本文中,我們就將對Memcached進行簡單地介紹。

Memcached簡介

在介紹Memcached之前,讓我們首先通過一個示例瞭解什麼是服務端緩存。

相信大家都玩過一些網絡聯機遊戲吧。在我那個年代(03年左右),這些遊戲常常添加了對戰功能,並提供了天梯來顯示具有最優秀戰績的玩家以及當 前玩家在天梯系統中的排名。這是遊戲開發商所常常採用的一種聚攏玩家人氣的手段。而希望在遊戲中證明自己的玩家則會由此激發鬥志,進而花費更多時間來在天 梯中取得更好的成績。

就天梯系統來說,其最主要的功能就是為玩家提供天梯排名的信息,而並不允許玩家對該系統中所記錄的數據作任何修改。這樣設定的結果就是,整個天 梯系統的讀操作居多,而寫操作很少。反過來,由於一個遊戲中的玩家可能有上千萬甚至上億人,而且在線人數常常達到上萬人,因此對天梯的訪問也會是非常頻繁 的。這樣的話,即使每秒鐘只有10個人訪問天梯中的排名,對這上億個玩家的天梯排名進行讀取及排序也是一件非常消耗性能的事情。

一個自然而然的想法就是:在對天梯排名進行一次計算後,我們在服務端將該天梯排名緩存起來,並在其它玩家訪問的時候直接返回該緩存中所記錄的結 果。而在一定時間段之後,如一個小時,我們再對緩存中的數據進行更新。這樣我們就不再需要每個小時執行成千上萬次的天梯排名計算了。

而這就是服務端緩存所提供的最重要功能。其既可以提高單個請求的響應速度,又可以降低服務層及數據庫層的壓力。除此之外,多個服務實例都可以讀 取該服務端緩存所緩存的信息,因此我們也不再需要擔心這些數據在各個服務實例中都保存了一份進而需要彼此同步的問題,也即是提高了擴展性。

而Memcached就是一個使用了BSD許可的服務端緩存實現。但是與其它服務端緩存實現不同的是,其主要由兩部分組成:獨立運行的 Memcached服務實例,以及用於訪問這些服務實例的客戶端。因此相較於普通服務端緩存實現中各個緩存都運行在服務實例之上的情 況,Memcached服務實例則是在服務實例之外獨立運行的:

從上圖中可以看出,由於Memcached緩存實例是獨立於各個應用服務器實例運行的,因此應用服務實例可以訪問任意的緩存實例。而傳統的緩存則與 特定的應用實例綁定,因此每個應用實例將只能訪問特定的緩存。這種綁定一方面會導致整個應用所能夠訪問的緩存容量變得很小,另一方面也可能導致不同的緩存 實例中存在著冗餘的數據,從而降低了緩存系統的整體效率。

在運行時,Memcached服務實例只需要消耗非常少的CPU資源,卻需要使用大量的內存。因此在決定如何組織您的服務端緩存結構之前,您首 先需要搞清當前服務中各個服務實例的負載情況。如果一個服務器的CPU使用率非常高,卻存在著非常多的空餘內存,那麼我們就完全可以在其上運行一個 Memcached實例。而如果當前服務中的所有服務實例都沒有過多的空餘內存,那麼我們就需要使用一系列獨立的服務實例來搭建服務端緩存。一個大型服務 常常擁有上百個Memcached實例。而在這上百個Memcached實例中所存儲的數據則不盡相同。由於這種數據的異構性,我們需要在訪問由 Memcached所記錄的信息之前決定在該服務端緩存系統中到底由哪個Memcached實例記錄了我們所想要訪問的數據:

Memcache緩存系統原理

如上圖所示,用戶需要通過一個Memcached客戶端來完成對緩存服務所記錄信息的訪問。該客戶端知道服務端緩存系統中所包含的所有 Memcached服務實例。在需要訪問具有特定鍵值的數據時,該客戶端內部會根據所需要讀取的數據的鍵值,如“foo”,以及當前Memcached緩 存服務的配置來計算相應的哈希值,以決定到底是哪個Memcached實例記錄了用戶所需要訪問的信息。在決定記錄了所需要信息的Memcached實例 之後,Memcached客戶端將從配置中讀取該Memcached服務實例所在地址,並向該Memcached實例發送數據訪問請求,以從該 Memcached實例中讀取具有鍵值“foo”的信息。在各個論壇的討論中,這被稱為是Memcached的兩階段哈希(Two-stage hash)。

而對數據的記錄也使用了類似的流程:假設用戶希望通過服務端緩存記錄數據“bar”,併為其指定鍵值“foo”。那麼Memcached客戶端 將首先對用戶所賦予的鍵值“foo”及當前服務端緩存所記錄的可用服務實例個數執行哈希計算,並根據哈希計算結果來決定存儲該數據的Memcached服 務實例。接下來,客戶端就會向該實例發送請求,以在其中記錄具有鍵值“foo”的數據“bar”。

這樣做的好處則在於,每個Memcached服務實例都是獨立的,而彼此之間並沒有任何交互。在這種情況下,我們可以省略很多複雜的功能邏輯, 如各個節點之間的數據同步以及結點之間消息的廣播等等。這種輕量級的架構可以簡化很多操作。如在一個節點失效的時候,我們僅僅需要使用一個新的 Memcached節點替代老節點即可。而在對緩存進行擴容的時候,我們也只需要添加額外的服務並修改客戶端配置。

這些記錄在服務端緩存中的數據是全局可見的。也就是說,一旦在Memcached服務端緩存中成功添加了一條新的記錄,那麼其它使用該緩存服務的應用實例將同樣可以訪問該記錄:

Memcache緩存系統原理

在Memcached中,每條記錄都由四部分組成:記錄的鍵,有效期,一系列可選的標記以及表示記錄內容的數據。由於記錄內容的數據中並不包含任何數據結構,因此我們在Memcached中所記錄的數據需要是經過序列化之後的表示。

內存管理

在使用緩存時,我們不得不考慮的一個問題就是如何對這些緩存數據的生存期進行管理。這其中包括如何使一個記錄在緩存中的數據過期,如何在緩存空間不夠時執行數據的替換等。因此在本節中,我們將對Memcached的內存管理機制進行介紹。

首先我們來看一看Memcached的內存管理模型。通常情況下,一個內存管理算法所最需要考慮的問題就是內存的碎片化 (Fragmentation):在長時間地分配及回收之後,被系統所使用的內存將趨向於散落在不連續的空間中。這使得系統很難找到連續內存空間,一方面 增大了內存分配失敗的概率,另一方面也使得內存分配工作變得更為複雜,降低了運行效率。

為了解決這個問題,Memcached使用了一種叫Slab的結構。在該分配算法中,內存將按照1MB的大小劃分為頁,而該頁內存則會繼續被分割為一系列具有相同大小的內存塊:

Memcache緩存系統原理

因此Memcached並不是直接根據需要記錄的數據的大小來直接分配相應大小的內存。在一條新的記錄到來時,Memcached會首先檢查該記錄 的大小,並根據記錄的大小選擇記錄所需要存儲到的Slab類型。接下來,Memcached就會檢查其內部所包含的該類型Slab。如果這些Slab中有 空餘的塊,那麼Memcached就會使用該塊記錄該條信息。如果已經沒有Slab擁有空閒的具有合適大小的塊,那麼Memcached就會創建一個新的 頁,並將該頁按照目標Slab的類型進行劃分。

一個需要考慮的特殊情況就是對記錄的更新。在對一個記錄進行更新的時候,記錄的大小可能會發生變化。在這種情況下,其所對應的Slab類型也可能會發生變化。因此在更新時,記錄在內存中的位置可能會發生變化。只不過從用戶的角度來說,這並不可見。

Memcached使用這種方式來分配內存的好處則在於,其可以降低由於記錄的多次讀寫而導致的碎片化。反過來,由於Memcached是根據 記錄的大小選擇需要插入到的塊類型,因此為每個記錄所分配的塊的大小常常大於該記錄所實際需要的內存大小,進而造成了內存的浪費。當然,您可以通過 Memcached的配置文件來指定各個塊的大小,從而儘可能地減少內存的浪費。

但是需要注意的是,由於默認情況下Memcached中每頁的大小為1MB,因此其單個塊最大為1MB。除此之外,Memcached還限制每個數據所對應的鍵的長度不能超過250個字節。

一般來說,Slab中各個塊的大小以及塊大小的遞增倍數可能會對記錄所載位置的選擇及內存利用率有很大的影響。例如在當前的實現下,各個 Slab中塊的大小默認情況下是按照1.25倍的方式來遞增的。也就是說,在一個Memcached實例中,某種類型Slab所提供的塊的大小是80K, 而提供稍大一點空間的Slab類型所提供的塊的大小就將是100K。如果現在我們需要插入一條81K的記錄,那麼Memcached就會選擇具有100K 塊大小的Slab,並嘗試找到一個具有空閒塊的Slab以存入該記錄。

同時您也需要注意到,我們使用的是100K塊大小的Slab來記錄具有81K大小的數據,因此記錄該數據所導致的內存浪費是19K,即19%的 浪費。而在需要存儲的各條記錄的大小平均分佈的情況下,這種內存浪費的幅度也在9%左右。該幅度實際上取決於我們剛剛提到的各個Slab中塊大小的遞增倍 數。在Memcached的初始實現中,各個Slab塊的遞增倍數在默認情況下是2,而不是現在的1.25,從而導致了平均25%左右的內存浪費。而在今 後的各個版本中,該遞增倍數可能還會發生變化,以優化Memcached的實際性能。

如果您一旦知道了您所需要緩存的數據的特徵,如通常情況下數據的大小以及各個數據的差異幅度,那麼您就可以根據這些數據的特徵來設置上面所提到 的各個參數。如果數據在通常情況下都比較小,那麼我們就需要將最小塊的大小調整得小一些。如果數據的大小變動不是很大,那麼我們可以將塊大小的遞增倍數設 置得小一些,從而使得各個塊的大小盡量地貼近需要存儲的數據,以提高內存的利用率。

還有一個值得注意的事情就是,由於Memcached在計算到底哪個服務實例記錄了具有特定鍵的數據時並不會考慮用來組成緩存系統中各個服務器 的差異性。如果每個服務器上只安裝了一個Memcached實例,那麼各個Memcached實例所擁有的可用內存將存在著數倍的差異。但是由於各個實例 被選中的概率基本相同,因此具有較大內存的Memcached實例將無法被充分利用。我們可以通過在具有較大內存的服務器上部署多個Memcached實 例來解決這個問題:

Memcache緩存系統原理

例如上圖所展示的緩存系統是由兩個服務器組成。這兩個服務器中的內存大小並不相同。第一個服務器的內存大小為32G,而第二個服務器的內存大小僅僅 有8G。為了能夠充分利用這兩個服務器的內存,我們在具有32G內存的服務器上部署了4個Memcached實例,而在只有8G內存的服務器上部署了1個 Memcached實例。在這種情況下,32G內存服務器上的4個Memcached實例將總共得到4倍於8G服務器所得到的負載,從而充分地利用了 32G內存服務器上的內存。

當然,由於緩存系統擁有有限的資源,因此其會在某一時刻被服務所產生的數據填滿。如果此時緩存系統再次接收到一個緩存數據的請求,那麼它就會根 據LRU(Least recently used)算法以及數據的過期時間來決定需要從緩存系統中移除的數據。而Memcached所使用的過期算法比較特殊,又被稱為延遲過期(Lazy expiration):當用戶從Memcached實例中讀取數據的時候,其將首先通過配置中所設置的過期時間來決定該數據是否過期。如果是,那麼在下 一次寫入數據卻沒有足夠空間的時候,Memcached會選擇該過期數據所在的內存塊作為新數據的目標地址。如果在寫入時沒有相應的記錄被標記為過期,那 麼LRU算法才被執行,從而找到最久沒有被使用的需要被替換的數據。

這裡的LRU是在Slab範圍內的,而不是全局的。假設Memcached緩存系統中的最常用的數據都存儲在100K的塊中,而該系統中還存在 著另外一種類型的Slab,其塊大小是300K,但是存在於其中的數據並不常用。當需要插入一條99K的數據而Memcached已經沒有足夠的內存再次 分配一個Slab實例的時候,其並不會釋放具有300K塊大小的Slab,而是在100K塊大小的各個Slab中找到需要釋放的塊,並將新數據添加到該塊 中。

高可用性

在企業級應用中,我們常常強調一個系統需要擁有高可用性和高可靠性。而對於一個組成而言,其需要能夠穩定地運行,並在出現異常的時候儘量使得異 常的影響限制在某個特定的範圍內,而不會導致整個系統不能正常工作。而且在出現異常之後,該組成需要能較為容易地恢復到正常的工作狀態。

那麼Memcached需要什麼樣的高可用性呢?在講解這個問題之前,我們先來看看在一個大型服務中Memcached所組成的服務端緩存是什麼樣的:

Memcache緩存系統原理

從上圖中可以看到,在一個大型服務中,由Memcached所組成的服務端緩存實際上是由非常多的Memcached實例組成的。在前面我們已經 介紹過,Memcached實例實際上是完全獨立的,不存在Memcached實例之間的相互交互。因此在其中一個發生了故障的時候,其它的各個 Memcached服務實例並不會受到影響。如果一個擁有了16個Memcached實例的服務端緩存系統中的一個Memcached實例發生了故障,那 麼整個系統將還有93.75%的緩存容量可以繼續工作。雖然緩存容量的減少會略微增加其後的各個服務實例的壓力,但是一個應用所經歷的負載波動常常比這個 大得多,因此該服務應該還是能夠正常工作的。

而這也恰恰表明了Memcached所具有的獨立性的正確性。由於Memcached本身致力於創建一個高效而且簡單,卻具有較強擴展性的緩存 組件,因此其並沒有強調數據的安全性。一旦其中的一個Memcached實例發生了故障,那麼我們還可以從數據庫及服務端再次計算得到該數據,並將其記錄 在其它可用的Memcached實例上。

我想您讀到這裡一定會想:“不,還有一個問題,那就是由於Memcached實例的個數變化會導致哈希計算的結果發生變化,從而導致所有對數據 的請求會導向到不正確的Memcached實例,使得由Memcached實例集群所提供的緩存服務全部失效,從而導致數據庫的壓力驟增。”

是的,這也是我曾經有所顧慮的地方。而且這不僅僅在服務端緩存失效的時候存在。只要服務端緩存中Memcached實例的數量發生了變化,那麼該問題就會發生。

Memcached所使用的解決方法就是Consistent Hashing。在該算法的幫助下,Memcached實例數量的變化將只可能導致其中的一小部分鍵的哈希值發生改變。那該算法到底是怎麼運行的呢?

首先請考慮一個圓,在該圓上分佈了多個點,以表示整數0到1023。這些整數平均分佈在整個圓上:

Memcache緩存系統原理

而在上圖中,我們則突出地顯示了6個藍點。這六個藍點基本上將該圓進行了六等分。而它們所對應的就是在當前Memcached緩存系統中所包含的三個 Memcached實例m1,m2以及m3。好,接下來我們則對當前需要存儲的數據執行哈希計算,並找到該哈希結果900在該圓上所對應的點:

Memcache緩存系統原理

可以看到,該點在順時針距離上離表示0的那個藍點最近,因此這個具有哈希值900的數據將記錄在Memcached實例m1中。

如果其中的一個Memcached實例失效了,那麼需要由該實例所記錄的數據將暫時失效,而其它實例所記錄的數據仍然還在:

Memcache緩存系統原理

從上圖中可以看到,在Memcached實例m1失效的情況下,值為900的數據將失效,而其它的值為112和750的數據將仍然記錄在 Memcached實例m2及m3上。也就是說,一個節點的失效現在將只會導致一部分數據不再在緩存系統中存在,而並沒有導致其它實例上所記錄的數據的目 標實例發生變化。

但是我們還不得不考慮另一個問題,那就是在一個服務的服務端緩存僅僅由一個或幾個Memcached實例組成的情況。在這種情況下,其中一個 Memcached實例失效是較為致命的,因為數據庫以及服務器實例將接收到大量的需要進行復雜計算的請求,並將最終導致服務器實例和數據庫過載。因此在 設計服務端緩存時,我們常常採取超出需求容量的方法來定義這些緩存。例如在服務實際需要5個Memcached結點時我們設計一個包含6個節點的服務端緩 存系統,以增加整個系統的容錯能力。


分享到:


相關文章: