您好,登錄后才能下訂單哦!
本文轉自互聯網
本系列文章將整理到我在GitHub上的《Java面試指南》倉庫,更多精彩內容請到我的倉庫里查看
https://github.com/h3pl/Java-Tutorial
喜歡的話麻煩點下Star哈
文章首發于我的個人博客:
www.how2playlife.com
本文是微信公眾號【Java技術江湖】的《探索Redis設計與實現》其中一篇,本文部分內容來源于網絡,為了把本文主題講得清晰透徹,也整合了很多我認為不錯的技術博客內容,引用其中了一些比較好的博客文章,如有侵權,請聯系作者。
該系列博文會告訴你如何從入門到進階,Redis基本的使用方法,Redis的基本數據結構,以及一些進階的使用方法,同時也需要進一步了解Redis的底層數據結構,再接著,還會帶來Redis主從復制、集群、分布式鎖等方面的相關內容,以及作為緩存的一些使用方法和注意事項,以便讓你更完整地了解整個Redis相關的技術體系,形成自己的知識框架。
如果對本系列文章有什么建議,或者是有什么疑問的話,也可以關注公眾號【Java技術江湖】聯系作者,歡迎你參與本系列博文的創作和修訂。
本文是《 Redis內部數據結構詳解》系列的第六篇。在本文中,我們圍繞一個Redis的內部數據結構——skiplist展開討論。
Redis里面使用skiplist是為了實現sorted set這種對外的數據結構。sorted set提供的操作非常豐富,可以滿足非常多的應用場景。這也意味著,sorted set相對來說實現比較復雜。同時,skiplist這種數據結構對于很多人來說都比較陌生,因為大部分學校里的算法課都沒有對這種數據結構進行過詳細的介紹。因此,為了介紹得足夠清楚,本文會比這個系列的其它幾篇花費更多的篇幅。
我們將大體分成三個部分進行介紹:
我們在討論中還會涉及到兩個Redis配置(在redis.conf中的ADVANCED CONFIG部分):
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
我們在討論中會詳細解釋這兩個配置的含義。
注:本文討論的代碼實現基于Redis源碼的3.2分支。
skiplist本質上也是一種查找結構,用于解決算法中的查找問題(Searching),即根據給定的key,快速查到它所在的位置(或者對應的value)。
我們在《Redis內部數據結構詳解》系列的 第一篇中介紹dict的時候,曾經討論過:一般查找問題的解法分為兩個大類:一個是基于各種平衡樹,一個是基于哈希表。但skiplist卻比較特殊,它沒法歸屬到這兩大類里面。
這種數據結構是由 William Pugh發明的,最早出現于他在1990年發表的論文《 Skip Lists: A Probabilistic Alternative to Balanced Trees》。對細節感興趣的同學可以下載論文原文來閱讀。
skiplist,顧名思義,首先它是一個list。實際上,它是在有序鏈表的基礎上發展起來的。
我們先來看一個有序鏈表,如下圖(最左側的灰色節點表示一個空的頭結點):
在這樣一個鏈表中,如果我們要查找某個數據,那么需要從頭開始逐個進行比較,直到找到包含數據的那個節點,或者找到第一個比給定數據大的節點為止(沒找到)。也就是說,時間復雜度為O(n)。同樣,當我們要插入新數據的時候,也要經歷同樣的查找過程,從而確定插入位置。
假如我們每相鄰兩個節點增加一個指針,讓指針指向下下個節點,如下圖:
這樣所有新增加的指針連成了一個新的鏈表,但它包含的節點個數只有原來的一半(上圖中是7, 19, 26)。現在當我們想查找數據的時候,可以先沿著這個新鏈表進行查找。當碰到比待查數據大的節點時,再回到原來的鏈表中進行查找。比如,我們想查找23,查找的路徑是沿著下圖中標紅的指針所指向的方向進行的:
在這個查找過程中,由于新增加的指針,我們不再需要與鏈表中每個節點逐個進行比較了。需要比較的節點數大概只有原來的一半。
利用同樣的方式,我們可以在上層新產生的鏈表上,繼續為每相鄰的兩個節點增加一個指針,從而產生第三層鏈表。如下圖:
在這個新的三層鏈表結構上,如果我們還是查找23,那么沿著最上層鏈表首先要比較的是19,發現23比19大,接下來我們就知道只需要到19的后面去繼續查找,從而一下子跳過了19前面的所有節點。可以想象,當鏈表足夠長的時候,這種多層鏈表的查找方式能讓我們跳過很多下層節點,大大加快查找的速度。
skiplist正是受這種多層鏈表的想法的啟發而設計出來的。實際上,按照上面生成鏈表的方式,上面每一層鏈表的節點個數,是下面一層的節點個數的一半,這樣查找過程就非常類似于一個二分查找,使得查找的時間復雜度可以降低到O(log n)。但是,這種方法在插入數據的時候有很大的問題。新插入一個節點之后,就會打亂上下相鄰兩層鏈表上節點個數嚴格的2:1的對應關系。如果要維持這種對應關系,就必須把新插入的節點后面的所有節點(也包括新插入的節點)重新進行調整,這會讓時間復雜度重新蛻化成O(n)。刪除數據也有同樣的問題。
skiplist為了避免這一問題,它不要求上下相鄰兩層鏈表之間的節點個數有嚴格的對應關系,而是為每個節點隨機出一個層數(level)。比如,一個節點隨機出的層數是3,那么就把它鏈入到第1層到第3層這三層鏈表中。為了表達清楚,下圖展示了如何通過一步步的插入操作從而形成一個skiplist的過程:
從上面skiplist的創建和插入過程可以看出,每一個節點的層數(level)是隨機出來的,而且新插入一個節點不會影響其它節點的層數。因此,插入操作只需要修改插入節點前后的指針,而不需要對很多節點都進行調整。這就降低了插入操作的復雜度。實際上,這是skiplist的一個很重要的特性,這讓它在插入性能上明顯優于平衡樹的方案。這在后面我們還會提到。
根據上圖中的skiplist結構,我們很容易理解這種數據結構的名字的由來。skiplist,翻譯成中文,可以翻譯成“跳表”或“跳躍表”,指的就是除了最下面第1層鏈表之外,它會產生若干層稀疏的鏈表,這些鏈表里面的指針故意跳過了一些節點(而且越高層的鏈表跳過的節點越多)。這就使得我們在查找數據的時候能夠先在高層的鏈表中進行查找,然后逐層降低,最終降到第1層鏈表來精確地確定數據位置。在這個過程中,我們跳過了一些節點,從而也就加快了查找速度。
剛剛創建的這個skiplist總共包含4層鏈表,現在假設我們在它里面依然查找23,下圖給出了查找路徑:
需要注意的是,前面演示的各個節點的插入過程,實際上在插入之前也要先經歷一個類似的查找過程,在確定插入位置后,再完成插入操作。
至此,skiplist的查找和插入操作,我們已經很清楚了。而刪除操作與插入操作類似,我們也很容易想象出來。這些操作我們也應該能很容易地用代碼實現出來。
當然,實際應用中的skiplist每個節點應該包含key和value兩部分。前面的描述中我們沒有具體區分key和value,但實際上列表中是按照key進行排序的,查找過程也是根據key在比較。
但是,如果你是第一次接觸skiplist,那么一定會產生一個疑問:節點插入時隨機出一個層數,僅僅依靠這樣一個簡單的隨機數操作而構建出來的多層鏈表結構,能保證它有一個良好的查找性能嗎?為了回答這個疑問,我們需要分析skiplist的統計性能。
在分析之前,我們還需要著重指出的是,執行插入操作時計算隨機數的過程,是一個很關鍵的過程,它對skiplist的統計特性有著很重要的影響。這并不是一個普通的服從均勻分布的隨機數,它的計算過程如下:
這個計算隨機層數的偽碼如下所示:
randomLevel()
level := 1
// random()返回一個[0...1)的隨機數
while random() < p and level < MaxLevel do
level := level + 1
return level
randomLevel()的偽碼中包含兩個參數,一個是p,一個是MaxLevel。在Redis的skiplist實現中,這兩個參數的取值為:
p = 1/4
MaxLevel = 32
在這一部分,我們來簡單分析一下skiplist的時間復雜度和空間復雜度,以便對于skiplist的性能有一個直觀的了解。如果你不是特別偏執于算法的性能分析,那么可以暫時跳過這一小節的內容。
我們先來計算一下每個節點所包含的平均指針數目(概率期望)。節點包含的指針數目,相當于這個算法在空間上的額外開銷(overhead),可以用來度量空間復雜度。
根據前面randomLevel()的偽碼,我們很容易看出,產生越高的節點層數,概率越低。定量的分析如下:
因此,一個節點的平均層數(也即包含的平均指針數目),計算如下:
現在很容易計算出:
接下來,為了分析時間復雜度,我們計算一下skiplist的平均查找長度。查找長度指的是查找路徑上跨越的跳數,而查找過程中的比較次數就等于查找長度加1。以前面圖中標出的查找23的查找路徑為例,從左上角的頭結點開始,一直到結點22,查找長度為6。
為了計算查找長度,這里我們需要利用一點小技巧。我們注意到,每個節點插入的時候,它的層數是由隨機函數randomLevel()計算出來的,而且隨機的計算不依賴于其它節點,每次插入過程都是完全獨立的。所以,從統計上來說,一個skiplist結構的形成與節點的插入順序無關。
這樣的話,為了計算查找長度,我們可以將查找過程倒過來看,從右下方第1層上最后到達的那個節點開始,沿著查找路徑向左向上回溯,類似于爬樓梯的過程。我們假設當回溯到某個節點的時候,它才被插入,這雖然相當于改變了節點的插入順序,但從統計上不影響整個skiplist的形成結構。
現在假設我們從一個層數為i的節點x出發,需要向左向上攀爬k層。這時我們有兩種可能:
這兩種情形如下圖所示:
用C(k)表示向上攀爬k個層級所需要走過的平均查找路徑長度(概率期望),那么:
C(0)=0
C(k)=(1-p)×(上圖中情況b的查找長度) + p×(上圖中情況c的查找長度)
代入,得到一個差分方程并化簡:
C(k)=(1-p)(C(k)+1) + p(C(k-1)+1)
C(k)=1/p+C(k-1)
C(k)=k/p
這個結果的意思是,我們每爬升1個層級,需要在查找路徑上走1/p步。而我們總共需要攀爬的層級數等于整個skiplist的總層數-1。
那么接下來我們需要分析一下當skiplist中有n個節點的時候,它的總層數的概率均值是多少。這個問題直觀上比較好理解。根據節點的層數隨機算法,容易得出:
所以,從第1層到最高層,各層鏈表的平均節點數是一個指數遞減的等比數列。容易推算出,總層數的均值為log 1/pn,而最高層的平均節點數為1/p。
綜上,粗略來計算的話,平均查找長度約等于:
即,平均時間復雜度為O(log n)。
當然,這里的時間復雜度分析還是比較粗略的。比如,沿著查找路徑向左向上回溯的時候,可能先到達左側頭結點,然后沿頭結點一路向上;還可能先到達最高層的節點,然后沿著最高層鏈表一路向左。但這些細節不影響平均時間復雜度的最后結果。另外,這里給出的時間復雜度只是一個概率平均值,但實際上計算一個精細的概率分布也是有可能的。詳情還請參見 William Pugh的論文《 Skip Lists: A Probabilistic Alternative to Balanced Trees》。
在這一部分,我們討論Redis中的skiplist實現。
在Redis中,skiplist被用于實現暴露給外部的一個數據結構:sorted set。準確地說,sorted set底層不僅僅使用了skiplist,還使用了ziplist和dict。這幾個數據結構的關系,我們下一章再討論。現在,我們先花點時間把sorted set的關鍵命令看一下。這些命令對于Redis里skiplist的實現,有重要的影響。
sorted set是一個有序的數據集合,對于像類似排行榜這樣的應用場景特別適合。
現在我們來看一個例子,用sorted set來存儲代數課(algebra)的成績表。原始數據如下:
這份數據給出了每位同學的名字和分數。下面我們將這份數據存儲到sorted set里面去:
對于上面的這些命令,我們需要的注意的地方包括:
總結一下,sorted set中的每個元素主要表現出3個屬性:
我們簡單分析一下前面出現的幾個查詢命令:
實際上,Redis中sorted set的實現是這樣的:
這里sorted set的構成我們在下一章還會再詳細地討論。現在我們集中精力來看一下sorted set與skiplist的關系,:
前述的查詢過程,也暗示了各個操作的時間復雜度:
總結起來,Redis中的skiplist跟前面介紹的經典的skiplist相比,有如下不同:
#define ZSKIPLIST_MAXLEVEL 32
#define ZSKIPLIST_P 0.25
typedef struct zskiplistNode {
robj *obj;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
這段代碼出自server.h,我們來簡要分析一下:
下圖以前面插入的代數課成績表為例,展示了Redis中一個skiplist的可能結構:
注意:圖中前向指針上面括號中的數字,表示對應的span的值。即當前指針跨越了多少個節點,這個計數不包括指針的起點節點,但包括指針的終點節點。
假設我們在這個skiplist中查找score=89.0的元素(即Bob的成績數據),在查找路徑中,我們會跨域圖中標紅的指針,這些指針上面的span值累加起來,就得到了Bob的排名(2+2+1)-1=4(減1是因為rank值以0起始)。需要注意這里算的是從小到大的排名,而如果要算從大到小的排名,只需要用skiplist長度減去查找路徑上的span累加值,即6-(2+2+1)=1。
可見,在查找skiplist的過程中,通過累加span值的方式,我們就能很容易算出排名。相反,如果指定排名來查找數據(類似zrange和zrevrange那樣),也可以不斷累加span并時刻保持累加值不超過指定的排名,通過這種方式就能得到一條O(log n)的查找路徑。
我們前面提到過,Redis中的sorted set,是在skiplist, dict和ziplist基礎上構建起來的:
在這里我們先來討論一下前一種情況——基于ziplist實現的sorted set。在本系列前面 關于ziplist的文章里,我們介紹過,ziplist就是由很多數據項組成的一大塊連續內存。由于sorted set的每一項元素都由數據和score組成,因此,當使用zadd命令插入一個(數據, score)對的時候,底層在相應的ziplist上就插入兩個數據項:數據在前,score在后。
ziplist的主要優點是節省內存,但它上面的查找操作只能按順序查找(可以正序也可以倒序)。因此,sorted set的各個查詢操作,就是在ziplist上從前向后(或從后向前)一步步查找,每一步前進兩個數據項,跨域一個(數據, score)對。
隨著數據的插入,sorted set底層的這個ziplist就可能會轉成zset的實現(轉換過程詳見t_zset.c的zsetConvert)。那么到底插入多少才會轉呢?
還記得本文開頭提到的兩個Redis配置嗎?
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
這個配置的意思是說,在如下兩個條件之一滿足的時候,ziplist會轉成zset(具體的觸發條件參見t_zset.c中的zaddGenericCommand相關代碼):
最后,zset結構的代碼定義如下:
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
在前面我們對于skiplist和平衡樹、哈希表的比較中,其實已經不難看出Redis里使用skiplist而不用平衡樹的原因了。現在我們看看,對于這個問題,Redis的作者 @antirez 是怎么說的:
There are a few reasons:
1) They are not very memory intensive. It’s up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.
2) A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.
3) They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.
這段話原文出處:
https://news.ycombinator.com/item?id=1171423
這里從內存占用、對范圍查找的支持和實現難易程度這三方面總結的原因,我們在前面其實也都涉及到了。
系列下一篇我們將介紹intset,以及它與Redis對外暴露的數據類型set的關系,敬請期待。
(完)
原創文章,轉載請注明出處,并包含下面的二維碼!否則拒絕轉載!
本文鏈接:
http://zhangtielei.com/posts/blog-redis-skiplist.html
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。