Memcached 是一個高性能的分布式內存對象緩存系統,它通過在內存中緩存數據和對象來減少讀取數據庫的次數,從而減輕RDBMS的負擔,提高服務的速度、提升可擴展性。本文將基於memcached1.4.15版本源碼,對其內存模型進行分析。
首先從業務需求出發。我們通過一條命令(如set)將一條鍵值對(key,value)插入memcached后,需要能夠做到:1、對該鍵值數據的高效索引;2、系統可能會頻繁的創建新數據和刪除舊數據,需要高效的內存管理;3、系統應該能夠自行刪除長期不使用的緩存數據。
關於問題1,memcached通過哈希表來對鍵值數據進行管理,具體的實現中采用鏈接法來處理hash沖突問題。(本文不考慮多線程中的加鎖問題和哈希表擴容問題)
關於問題2,最簡單的思路是來了新的數據就malloc內存,將新數據保存在這段新分配的內存中,當數據要被刪除時就把這段內存free掉。但是頻繁的malloc和free將會導致系統的內存碎片問題,加重系統內存管理的負擔。同時malloc和free作為系統調用,在時間方面也存在一定開銷。Memcached的解決方式是創建內存池來管理內存分配,具體實現思路是采用Slab Allocator作為內存分配器。
關於問題3,memcached給每個數據記錄過期時間,並將同一個slab class的所有數據通過LRU算法進行組織,當插入新數據時通過檢查LRU鏈表對超時的舊數據進行刪除。
主要數據結構介紹:
item:為鍵值數據的實際儲存結構。item主要由兩個部分組成,第一個部分是公共屬性部分,包括連接其它 item 的指針 (next,prev,h_next),還有最近訪問時間(time), 過期的時間(exptime)等,結構長度固定;第二部分是item的數據部分,由 CAS, key, suffix, value 組成,由於實際的鍵值數據長度不確定,因此該部分的結構長度不固定。
在此處采用了struct的空數組技巧,在公共數據最后定義了空數組data,該data指針本身並不占用任何存儲空間,指向數據部分的首地址。數據部分長度不確定,根據具體數據長度分配的內存大小。實際item結構的長度 = item中的屬性部分長度(固定) + 數據部分長度(不固定),因此不同item之間需要的內存大小是不一樣的。
Chunk:由申請的連續內存塊平均切分而成。比如申請的1M連續內存塊,可以被切分成11個88bytes的chunk內存小塊。Chunk是實際分配給item的內存空間。Memcached會維護多個不同大小的chunk內存塊。若某個item需要100bytes的內存空間,系統將會取出一個最接近且大於100bytes大小的chunk分配給該item作為內存空間。
上圖所示中,某item公共屬性部分需要48bytes內存空間,數據部分需要52bytes內存空間,該item一共需要100bytes連續內存空間。該memcached分別維護了88bytes、112 bytes、144 bytes和184 bytes大小的chunk塊群。最接近且大於該item所需內存大小的是size為112bytes的chunk塊。因此我們取出一個尚未被使用的112 bytes 的chunk塊,並將該item中的數據保存到該chunk的內存空間中。此時該chunk中將有12bytes剩余內存將作為碎片被暫時浪費。
Slab Class:管理特定大小的 chunk 的集合。Memcached每次默認分配的一個連續內存塊為1M大小,它們被切分為不同大小的chunk。但是不同chunk的需求量不同,有的情況下某些大小的chunk只需一個連續內存塊切分的數量即可滿足業務需要,但有的大小的chunk需求量比較大,需要分配更多的連續內存塊來進行切分。這些切分為相同大小的chunk塊群,都由對應的slab class進行管理。
通常memcached會指定一個最小的chunk大小,同時設置一個增長因子。系統依次創建管理隨增長因子增長且保持字節對齊的chunk大小的slab class。比如最小chunk大小為88bytes,增長因子為1.25,則系統將會分別創建管理88bytes大小、112bytes大小、144bytes大小的chunk的slab class。
slabclass的屬性說明。
typedef struct { unsigned int size; //該 slabclass 的 chunk 大小 unsigned int perslab; //表示每個 slab 可以切分成多少個 chunk,如果slab為1M,則perslab = 1M/size void *slots; //回收到的item鏈表 unsigned int sl_curr; //當前鏈表中有多少個回收而來的空閑chunk unsigned int slabs; //該class一共分配了多少chunk void **slab_list; //list數組用於維護chunk. unsigned int list_size; /* size of prev array */ unsigned int killing; /* index+1 of dying slab, or zero if none */ size_t requested; /* The number of requested bytes */ } slabclass_t;
內存初始化:
Memcached的內存初始化方式分為兩種,分別為預分配方式和按需分配方式。Memcached默認采用按需分配方式。
在預分配方式中,memcached會在啟動時通過malloc申請64M的連續內存(可配置),然后memcached根據初始chunk大小和增長因子創建管理不同chunk大小的slab class,每個slab class依次從之前申請的64M內存中獲取1個1M的連續內存塊,並將該內存塊切分為對應大小的chunk塊並進行管理,直到申請的內存用完為止。
下圖表示預分配方式下初始化時創建的前3個slab_class,每個slab class分配了一個1M的連續內存塊。其中slab_class1切分為了11915個每個大小為88bytes的chunk,slab_class2切分為了9362個每個大小為112bytes的chunk,slab_class3切分為了7281個每個大小為144bytes的chunk,更多slab_class以此類推(圖中每個slab class中只畫了4個chunk)。
我們以具體的size為88bytes的slab class為例進行說明。該slab class目前被分配了約1M的連續內存,這段內存被掛載在slab_list[0]上。這段內存被切分成了11915段(圖中只畫了4段),每段作為一個88bytes的chunk。每一個chunk又被item初始化,並通過slots指針作為表頭節點,與每個item的prev、next指針共同組成了空閑item雙向鏈表。初始化完后該slab class中存在11915個空閑的item。每當系統需要一個88bytes的chunk時,就通過表頭slots從鏈表中取出一個chunk即可。
通過預分配方式,每個slab class在初始化階段公平的分配到了約為1M的連續內存,讓系統在一開始每個slab都有chunk可供分配。但是實際業務中,不同大小的chunk使用頻率並不相同,有的slab中的chunk很快就被使用完畢,而有的slab中的chunk又長期未被使用,造成內存的浪費。因此Memcached默認采用的是按需分配的方式。
在按需分配的方式中,初始化階段Memcached只會為每個slab指定對應chunk大小,並不會給slab分配實際內存。
當有實際數據待儲存到該slab下的chunk時,Memcached首先會判斷該slab是否有過期的item待回收使用,如果沒有再判斷是否有空閑的chunk,如果還沒有,才會給該slab分配1M連續內存。這時slab將會對這塊連續內存進行切分chunk管理,並從中取出一個空閑chunk用於儲存數據。
Slab內存擴容:
在之前的預分配初始化中,我們給size為88bytes的slab class分配了一塊約為1M的連續內存塊,並將其切分為了11915個chunk。但是實際使用中,11915個chunk很可能是不夠使用的。如果原有chunk全部被使用后,又有新的數據需要88bytes的chunk內存空間。此時Memcached將會對該slab進行擴容操作。
(上圖中slab_list[0]中的chunk均未使用,並不會實際申請slab_list[1]的新連續內存塊)
在slab中存在一個初始大小為16的slab_list數組,用於管理連續內存塊。其中預分配的第一個連續內存塊被掛載在slab_list[0]上。當第一個連續內存塊中chunk不夠用時,Memcached將會再次給該slab分配一個大小約為1M的連續內存塊,並掛載在slab_list[1]上,並同樣將該段連續內存切分為11915個chunk,並將這些新chunk添加到該slab的空閑雙向鏈表中。此時該slab一共管理11915*2個chunk,其中新分配的11915個chunk為空閑chunk。
因為slab_list數組初始大小為16,理論上該slab可以掛載16個這樣的連續內存,每個連續內容可切分為11915個chunk,也就是slab能夠管理16*11915=190640個chunk。如果190640個chunk都不能滿足這個slab的chunk需求,那么Memcached將會對slab_list通過realloc進行擴容,每次擴容的大小為原slab_list的大小的2倍。一次slab_list擴容后,該數組大小為32,將可分配32*11915個chunk。只要系統內存足夠,通過slab_list的擴容和分配新的連續內存塊,每個slab class可以管理無數個大小相同的chunk。
哈希表:
Memcached的哈希表采用鏈接法實現。hashtable被分成多個桶bucket,每個item通過hash函數確定具體的bucket,然后鏈接到該bucket上,如果該桶中已存在鏈接的item(即出現了哈希沖突),則將這個item通過h_next指針形成該bucket下鏈接的單向鏈表。圖中,item A和item B都被哈希映射到了bucket[1]中,它們通過h_next組織為單向鏈表,且bucket[1]作為鏈表表頭。(可以參考STL中的unordered_map)
LRU鏈表:
Memcached中每個slab中都維護了一個LRU鏈表,來組織該slab中已經被分配的item塊,用於記錄“最近最少使用”的item信息。其中heads指向鏈表的頭節點,tails指向鏈表的尾節點。每當有新chunk被使用時,將會將該chunk的item添加到LRU鏈表頭。或者有原使用的item被修改,也會將其從鏈表中移動到LRU鏈表頭處。通過該機制,保證了鏈表頭部分的的item為新創建或新修改的數據,鏈表尾item為該slab中儲存最久的數據。
Memcached采用了惰性刪除的機制,系統不會主動監視item中數據是否過期,而是在get的時候查看該item的時間戳,如果已過期就刪除並將該chunk釋放到空閑鏈表中。
同時在新數據插入中,Memcached也會優先判斷該slab的LRU鏈表尾部的item節點是否超時,如果超時的話,Memcached也會優先刪除並使用已經超時的item的chunk作為新數據的儲存空間。
當該slab的LRU鏈表尾部item節點並未超時,但是slab中無可用chunk,且無法從系統中擴容到新的內存空間時,Memcached將會直接摘取LRU鏈表中的最后的item,強行刪除並將其空間分配給新的數據記錄。
Memcached可以配置為禁止使用LRU機制,這樣的話當該slab中chunk耗盡且分配不到新內存時將會返回錯誤。
插入數據流程:
在Memcached中插入數據主要分為以下幾個流程:
1.哈希查找是否存在相同鍵值
2.根據item大小選擇slab class
3.從過期item或空閑鏈表中分配item
4.加入LRU鏈表及哈希表
我們以插入一個名為Data1的鍵值數據進行說明。首先系統將根據Data1的key去查詢哈希表,是否存在相同的鍵值數據,如果數據相同,只執行更新操作;如果key相同但value不同,對原item執行刪除操作,並繼續執行。
data1 + item 部分所需內存 < 88bytes,因此我們選擇管理88bytes的slab class。首先判斷該slab的LRU鏈表尾部tails所指是否存在超時過期節點,此時LRU鏈表為空,tails指向null,並無過期節點。
然后再判斷該slab class中slots指針維護的空閑鏈表,此時空閑鏈表中存在空閑chunk。Memcached將空閑鏈表的頭節點chunk取出,並將Data1的數據保存到該chunk中。此時空閑chunk雙向鏈表如圖淺黑色指針部分。
接着通過hash映射確定該item對應於哈希表中bucket[1]中,因為之前該哈希表中bucket[1]已經掛載了2個數據item,因此我們將Data1的item添加到bucket[1]中的單鏈表的表頭。此時哈希表如圖紅色指針部分。
我們把該item加入該slab class的LRU鏈表,此時該item將作為該slab class第一個被使用的item。LRU鏈表如圖藍色指針部分。
最后我們修改該slab class中的屬性,因為一個chunk已經被使用,因此我們將該slab class中的sl_curr當前可用chunk修改為11914。
再添加一個具體數據
完成了上述插入操作后,我們再嘗試添加一個新的Data2數據,data2 + item 部分所需內存依舊小於88bytes。
首先依舊是進行哈希查找是否存在相同鍵值,略過不談。
同樣是選擇管理88bytes的slab class,判斷該slab的LRU鏈表尾部tails所指是否存在超時過期節點。此時LRU鏈表只有之前儲存Data1的item節點,tails即指向它,該item節點並未過期。
此時該slab class的空閑鏈表中依舊存在空閑chunk,我們再次從該slots維護的空閑雙向鏈表中取出表頭的88bytes的chunk,並將Data2的數據保存到該chunk中。
通過hash映射確定該item對應於哈希表中bucket[3]中,因為之前中bucket[3]中並未掛載任何item,因此我們將Data2的item添加到bucket[3]中的單鏈表的表頭。
我們把該item加入該slab class的LRU鏈表的表頭。此時Data2所對應的item位於LRU鏈表的第一個節點,Data1對應的item位於LRU鏈表的第二個節點。
最后將該slab class中的sl_curr當前可用chunk修改為11913。
刪除數據流程:
關於刪除數據item部分的操作大致為添加操作的逆操作,主要流程為:
1.通過哈希表獲取該鍵值數據item
2.從哈希表中移除該item節點
3.從LRU鏈表中移該item節點
4.清空item數據並將該chunk重新添加到空閑chunk鏈表
在此再不做具體分析。