Redis zset內部實現

Redis物件 Redis物件由redisObject結構體表示。

Redis中的每個鍵值對的鍵和值都是一個redisObject。 共有五種型別的物件:字串(String)、列表(List)、雜湊(Hash)、集合(Set)、有序集合(SortedSet),原始碼server。h如下定義:

每種型別的物件至少都有兩種或以上的編碼方式;可以在不同的使用場景上最佳化物件的使用場景。用TYPE命令可檢視某個鍵值對的型別

物件編碼 Redis目前使用的編碼方式:

本質上,Redis就是基於這些資料結構而構造出一個物件儲存系統。redisObject結構體有個ptr指標,指向物件的底層實現資料結構,encoding屬性記錄物件所使用的編碼,即該物件使用什麼資料結構作為底層實現。

zset介紹 有序集合物件的編碼可以是ziplist或者skiplist。同時滿足以下條件時使用ziplist編碼:

元素數量小於128個 所有member的長度都小於64位元組 以上兩個條件的上限值可透過zset-max-ziplist-entries和zset-max-ziplist-value來修改。

ziplist編碼的有序集合使用緊挨在一起的壓縮列表節點來儲存,第一個節點儲存member,第二個儲存score。ziplist內的集合元素按score從小到大排序,score較小的排在表頭位置。

skiplist編碼的有序集合底層是一個命名為zset的結構體,而一個zset結構同時包含一個字典和一個跳躍表。跳躍表按score從小到大儲存所有集合元素。而字典則儲存著從member到score的對映,這樣就可以用O(1)的複雜度來查詢member對應的score值。雖然同時使用兩種結構,但它們會透過指標來共享相同元素的member和score,因此不會浪費額外的記憶體。

skiplist介紹 跳錶(skip List)是一種隨機化的資料結構,基於並聯的連結串列,實現簡單,插入、刪除、查詢的複雜度均為O(logN)。簡單說來跳錶也是連結串列的一種,只不過它在連結串列的基礎上增加了跳躍功能,正是這個跳躍的功能,使得在查詢元素時,跳錶能夠提供O(logN)的時間複雜度。

先來看一個有序連結串列,如下圖(最左側的灰色節點表示一個空的頭結點):

Redis zset內部實現

在這樣一個連結串列中,如果我們要查詢某個資料,那麼需要從頭開始逐個進行比較,直到找到包含資料的那個節點,或者找到第一個比給定資料大的節點為止(沒找到)。也就是說,時間複雜度為O(n)。同樣,當我們要插入新資料的時候,也要經歷同樣的查詢過程,從而確定插入位置。

假如我們每相鄰兩個節點增加一個指標,讓指標指向下下個節點,如下圖:

Redis zset內部實現

這樣所有新增加的指標連成了一個新的連結串列,但它包含的節點個數只有原來的一半(上圖中是7, 19, 26)。現在當我們想查詢資料的時候,可以先沿著這個新連結串列進行查詢。當碰到比待查資料大的節點時,再回到原來的連結串列中進行查詢。比如,我們想查詢23,查詢的路徑是沿著下圖中標紅的指標所指向的方向進行的:

Redis zset內部實現

23首先和7比較,再和19比較,比它們都大,繼續向後比較。

但23和26比較的時候,比26要小,因此回到下面的連結串列(原連結串列),與22比較。

23比22要大,沿下面的指標繼續向後和26比較。23比26小,說明待查資料23在原連結串列中不存在,而且它的插入位置應該在22和26之間。 在這個查詢過程中,由於新增加的指標,我們不再需要與連結串列中每個節點逐個進行比較了。需要比較的節點數大概只有原來的一半。

利用同樣的方式,我們可以在上層新產生的連結串列上,繼續為每相鄰的兩個節點增加一個指標,從而產生第三層連結串列。如下圖:

Redis zset內部實現

在這個新的三層連結串列結構上,如果我們還是查詢23,那麼沿著最上層連結串列首先要比較的是19,發現23比19大,接下來我們就知道只需要到19的後面去繼續查詢,從而一下子跳過了19前面的所有節點。可以想象,當連結串列足夠長的時候,這種多層連結串列的查詢方式能讓我們跳過很多下層節點,大大加快查詢的速度。

skiplist正是受這種多層連結串列的想法的啟發而設計出來的。實際上,按照上面生成連結串列的方式,上面每一層連結串列的節點個數,是下面一層的節點個數的一半,這樣查詢過程就非常類似於一個二分查詢,使得查詢的時間複雜度可以降低到O(log n)。但是,這種方法在插入資料的時候有很大的問題。新插入一個節點之後,就會打亂上下相鄰兩層連結串列上節點個數嚴格的2:1的對應關係。如果要維持這種對應關係,就必須把新插入的節點後面的所有節點(也包括新插入的節點)重新進行調整,這會讓時間複雜度重新蛻化成O(n)。刪除資料也有同樣的問題。

skiplist為了避免這一問題,它不要求上下相鄰兩層連結串列之間的節點個數有嚴格的對應關係,而是為每個節點隨機出一個層數(level)。比如,一個節點隨機出的層數是3,那麼就把它鏈入到第1層到第3層這三層連結串列中。為了表達清楚,下圖展示瞭如何透過一步步的插入操作從而形成一個skiplist的過程:

Redis zset內部實現

從上面skiplist的建立和插入過程可以看出,每一個節點的層數(level)是隨機出來的,而且新插入一個節點不會影響其它節點的層數。因此,插入操作只需要修改插入節點前後的指標,而不需要對很多節點都進行調整。這就降低了插入操作的複雜度。實際上,這是skiplist的一個很重要的特性,這讓它在插入效能上明顯優於平衡樹的方案。這在後面我們還會提到。

skiplist,指的就是除了最下面第1層連結串列之外,它會產生若干層稀疏的連結串列,這些連結串列裡面的指標故意跳過了一些節點(而且越高層的連結串列跳過的節點越多)。這就使得我們在查詢資料的時候能夠先在高層的連結串列中進行查詢,然後逐層降低,最終降到第1層連結串列來精確地確定資料位置。在這個過程中,我們跳過了一些節點,從而也就加快了查詢速度。

剛剛建立的這個skiplist總共包含4層連結串列,現在假設我們在它裡面依然查詢23,下圖給出了查詢路徑:

Redis zset內部實現

需要注意的是,前面演示的各個節點的插入過程,實際上在插入之前也要先經歷一個類似的查詢過程,在確定插入位置後,再完成插入操作。

實際應用中的skiplist每個節點應該包含key和value兩部分。前面的描述中我們沒有具體區分key和value,但實際上列表中是按照key(score)進行排序的,查詢過程也是根據key在比較。

執行插入操作時計算隨機數的過程,是一個很關鍵的過程,它對skiplist的統計特性有著很重要的影響。這並不是一個普通的服從均勻分佈的隨機數,它的計算過程如下:

首先,每個節點肯定都有第1層指標(每個節點都在第1層連結串列裡)。 如果一個節點有第i層(i>=1)指標(即節點已經在第1層到第i層連結串列中),那麼它有第(i+1)層指標的機率為p。 節點最大的層數不允許超過一個最大值,記為MaxLevel。

skiplist與平衡樹、雜湊表的比較 skiplist和各種平衡樹(如AVL、紅黑樹等)的元素是有序排列的,而雜湊表不是有序的。因此,在雜湊表上只能做單個key的查詢,不適宜做範圍查詢。所謂範圍查詢,指的是查詢那些大小在指定的兩個值之間的所有節點。 在做範圍查詢的時候,平衡樹比skiplist操作要複雜。在平衡樹上,我們找到指定範圍的小值之後,還需要以中序遍歷的順序繼續尋找其它不超過大值的節點。如果不對平衡樹進行一定的改造,這裡的中序遍歷並不容易實現。而在skiplist上進行範圍查詢就非常簡單,只需要在找到小值之後,對第1層連結串列進行若干步的遍歷就可以實現。 平衡樹的插入和刪除操作可能引發子樹的調整,邏輯複雜,而skiplist的插入和刪除只需要修改相鄰節點的指標,操作簡單又快速。 從記憶體佔用上來說,skiplist比平衡樹更靈活一些。一般來說,平衡樹每個節點包含2個指標(分別指向左右子樹),而skiplist每個節點包含的指標數目平均為1/(1-p),具體取決於引數p的大小。如果像Redis裡的實現一樣,取p=1/4,那麼平均每個節點包含1。33個指標,比平衡樹更有優勢。 查詢單個key,skiplist和平衡樹的時間複雜度都為O(log n),大體相當;而雜湊表在保持較低的雜湊值衝突機率的前提下,查詢時間複雜度接近O(1),效能更高一些。所以我們平常使用的各種Map或dictionary結構,大都是基於雜湊表實現的。 從演算法實現難度上來比較,skiplist比平衡樹要簡單得多。