應用層:
String——字符串
Hash——字典
List——列表
Set——集合
Sorted Set——有序集合
中間層
redisobject
從Redis的使用者的角度來看,一個Redis節點包含多個database(非cluster模式下默認是16個,cluster模式下只能是1個),而一個database維護了從key space到object space的映射關系。這個映射關系的key是string類型,而value可以是多種數據類型,比如:string, list, hash等。我們可以看到,key的類型固定是string,而value可能的類型是多個。
而從Redis內部實現的角度來看,在前面第一篇文章中,我們已經提到過,一個database內的這個映射關系是用一個dict來維護的。dict的key固定用一種數據結構來表達就夠了,這就是動態字符串sds。而value則比較復雜,為了在同一個dict內能夠存儲不同類型的value,這就需要一個通用的數據結構,這個通用的數據結構就是robj(全名是redisObject)。舉個例子:如果value是一個list,那么它的內部存儲結構是一個quicklist(quicklist的具體實現我們放在后面的文章討論);如果value是一個string,那么它的內部存儲結構一般情況下是一個sds。當然實際情況更復雜一點,比如一個string類型的value,如果它的值是一個數字,那么Redis內部還會把它轉成long型來存儲,從而減小內存使用。而一個robj既能表示一個sds,也能表示一個quicklist,甚至還能表示一個long型。
底層
dict-hash
dict是一個用于維護key和value映射關系的數據結構,與很多語言中的Map或dictionary類似。Redis的一個database中所有key到value的映射,就是使用一個dict來維護的。不過,這只是它在Redis中的一個用途而已,它在Redis中被使用的地方還有很多。比如,一個Redis hash結構,當它的field較多時,便會采用dict來存儲。再比如,Redis配合使用dict和skiplist來共同維護一個sorted set。這些細節我們后面再討論,在本文中,我們集中精力討論dict本身的實現。
sds-string
一個sds字符串的完整結構,由在內存地址上前后相鄰的兩部分組成:
一個header。通常包含字符串的長度(len)、最大容量(alloc)和flags。sdshdr5有所不同。
一個字符數組。這個字符數組的長度等于最大容量+1。真正有效的字符串數據,其長度通常小于最大容量。在真正的字符串數據之后,是空余未用的字節(一般以字節0填充),允許在不重新分配內存的前提下讓字符串數據向后做有限的擴展。在真正的字符串數據之后,還有一個NULL結束符,即ASCII碼為0的’\0’字符。這是為了和傳統C字符串兼容。之所以字符數組的長度比最大容量多1個字節,就是為了在字符串長度達到最大容量時仍然有1個字節存放NULL結束符。
ziplist
翻譯一下就是說:ziplist是一個經過特殊編碼的雙向鏈表,它的設計目標就是為了提高存儲效率。ziplist可以用于存儲字符串或整數,其中整數是按真正的二進制表示進行編碼的,而不是編碼成字符串序列。它能以O(1)的時間復雜度在表的兩端提供push和pop操作。
實際上,ziplist充分體現了Redis對于存儲效率的追求。一個普通的雙向鏈表,鏈表中每一項都占用獨立的一塊內存,各項之間用地址指針(或引用)連接起來。這種方式會帶來大量的內存碎片,而且地址指針也會占用額外的內存。而ziplist卻是將表中每一項存放在前后連續的地址空間內,一個ziplist整體占用一大塊內存。它是一個表(list),但其實不是一個鏈表(linked list)。
quicklist
它確實是一個雙向鏈表,而且是一個ziplist的雙向鏈表。
這是什么意思呢?
我們知道,雙向鏈表是由多個節點(Node)組成的。這個描述的意思是:quicklist的每個節點都是一個ziplist。ziplist我們已經在上一篇介紹過。
ziplist本身也是一個能維持數據項先后順序的列表(按插入位置),而且是一個內存緊縮的列表(各個數據項在內存上前后相鄰)。比如,一個包含3個節點的quicklist,如果每個節點的ziplist又包含4個數據項,那么對外表現上,這個list就總共包含12個數據項。
quicklist的結構為什么這樣設計呢?總結起來,大概又是一個空間和時間的折中:
雙向鏈表便于在表的兩端進行push和pop操作,但是它的內存開銷比較大。首先,它在每個節點上除了要保存數據之外,還要額外保存兩個指針;其次,雙向鏈表的各個節點是單獨的內存塊,地址不連續,節點多了容易產生內存碎片。
ziplist由于是一整塊連續內存,所以存儲效率很高。但是,它不利于修改操作,每次數據變動都會引發一次內存的realloc。特別是當ziplist長度很長的時候,一次realloc可能會導致大批量的數據拷貝,進一步降低性能。
于是,結合了雙向鏈表和ziplist的優點,quicklist就應運而生了。
不過,這也帶來了一個新問題:到底一個quicklist節點包含多長的ziplist合適呢?比如,同樣是存儲12個數據項,既可以是一個quicklist包含3個節點,而每個節點的ziplist又包含4個數據項,也可以是一個quicklist包含6個節點,而每個節點的ziplist又包含2個數據項。
skiplist
這種數據結構是由William Pugh發明的,最早出現于他在1990年發表的論文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。對細節感興趣的同學可以下載論文原文來閱讀。
skiplist,顧名思義,首先它是一個list。實際上,它是在有序鏈表的基礎上發展起來的。
我們先來看一個有序鏈表,如下圖(最左側的灰色節點表示一個空的頭結點):
在這樣一個鏈表中,如果我們要查找某個數據,那么需要從頭開始逐個進行比較,直到找到包含數據的那個節點,或者找到第一個比給定數據大的節點為止(沒找到)。也就是說,時間復雜度為O(n)。同樣,當我們要插入新數據的時候,也要經歷同樣的查找過程,從而確定插入位置。
假如我們每相鄰兩個節點增加一個指針,讓指針指向下下個節點,如下圖:
這樣所有新增加的指針連成了一個新的鏈表,但它包含的節點個數只有原來的一半(上圖中是7, 19, 26)。現在當我們想查找數據的時候,可以先沿著這個新鏈表進行查找。當碰到比待查數據大的節點時,再回到原來的鏈表中進行查找。比如,我們想查找23,查找的路徑是沿著下圖中標紅的指針所指向的方向進行的:
23首先和7比較,再和19比較,比它們都大,繼續向后比較。
但23和26比較的時候,比26要小,因此回到下面的鏈表(原鏈表),與22比較。
23比22要大,沿下面的指針繼續向后和26比較。23比26小,說明待查數據23在原鏈表中不存在,而且它的插入位置應該在22和26之間。
在這個查找過程中,由于新增加的指針,我們不再需要與鏈表中每個節點逐個進行比較了。需要比較的節點數大概只有原來的一半。
利用同樣的方式,我們可以在上層新產生的鏈表上,繼續為每相鄰的兩個節點增加一個指針,從而產生第三層鏈表。如下圖:
在這個新的三層鏈表結構上,如果我們還是查找23,那么沿著最上層鏈表首先要比較的是19,發現23比19大,接下來我們就知道只需要到19的后面去繼續查找,從而一下子跳過了19前面的所有節點。可以想象,當鏈表足夠長的時候,這種多層鏈表的查找方式能讓我們跳過很多下層節點,大大加快查找的速度。
skiplist正是受這種多層鏈表的想法的啟發而設計出來的。實際上,按照上面生成鏈表的方式,上面每一層鏈表的節點個數,是下面一層的節點個數的一半,這樣查找過程就非常類似于一個二分查找,使得查找的時間復雜度可以降低到O(log n)。但是,這種方法在插入數據的時候有很大的問題。新插入一個節點之后,就會打亂上下相鄰兩層鏈表上節點個數嚴格的2:1的對應關系。如果要維持這種對應關系,就必須把新插入的節點后面的所有節點(也包括新插入的節點)重新進行調整,這會讓時間復雜度重新蛻化成O(n)。刪除數據也有同樣的問題。
skiplist為了避免這一問題,它不要求上下相鄰兩層鏈表之間的節點個數有嚴格的對應關系,而是為每個節點隨機出一個層數(level)。比如,一個節點隨機出的層數是3,那么就把它鏈入到第1層到第3層這三層鏈表中。為了表達清楚,下圖展示了如何通過一步步的插入操作從而形成一個skiplist的過程:
從上面skiplist的創建和插入過程可以看出,每一個節點的層數(level)是隨機出來的,而且新插入一個節點不會影響其它節點的層數。因此,插入操作只需要修改插入節點前后的指針,而不需要對很多節點都進行調整。這就降低了插入操作的復雜度。實際上,這是skiplist的一個很重要的特性,這讓它在插入性能上明顯優于平衡樹的方案。這在后面我們還會提到。
intset
encoding: 數據編碼,表示intset中的每個數據元素用幾個字節來存儲。它有三種可能的取值:INTSET_ENC_INT16表示每個元素用2個字節存儲,INTSET_ENC_INT32表示每個元素用4個字節存儲,INTSET_ENC_INT64表示每個元素用8個字節存儲。因此,intset中存儲的整數最多只能占用64bit。
length: 表示intset中的元素個數。encoding和length兩個字段構成了intset的頭部(header)。
contents: 是一個柔性數組(flexible array member),表示intset的header后面緊跟著數據元素。這個數組的總長度(即總字節數)等于encoding * length。柔性數組在Redis的很多數據結構的定義中都出現過(例如sds,quicklist,skiplist),用于表達一個偏移量。contents需要單獨為其分配空間,這部分內存不包含在intset結構當中。
其中需要注意的是,intset可能會隨著數據的添加而改變它的數據編碼:
最開始,新創建的intset使用占內存最小的INTSET_ENC_INT16(值為2)作為數據編碼。
每添加一個新元素,則根據元素大小決定是否對數據編碼進行升級。
下圖給出了一個添加數據的具體例子(點擊看大圖)。
在上圖中:
新創建的intset只有一個header,總共8個字節。其中encoding= 2,length= 0。
添加13, 5兩個元素之后,因為它們是比較小的整數,都能使用2個字節表示,所以encoding不變,值還是2。
當添加32768的時候,它不再能用2個字節來表示了(2個字節能表達的數據范圍是-215~215-1,而32768等于215,超出范圍了),因此encoding必須升級到INTSET_ENC_INT32(值為4),即用4個字節表示一個元素。
在添加每個元素的過程中,intset始終保持從小到大有序。
與ziplist類似,intset也是按小端(little endian)模式存儲的(參見維基百科詞條Endianness)。比如,在上圖中intset添加完所有數據之后,表示encoding字段的4個字節應該解釋成0x00000004,而第5個數據應該解釋成0x000186A0 = 100000。
intset與ziplist相比:
ziplist可以存儲任意二進制串,而intset只能存儲整數。
ziplist是無序的,而intset是從小到大有序的。因此,在ziplist上查找只能遍歷,而在intset上可以進行二分查找,性能更高。
ziplist可以對每個數據項進行不同的變長編碼(每個數據項前面都有數據長度字段len),而intset只能整體使用一個統一的編碼(encoding)。