Redis 为什么用跳表而不用平衡树
本文是《Redis內(nèi)部數(shù)據(jù)結(jié)構(gòu)詳解》系列的第六篇。在本文中,我們圍繞一個Redis的內(nèi)部數(shù)據(jù)結(jié)構(gòu)——skiplist展開討論。
Redis里面使用skiplist是為了實現(xiàn)sorted set這種對外的數(shù)據(jù)結(jié)構(gòu)。sorted set提供的操作非常豐富,可以滿足非常多的應(yīng)用場景。這也意味著,sorted set相對來說實現(xiàn)比較復(fù)雜。同時,skiplist這種數(shù)據(jù)結(jié)構(gòu)對于很多人來說都比較陌生,因為大部分學(xué)校里的算法課都沒有對這種數(shù)據(jù)結(jié)構(gòu)進(jìn)行過詳細(xì)的介紹。因此,為了介紹得足夠清楚,本文會比這個系列的其它幾篇花費更多的篇幅。
我們將大體分成三個部分進(jìn)行介紹:
我們在討論中還會涉及到兩個Redis配置(在redis.conf中的ADVANCED CONFIG部分):
zset-max-ziplist-entries 128 zset-max-ziplist-value 64我們在討論中會詳細(xì)解釋這兩個配置的含義。
注:本文討論的代碼實現(xiàn)基于Redis源碼的3.2分支。
skiplist數(shù)據(jù)結(jié)構(gòu)簡介
skiplist本質(zhì)上也是一種查找結(jié)構(gòu),用于解決算法中的查找問題(Searching),即根據(jù)給定的key,快速查到它所在的位置(或者對應(yīng)的value)。
我們在《Redis內(nèi)部數(shù)據(jù)結(jié)構(gòu)詳解》系列的第一篇中介紹dict的時候,曾經(jīng)討論過:一般查找問題的解法分為兩個大類:一個是基于各種平衡樹,一個是基于哈希表。但skiplist卻比較特殊,它沒法歸屬到這兩大類里面。
這種數(shù)據(jù)結(jié)構(gòu)是由William Pugh發(fā)明的,最早出現(xiàn)于他在1990年發(fā)表的論文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。對細(xì)節(jié)感興趣的同學(xué)可以下載論文原文來閱讀。
skiplist,顧名思義,首先它是一個list。實際上,它是在有序鏈表的基礎(chǔ)上發(fā)展起來的。
我們先來看一個有序鏈表,如下圖(最左側(cè)的灰色節(jié)點表示一個空的頭結(jié)點):
在這樣一個鏈表中,如果我們要查找某個數(shù)據(jù),那么需要從頭開始逐個進(jìn)行比較,直到找到包含數(shù)據(jù)的那個節(jié)點,或者找到第一個比給定數(shù)據(jù)大的節(jié)點為止(沒找到)。也就是說,時間復(fù)雜度為O(n)。同樣,當(dāng)我們要插入新數(shù)據(jù)的時候,也要經(jīng)歷同樣的查找過程,從而確定插入位置。
假如我們每相鄰兩個節(jié)點增加一個指針,讓指針指向下下個節(jié)點,如下圖:
這樣所有新增加的指針連成了一個新的鏈表,但它包含的節(jié)點個數(shù)只有原來的一半(上圖中是7, 19, 26)。現(xiàn)在當(dāng)我們想查找數(shù)據(jù)的時候,可以先沿著這個新鏈表進(jìn)行查找。當(dāng)碰到比待查數(shù)據(jù)大的節(jié)點時,再回到原來的鏈表中進(jìn)行查找。比如,我們想查找23,查找的路徑是沿著下圖中標(biāo)紅的指針?biāo)赶虻姆较蜻M(jìn)行的:
- 23首先和7比較,再和19比較,比它們都大,繼續(xù)向后比較。
- 但23和26比較的時候,比26要小,因此回到下面的鏈表(原鏈表),與22比較。
- 23比22要大,沿下面的指針繼續(xù)向后和26比較。23比26小,說明待查數(shù)據(jù)23在原鏈表中不存在,而且它的插入位置應(yīng)該在22和26之間。
在這個查找過程中,由于新增加的指針,我們不再需要與鏈表中每個節(jié)點逐個進(jìn)行比較了。需要比較的節(jié)點數(shù)大概只有原來的一半。
利用同樣的方式,我們可以在上層新產(chǎn)生的鏈表上,繼續(xù)為每相鄰的兩個節(jié)點增加一個指針,從而產(chǎn)生第三層鏈表。如下圖:
在這個新的三層鏈表結(jié)構(gòu)上,如果我們還是查找23,那么沿著最上層鏈表首先要比較的是19,發(fā)現(xiàn)23比19大,接下來我們就知道只需要到19的后面去繼續(xù)查找,從而一下子跳過了19前面的所有節(jié)點。可以想象,當(dāng)鏈表足夠長的時候,這種多層鏈表的查找方式能讓我們跳過很多下層節(jié)點,大大加快查找的速度。
skiplist正是受這種多層鏈表的想法的啟發(fā)而設(shè)計出來的。實際上,按照上面生成鏈表的方式,上面每一層鏈表的節(jié)點個數(shù),是下面一層的節(jié)點個數(shù)的一半,這樣查找過程就非常類似于一個二分查找,使得查找的時間復(fù)雜度可以降低到O(log n)。但是,這種方法在插入數(shù)據(jù)的時候有很大的問題。新插入一個節(jié)點之后,就會打亂上下相鄰兩層鏈表上節(jié)點個數(shù)嚴(yán)格的2:1的對應(yīng)關(guān)系。如果要維持這種對應(yīng)關(guān)系,就必須把新插入的節(jié)點后面的所有節(jié)點(也包括新插入的節(jié)點)重新進(jìn)行調(diào)整,這會讓時間復(fù)雜度重新蛻化成O(n)。刪除數(shù)據(jù)也有同樣的問題。
skiplist為了避免這一問題,它不要求上下相鄰兩層鏈表之間的節(jié)點個數(shù)有嚴(yán)格的對應(yīng)關(guān)系,而是為每個節(jié)點隨機出一個層數(shù)(level)。比如,一個節(jié)點隨機出的層數(shù)是3,那么就把它鏈入到第1層到第3層這三層鏈表中。為了表達(dá)清楚,下圖展示了如何通過一步步的插入操作從而形成一個skiplist的過程(點擊看大圖):
從上面skiplist的創(chuàng)建和插入過程可以看出,每一個節(jié)點的層數(shù)(level)是隨機出來的,而且新插入一個節(jié)點不會影響其它節(jié)點的層數(shù)。因此,插入操作只需要修改插入節(jié)點前后的指針,而不需要對很多節(jié)點都進(jìn)行調(diào)整。這就降低了插入操作的復(fù)雜度。實際上,這是skiplist的一個很重要的特性,這讓它在插入性能上明顯優(yōu)于平衡樹的方案。這在后面我們還會提到。
根據(jù)上圖中的skiplist結(jié)構(gòu),我們很容易理解這種數(shù)據(jù)結(jié)構(gòu)的名字的由來。skiplist,翻譯成中文,可以翻譯成“跳表”或“跳躍表”,指的就是除了最下面第1層鏈表之外,它會產(chǎn)生若干層稀疏的鏈表,這些鏈表里面的指針故意跳過了一些節(jié)點(而且越高層的鏈表跳過的節(jié)點越多)。這就使得我們在查找數(shù)據(jù)的時候能夠先在高層的鏈表中進(jìn)行查找,然后逐層降低,最終降到第1層鏈表來精確地確定數(shù)據(jù)位置。在這個過程中,我們跳過了一些節(jié)點,從而也就加快了查找速度。
剛剛創(chuàng)建的這個skiplist總共包含4層鏈表,現(xiàn)在假設(shè)我們在它里面依然查找23,下圖給出了查找路徑:
需要注意的是,前面演示的各個節(jié)點的插入過程,實際上在插入之前也要先經(jīng)歷一個類似的查找過程,在確定插入位置后,再完成插入操作。
至此,skiplist的查找和插入操作,我們已經(jīng)很清楚了。而刪除操作與插入操作類似,我們也很容易想象出來。這些操作我們也應(yīng)該能很容易地用代碼實現(xiàn)出來。
當(dāng)然,實際應(yīng)用中的skiplist每個節(jié)點應(yīng)該包含key和value兩部分。前面的描述中我們沒有具體區(qū)分key和value,但實際上列表中是按照key進(jìn)行排序的,查找過程也是根據(jù)key在比較。
但是,如果你是第一次接觸skiplist,那么一定會產(chǎn)生一個疑問:節(jié)點插入時隨機出一個層數(shù),僅僅依靠這樣一個簡單的隨機數(shù)操作而構(gòu)建出來的多層鏈表結(jié)構(gòu),能保證它有一個良好的查找性能嗎?為了回答這個疑問,我們需要分析skiplist的統(tǒng)計性能。
在分析之前,我們還需要著重指出的是,執(zhí)行插入操作時計算隨機數(shù)的過程,是一個很關(guān)鍵的過程,它對skiplist的統(tǒng)計特性有著很重要的影響。這并不是一個普通的服從均勻分布的隨機數(shù),它的計算過程如下:
- 首先,每個節(jié)點肯定都有第1層指針(每個節(jié)點都在第1層鏈表里)。
- 如果一個節(jié)點有第i層(i>=1)指針(即節(jié)點已經(jīng)在第1層到第i層鏈表中),那么它有第(i+1)層指針的概率為p。
- 節(jié)點最大的層數(shù)不允許超過一個最大值,記為MaxLevel。
這個計算隨機層數(shù)的偽碼如下所示:
randomLevel()level := 1// random()返回一個[0...1)的隨機數(shù)while random() < p and level < MaxLevel dolevel := level + 1return levelrandomLevel()的偽碼中包含兩個參數(shù),一個是p,一個是MaxLevel。在Redis的skiplist實現(xiàn)中,這兩個參數(shù)的取值為:
p = 1/4 MaxLevel = 32skiplist的算法性能分析
在這一部分,我們來簡單分析一下skiplist的時間復(fù)雜度和空間復(fù)雜度,以便對于skiplist的性能有一個直觀的了解。如果你不是特別偏執(zhí)于算法的性能分析,那么可以暫時跳過這一小節(jié)的內(nèi)容。
我們先來計算一下每個節(jié)點所包含的平均指針數(shù)目(概率期望)。節(jié)點包含的指針數(shù)目,相當(dāng)于這個算法在空間上的額外開銷(overhead),可以用來度量空間復(fù)雜度。
根據(jù)前面randomLevel()的偽碼,我們很容易看出,產(chǎn)生越高的節(jié)點層數(shù),概率越低。定量的分析如下:
- 節(jié)點層數(shù)至少為1。而大于1的節(jié)點層數(shù),滿足一個概率分布。
- 節(jié)點層數(shù)恰好等于1的概率為1-p。
- 節(jié)點層數(shù)大于等于2的概率為p,而節(jié)點層數(shù)恰好等于2的概率為p(1-p)。
- 節(jié)點層數(shù)大于等于3的概率為p2,而節(jié)點層數(shù)恰好等于3的概率為p2(1-p)。
- 節(jié)點層數(shù)大于等于4的概率為p3,而節(jié)點層數(shù)恰好等于4的概率為p3(1-p)。
- ......
因此,一個節(jié)點的平均層數(shù)(也即包含的平均指針數(shù)目),計算如下:
現(xiàn)在很容易計算出:
- 當(dāng)p=1/2時,每個節(jié)點所包含的平均指針數(shù)目為2;
- 當(dāng)p=1/4時,每個節(jié)點所包含的平均指針數(shù)目為1.33。這也是Redis里的skiplist實現(xiàn)在空間上的開銷。
接下來,為了分析時間復(fù)雜度,我們計算一下skiplist的平均查找長度。查找長度指的是查找路徑上跨越的跳數(shù),而查找過程中的比較次數(shù)就等于查找長度加1。以前面圖中標(biāo)出的查找23的查找路徑為例,從左上角的頭結(jié)點開始,一直到結(jié)點22,查找長度為6。
為了計算查找長度,這里我們需要利用一點小技巧。我們注意到,每個節(jié)點插入的時候,它的層數(shù)是由隨機函數(shù)randomLevel()計算出來的,而且隨機的計算不依賴于其它節(jié)點,每次插入過程都是完全獨立的。所以,從統(tǒng)計上來說,一個skiplist結(jié)構(gòu)的形成與節(jié)點的插入順序無關(guān)。
這樣的話,為了計算查找長度,我們可以將查找過程倒過來看,從右下方第1層上最后到達(dá)的那個節(jié)點開始,沿著查找路徑向左向上回溯,類似于爬樓梯的過程。我們假設(shè)當(dāng)回溯到某個節(jié)點的時候,它才被插入,這雖然相當(dāng)于改變了節(jié)點的插入順序,但從統(tǒng)計上不影響整個skiplist的形成結(jié)構(gòu)。
現(xiàn)在假設(shè)我們從一個層數(shù)為i的節(jié)點x出發(fā),需要向左向上攀爬k層。這時我們有兩種可能:
- 如果節(jié)點x有第(i+1)層指針,那么我們需要向上走。這種情況概率為p。
- 如果節(jié)點x沒有第(i+1)層指針,那么我們需要向左走。這種情況概率為(1-p)。
這兩種情形如下圖所示:
用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這個結(jié)果的意思是,我們每爬升1個層級,需要在查找路徑上走1/p步。而我們總共需要攀爬的層級數(shù)等于整個skiplist的總層數(shù)-1。
那么接下來我們需要分析一下當(dāng)skiplist中有n個節(jié)點的時候,它的總層數(shù)的概率均值是多少。這個問題直觀上比較好理解。根據(jù)節(jié)點的層數(shù)隨機算法,容易得出:
- 第1層鏈表固定有n個節(jié)點;
- 第2層鏈表平均有n*p個節(jié)點;
- 第3層鏈表平均有n*p2個節(jié)點;
- ...
所以,從第1層到最高層,各層鏈表的平均節(jié)點數(shù)是一個指數(shù)遞減的等比數(shù)列。容易推算出,總層數(shù)的均值為log1/pn,而最高層的平均節(jié)點數(shù)為1/p。
綜上,粗略來計算的話,平均查找長度約等于:
- C(log1/pn-1)=(log1/pn-1)/p
即,平均時間復(fù)雜度為O(log n)。
當(dāng)然,這里的時間復(fù)雜度分析還是比較粗略的。比如,沿著查找路徑向左向上回溯的時候,可能先到達(dá)左側(cè)頭結(jié)點,然后沿頭結(jié)點一路向上;還可能先到達(dá)最高層的節(jié)點,然后沿著最高層鏈表一路向左。但這些細(xì)節(jié)不影響平均時間復(fù)雜度的最后結(jié)果。另外,這里給出的時間復(fù)雜度只是一個概率平均值,但實際上計算一個精細(xì)的概率分布也是有可能的。詳情還請參見William Pugh的論文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。
skiplist與平衡樹、哈希表的比較
- skiplist和各種平衡樹(如AVL、紅黑樹等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做單個key的查找,不適宜做范圍查找。所謂范圍查找,指的是查找那些大小在指定的兩個值之間的所有節(jié)點。
- 在做范圍查找的時候,平衡樹比skiplist操作要復(fù)雜。在平衡樹上,我們找到指定范圍的小值之后,還需要以中序遍歷的順序繼續(xù)尋找其它不超過大值的節(jié)點。如果不對平衡樹進(jìn)行一定的改造,這里的中序遍歷并不容易實現(xiàn)。而在skiplist上進(jìn)行范圍查找就非常簡單,只需要在找到小值之后,對第1層鏈表進(jìn)行若干步的遍歷就可以實現(xiàn)。
- 平衡樹的插入和刪除操作可能引發(fā)子樹的調(diào)整,邏輯復(fù)雜,而skiplist的插入和刪除只需要修改相鄰節(jié)點的指針,操作簡單又快速。
- 從內(nèi)存占用上來說,skiplist比平衡樹更靈活一些。一般來說,平衡樹每個節(jié)點包含2個指針(分別指向左右子樹),而skiplist每個節(jié)點包含的指針數(shù)目平均為1/(1-p),具體取決于參數(shù)p的大小。如果像Redis里的實現(xiàn)一樣,取p=1/4,那么平均每個節(jié)點包含1.33個指針,比平衡樹更有優(yōu)勢。
- 查找單個key,skiplist和平衡樹的時間復(fù)雜度都為O(log n),大體相當(dāng);而哈希表在保持較低的哈希值沖突概率的前提下,查找時間復(fù)雜度接近O(1),性能更高一些。所以我們平常使用的各種Map或dictionary結(jié)構(gòu),大都是基于哈希表實現(xiàn)的。
- 從算法實現(xiàn)難度上來比較,skiplist比平衡樹要簡單得多。
Redis中的skiplist實現(xiàn)
在這一部分,我們討論Redis中的skiplist實現(xiàn)。
在Redis中,skiplist被用于實現(xiàn)暴露給外部的一個數(shù)據(jù)結(jié)構(gòu):sorted set。準(zhǔn)確地說,sorted set底層不僅僅使用了skiplist,還使用了ziplist和dict。這幾個數(shù)據(jù)結(jié)構(gòu)的關(guān)系,我們下一章再討論。現(xiàn)在,我們先花點時間把sorted set的關(guān)鍵命令看一下。這些命令對于Redis里skiplist的實現(xiàn),有重要的影響。
sorted set的命令舉例
sorted set是一個有序的數(shù)據(jù)集合,對于像類似排行榜這樣的應(yīng)用場景特別適合。
現(xiàn)在我們來看一個例子,用sorted set來存儲代數(shù)課(algebra)的成績表。原始數(shù)據(jù)如下:
- Alice 87.5
- Bob 89.0
- Charles 65.5
- David 78.0
- Emily 93.5
- Fred 87.5
這份數(shù)據(jù)給出了每位同學(xué)的名字和分?jǐn)?shù)。下面我們將這份數(shù)據(jù)存儲到sorted set里面去:
對于上面的這些命令,我們需要的注意的地方包括:
- 前面的6個zadd命令,將6位同學(xué)的名字和分?jǐn)?shù)(score)都輸入到一個key值為algebra的sorted set里面了。注意Alice和Fred的分?jǐn)?shù)相同,都是87.5分。
- zrevrank命令查詢Alice的排名(命令中的rev表示按照倒序排列,也就是從大到小),返回3。排在Alice前面的分別是Emily、Bob、Fred,而排名(rank)從0開始計數(shù),所以Alice的排名是3。注意,其實Alice和Fred的分?jǐn)?shù)相同,這種情況下sorted set會把分?jǐn)?shù)相同的元素,按照字典順序來排列。按照倒序,Fred排在了Alice的前面。
- zscore命令查詢了Charles對應(yīng)的分?jǐn)?shù)。
- zrevrange命令查詢了從大到小排名為0~3的4位同學(xué)。
- zrevrangebyscore命令查詢了分?jǐn)?shù)在80.0和90.0之間的所有同學(xué),并按分?jǐn)?shù)從大到小排列。
總結(jié)一下,sorted set中的每個元素主要表現(xiàn)出3個屬性:
- 數(shù)據(jù)本身(在前面的例子中我們把名字存成了數(shù)據(jù))。
- 每個數(shù)據(jù)對應(yīng)一個分?jǐn)?shù)(score)。
- 根據(jù)分?jǐn)?shù)大小和數(shù)據(jù)本身的字典排序,每個數(shù)據(jù)會產(chǎn)生一個排名(rank)。可以按正序或倒序。
Redis中skiplist實現(xiàn)的特殊性
我們簡單分析一下前面出現(xiàn)的幾個查詢命令:
- zrevrank由數(shù)據(jù)查詢它對應(yīng)的排名,這在前面介紹的skiplist中并不支持。
- zscore由數(shù)據(jù)查詢它對應(yīng)的分?jǐn)?shù),這也不是skiplist所支持的。
- zrevrange根據(jù)一個排名范圍,查詢排名在這個范圍內(nèi)的數(shù)據(jù)。這在前面介紹的skiplist中也不支持。
- zrevrangebyscore根據(jù)分?jǐn)?shù)區(qū)間查詢數(shù)據(jù)集合,是一個skiplist所支持的典型的范圍查找(score相當(dāng)于key)。
實際上,Redis中sorted set的實現(xiàn)是這樣的:
- 當(dāng)數(shù)據(jù)較少時,sorted set是由一個ziplist來實現(xiàn)的。
- 當(dāng)數(shù)據(jù)多的時候,sorted set是由一個dict + 一個skiplist來實現(xiàn)的。簡單來講,dict用來查詢數(shù)據(jù)到分?jǐn)?shù)的對應(yīng)關(guān)系,而skiplist用來根據(jù)分?jǐn)?shù)查詢數(shù)據(jù)(可能是范圍查找)。
這里sorted set的構(gòu)成我們在下一章還會再詳細(xì)地討論。現(xiàn)在我們集中精力來看一下sorted set與skiplist的關(guān)系,:
- zscore的查詢,不是由skiplist來提供的,而是由那個dict來提供的。
- 為了支持排名(rank),Redis里對skiplist做了擴展,使得根據(jù)排名能夠快速查到數(shù)據(jù),或者根據(jù)分?jǐn)?shù)查到數(shù)據(jù)之后,也同時很容易獲得排名。而且,根據(jù)排名的查找,時間復(fù)雜度也為O(log n)。
- zrevrange的查詢,是根據(jù)排名查數(shù)據(jù),由擴展后的skiplist來提供。
- zrevrank是先在dict中由數(shù)據(jù)查到分?jǐn)?shù),再拿分?jǐn)?shù)到skiplist中去查找,查到后也同時獲得了排名。
前述的查詢過程,也暗示了各個操作的時間復(fù)雜度:
- zscore只用查詢一個dict,所以時間復(fù)雜度為O(1)
- zrevrank, zrevrange, zrevrangebyscore由于要查詢skiplist,所以zrevrank的時間復(fù)雜度為O(log n),而zrevrange, zrevrangebyscore的時間復(fù)雜度為O(log(n)+M),其中M是當(dāng)前查詢返回的元素個數(shù)。
總結(jié)起來,Redis中的skiplist跟前面介紹的經(jīng)典的skiplist相比,有如下不同:
- 分?jǐn)?shù)(score)允許重復(fù),即skiplist的key允許重復(fù)。這在最開始介紹的經(jīng)典skiplist中是不允許的。
- 在比較時,不僅比較分?jǐn)?shù)(相當(dāng)于skiplist的key),還比較數(shù)據(jù)本身。在Redis的skiplist實現(xiàn)中,數(shù)據(jù)本身的內(nèi)容唯一標(biāo)識這份數(shù)據(jù),而不是由key來唯一標(biāo)識。另外,當(dāng)多個元素分?jǐn)?shù)相同的時候,還需要根據(jù)數(shù)據(jù)內(nèi)容來進(jìn)字典排序。
- 第1層鏈表不是一個單向鏈表,而是一個雙向鏈表。這是為了方便以倒序方式獲取一個范圍內(nèi)的元素。
- 在skiplist中可以很方便地計算出每個元素的排名(rank)。
skiplist的數(shù)據(jù)結(jié)構(gòu)定義
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,我們來簡要分析一下:
- 開頭定義了兩個常量,ZSKIPLIST_MAXLEVEL和ZSKIPLIST_P,分別對應(yīng)我們前面講到的skiplist的兩個參數(shù):一個是MaxLevel,一個是p。
- zskiplistNode定義了skiplist的節(jié)點結(jié)構(gòu)。
- obj字段存放的是節(jié)點數(shù)據(jù),它的類型是一個string robj。本來一個string robj可能存放的不是sds,而是long型,但zadd命令在將數(shù)據(jù)插入到skiplist里面之前先進(jìn)行了解碼,所以這里的obj字段里存儲的一定是一個sds。有關(guān)robj的詳情可以參見系列文章的第三篇:《Redis內(nèi)部數(shù)據(jù)結(jié)構(gòu)詳解(3)——robj》。這樣做的目的應(yīng)該是為了方便在查找的時候?qū)?shù)據(jù)進(jìn)行字典序的比較,而且,skiplist里的數(shù)據(jù)部分是數(shù)字的可能性也比較小。
- score字段是數(shù)據(jù)對應(yīng)的分?jǐn)?shù)。
- backward字段是指向鏈表前一個節(jié)點的指針(前向指針)。節(jié)點只有1個前向指針,所以只有第1層鏈表是一個雙向鏈表。
- level[]存放指向各層鏈表后一個節(jié)點的指針(后向指針)。每層對應(yīng)1個后向指針,用forward字段表示。另外,每個后向指針還對應(yīng)了一個span值,它表示當(dāng)前的指針跨越了多少個節(jié)點。span用于計算元素排名(rank),這正是前面我們提到的Redis對于skiplist所做的一個擴展。需要注意的是,level[]是一個柔性數(shù)組(flexible array member),因此它占用的內(nèi)存不在zskiplistNode結(jié)構(gòu)里面,而需要插入節(jié)點的時候單獨為它分配。也正因為如此,skiplist的每個節(jié)點所包含的指針數(shù)目才是不固定的,我們前面分析過的結(jié)論——skiplist每個節(jié)點包含的指針數(shù)目平均為1/(1-p)——才能有意義。
- zskiplist定義了真正的skiplist結(jié)構(gòu),它包含:
- 頭指針header和尾指針tail。
- 鏈表長度length,即鏈表包含的節(jié)點總數(shù)。注意,新創(chuàng)建的skiplist包含一個空的頭指針,這個頭指針不包含在length計數(shù)中。
- level表示skiplist的總層數(shù),即所有節(jié)點層數(shù)的最大值。
下圖以前面插入的代數(shù)課成績表為例,展示了Redis中一個skiplist的可能結(jié)構(gòu)(點擊看大圖):
注意:圖中前向指針上面括號中的數(shù)字,表示對應(yīng)的span的值。即當(dāng)前指針跨越了多少個節(jié)點,這個計數(shù)不包括指針的起點節(jié)點,但包括指針的終點節(jié)點。
假設(shè)我們在這個skiplist中查找score=89.0的元素(即Bob的成績數(shù)據(jù)),在查找路徑中,我們會跨域圖中標(biāo)紅的指針,這些指針上面的span值累加起來,就得到了Bob的排名(2+2+1)-1=4(減1是因為rank值以0起始)。需要注意這里算的是從小到大的排名,而如果要算從大到小的排名,只需要用skiplist長度減去查找路徑上的span累加值,即6-(2+2+1)=1。
可見,在查找skiplist的過程中,通過累加span值的方式,我們就能很容易算出排名。相反,如果指定排名來查找數(shù)據(jù)(類似zrange和zrevrange那樣),也可以不斷累加span并時刻保持累加值不超過指定的排名,通過這種方式就能得到一條O(log n)的查找路徑。
Redis中的sorted set
我們前面提到過,Redis中的sorted set,是在skiplist, dict和ziplist基礎(chǔ)上構(gòu)建起來的:
- 當(dāng)數(shù)據(jù)較少時,sorted set是由一個ziplist來實現(xiàn)的。
- 當(dāng)數(shù)據(jù)多的時候,sorted set是由一個叫zset的數(shù)據(jù)結(jié)構(gòu)來實現(xiàn)的,這個zset包含一個dict + 一個skiplist。dict用來查詢數(shù)據(jù)到分?jǐn)?shù)(score)的對應(yīng)關(guān)系,而skiplist用來根據(jù)分?jǐn)?shù)查詢數(shù)據(jù)(可能是范圍查找)。
在這里我們先來討論一下前一種情況——基于ziplist實現(xiàn)的sorted set。在本系列前面關(guān)于ziplist的文章里,我們介紹過,ziplist就是由很多數(shù)據(jù)項組成的一大塊連續(xù)內(nèi)存。由于sorted set的每一項元素都由數(shù)據(jù)和score組成,因此,當(dāng)使用zadd命令插入一個(數(shù)據(jù), score)對的時候,底層在相應(yīng)的ziplist上就插入兩個數(shù)據(jù)項:數(shù)據(jù)在前,score在后。
ziplist的主要優(yōu)點是節(jié)省內(nèi)存,但它上面的查找操作只能按順序查找(可以正序也可以倒序)。因此,sorted set的各個查詢操作,就是在ziplist上從前向后(或從后向前)一步步查找,每一步前進(jìn)兩個數(shù)據(jù)項,跨域一個(數(shù)據(jù), score)對。
隨著數(shù)據(jù)的插入,sorted set底層的這個ziplist就可能會轉(zhuǎn)成zset的實現(xiàn)(轉(zhuǎn)換過程詳見t_zset.c的zsetConvert)。那么到底插入多少才會轉(zhuǎn)呢?
還記得本文開頭提到的兩個Redis配置嗎?
zset-max-ziplist-entries 128 zset-max-ziplist-value 64這個配置的意思是說,在如下兩個條件之一滿足的時候,ziplist會轉(zhuǎn)成zset(具體的觸發(fā)條件參見t_zset.c中的zaddGenericCommand相關(guān)代碼):
- 當(dāng)sorted set中的元素個數(shù),即(數(shù)據(jù), score)對的數(shù)目超過128的時候,也就是ziplist數(shù)據(jù)項超過256的時候。
- 當(dāng)sorted set中插入的任意一個數(shù)據(jù)的長度超過了64的時候。
最后,zset結(jié)構(gòu)的代碼定義如下:
typedef struct zset {dict *dict;zskiplist *zsl; } zset;Redis為什么用skiplist而不用平衡樹?
在前面我們對于skiplist和平衡樹、哈希表的比較中,其實已經(jīng)不難看出Redis里使用skiplist而不用平衡樹的原因了。現(xiàn)在我們看看,對于這個問題,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
這里從內(nèi)存占用、對范圍查找的支持和實現(xiàn)難易程度這三方面總結(jié)的原因,我們在前面其實也都涉及到了。
系列下一篇我們將介紹intset,以及它與Redis對外暴露的數(shù)據(jù)類型set的關(guān)系,敬請期待。
(完)
其它精選文章:
- Redis內(nèi)部數(shù)據(jù)結(jié)構(gòu)詳解(5)——quicklist
- Redis內(nèi)部數(shù)據(jù)結(jié)構(gòu)詳解(4)——ziplist
- Redis內(nèi)部數(shù)據(jù)結(jié)構(gòu)詳解(3)——robj
- Redis內(nèi)部數(shù)據(jù)結(jié)構(gòu)詳解(2)——sds
- Redis內(nèi)部數(shù)據(jù)結(jié)構(gòu)詳解(1)——dict
轉(zhuǎn)載于:https://www.cnblogs.com/dailidong/p/7571089.html
總結(jié)
以上是生活随笔為你收集整理的Redis 为什么用跳表而不用平衡树的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: linux 添加用户
- 下一篇: 20161011L04-03老男孩lin