Redis里面使用skiplist是為了實(shí)現(xiàn)sorted set這種對(duì)外的數(shù)據(jù)結(jié)構(gòu)。sorted set提供的操作非常豐富,可以滿足非常多的應(yīng)用場(chǎng)景。這也意味著绍些,sorted set相對(duì)來說實(shí)現(xiàn)比較復(fù)雜。同時(shí)耀鸦,skiplist這種數(shù)據(jù)結(jié)構(gòu)對(duì)于很多人來說都比較陌生柬批,因?yàn)榇蟛糠謱W(xué)校里的算法課都沒有對(duì)這種數(shù)據(jù)結(jié)構(gòu)進(jìn)行過詳細(xì)的介紹。
我們將大體分成三個(gè)部分進(jìn)行介紹:
介紹經(jīng)典的skiplist數(shù)據(jù)結(jié)構(gòu)袖订,并進(jìn)行簡單的算法分析氮帐。這一部分的介紹,與Redis沒有直接關(guān)系著角。我會(huì)嘗試盡量使用通俗易懂的語言進(jìn)行描述揪漩。
討論Redis里的skiplist的具體實(shí)現(xiàn)旋恼。為了支持sorted set本身的一些要求吏口,在經(jīng)典的skiplist基礎(chǔ)上,Redis里的相應(yīng)實(shí)現(xiàn)做了若干改動(dòng)冰更。
討論sorted set是如何在skiplist, dict和ziplist基礎(chǔ)上構(gòu)建起來的产徊。
我們?cè)谟懻撝羞€會(huì)涉及到兩個(gè)Redis配置(在redis.conf中的ADVANCED CONFIG部分):
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
我們?cè)谟懻撝袝?huì)詳細(xì)解釋這兩個(gè)配置的含義。
skiplist數(shù)據(jù)結(jié)構(gòu)簡介
skiplist本質(zhì)上也是一種查找結(jié)構(gòu)蜀细,用于解決算法中的查找問題(Searching)舟铜,即根據(jù)給定的key,快速查到它所在的位置(或者對(duì)應(yīng)的value)奠衔。一般查找問題的解法分為兩個(gè)大類:一個(gè)是基于各種平衡樹谆刨,一個(gè)是基于哈希表。但skiplist卻比較特殊归斤,它沒法歸屬到這兩大類里面痊夭。
這種數(shù)據(jù)結(jié)構(gòu)是由William Pugh發(fā)明的,最早出現(xiàn)于他在1990年發(fā)表的論文《Skip Lists: A Probabilistic Alternative to Balanced Trees》脏里。對(duì)細(xì)節(jié)感興趣的同學(xué)可以下載論文原文來閱讀她我。
skiplist,顧名思義,首先它是一個(gè)list番舆。實(shí)際上酝碳,它是在有序鏈表的基礎(chǔ)上發(fā)展起來的。
我們先來看一個(gè)有序鏈表恨狈,如下圖(最左側(cè)的灰色節(jié)點(diǎn)表示一個(gè)空的頭結(jié)點(diǎn)):
在這樣一個(gè)鏈表中疏哗,如果我們要查找某個(gè)數(shù)據(jù),那么需要從頭開始逐個(gè)進(jìn)行比較禾怠,直到找到包含數(shù)據(jù)的那個(gè)節(jié)點(diǎn)沃斤,或者找到第一個(gè)比給定數(shù)據(jù)大的節(jié)點(diǎn)為止(沒找到)。也就是說刃宵,時(shí)間復(fù)雜度為O(n)衡瓶。同樣,當(dāng)我們要插入新數(shù)據(jù)的時(shí)候牲证,也要經(jīng)歷同樣的查找過程哮针,從而確定插入位置。
假如我們每相鄰兩個(gè)節(jié)點(diǎn)增加一個(gè)指針坦袍,讓指針指向下下個(gè)節(jié)點(diǎn)十厢,如下圖:
這樣所有新增加的指針連成了一個(gè)新的鏈表,但它包含的節(jié)點(diǎn)個(gè)數(shù)只有原來的一半(上圖中是7, 19, 26)∥嫫耄現(xiàn)在當(dāng)我們想查找數(shù)據(jù)的時(shí)候蛮放,可以先沿著這個(gè)新鏈表進(jìn)行查找。當(dāng)碰到比待查數(shù)據(jù)大的節(jié)點(diǎn)時(shí)奠宜,再回到原來的鏈表中進(jìn)行查找包颁。比如,我們想查找23压真,查找的路徑是沿著下圖中標(biāo)紅的指針?biāo)赶虻姆较蜻M(jìn)行的:
23首先和7比較娩嚼,再和19比較,比它們都大滴肿,繼續(xù)向后比較岳悟。
但23和26比較的時(shí)候,比26要小泼差,因此回到下面的鏈表(原鏈表)贵少,與22比較。
23比22要大堆缘,沿下面的指針繼續(xù)向后和26比較滔灶。23比26小,說明待查數(shù)據(jù)23在原鏈表中不存在套啤,而且它的插入位置應(yīng)該在22和26之間宽气。
在這個(gè)查找過程中随常,由于新增加的指針,我們不再需要與鏈表中每個(gè)節(jié)點(diǎn)逐個(gè)進(jìn)行比較了萄涯。需要比較的節(jié)點(diǎn)數(shù)大概只有原來的一半绪氛。
利用同樣的方式,我們可以在上層新產(chǎn)生的鏈表上涝影,繼續(xù)為每相鄰的兩個(gè)節(jié)點(diǎn)增加一個(gè)指針枣察,從而產(chǎn)生第三層鏈表。如下圖
在這個(gè)新的三層鏈表結(jié)構(gòu)上燃逻,如果我們還是查找23序目,那么沿著最上層鏈表首先要比較的是19,發(fā)現(xiàn)23比19大伯襟,接下來我們就知道只需要到19的后面去繼續(xù)查找猿涨,從而一下子跳過了19前面的所有節(jié)點(diǎn)∧饭郑可以想象叛赚,當(dāng)鏈表足夠長的時(shí)候,這種多層鏈表的查找方式能讓我們跳過很多下層節(jié)點(diǎn)稽揭,大大加快查找的速度俺附。
skiplist正是受這種多層鏈表的想法的啟發(fā)而設(shè)計(jì)出來的。實(shí)際上溪掀,按照上面生成鏈表的方式事镣,上面每一層鏈表的節(jié)點(diǎn)個(gè)數(shù),是下面一層的節(jié)點(diǎn)個(gè)數(shù)的一半揪胃,這樣查找過程就非常類似于一個(gè)二分查找璃哟,使得查找的時(shí)間復(fù)雜度可以降低到O(log n)。但是只嚣,這種方法在插入數(shù)據(jù)的時(shí)候有很大的問題沮稚。新插入一個(gè)節(jié)點(diǎn)之后,就會(huì)打亂上下相鄰兩層鏈表上節(jié)點(diǎn)個(gè)數(shù)嚴(yán)格的2:1的對(duì)應(yīng)關(guān)系册舞。如果要維持這種對(duì)應(yīng)關(guān)系,就必須把新插入的節(jié)點(diǎn)后面的所有節(jié)點(diǎn)(也包括新插入的節(jié)點(diǎn))重新進(jìn)行調(diào)整障般,這會(huì)讓時(shí)間復(fù)雜度重新蛻化成O(n)调鲸。刪除數(shù)據(jù)也有同樣的問題。
skiplist為了避免這一問題挽荡,它不要求上下相鄰兩層鏈表之間的節(jié)點(diǎn)個(gè)數(shù)有嚴(yán)格的對(duì)應(yīng)關(guān)系藐石,而是為每個(gè)節(jié)點(diǎn)隨機(jī)出一個(gè)層數(shù)(level)。比如定拟,一個(gè)節(jié)點(diǎn)隨機(jī)出的層數(shù)是3于微,那么就把它鏈入到第1層到第3層這三層鏈表中逗嫡。為了表達(dá)清楚,下圖展示了如何通過一步步的插入操作從而形成一個(gè)skiplist的過程:
從上面skiplist的創(chuàng)建和插入過程可以看出株依,每一個(gè)節(jié)點(diǎn)的層數(shù)(level)是隨機(jī)出來的驱证,而且新插入一個(gè)節(jié)點(diǎn)不會(huì)影響其它節(jié)點(diǎn)的層數(shù)。因此恋腕,插入操作只需要修改插入節(jié)點(diǎn)前后的指針抹锄,而不需要對(duì)很多節(jié)點(diǎn)都進(jìn)行調(diào)整。這就降低了插入操作的復(fù)雜度荠藤。實(shí)際上伙单,這是skiplist的一個(gè)很重要的特性,這讓它在插入性能上明顯優(yōu)于平衡樹的方案哈肖。這在后面我們還會(huì)提到吻育。
根據(jù)上圖中的skiplist結(jié)構(gòu),我們很容易理解這種數(shù)據(jù)結(jié)構(gòu)的名字的由來淤井。skiplist扫沼,翻譯成中文,可以翻譯成“跳表”或“跳躍表”庄吼,指的就是除了最下面第1層鏈表之外缎除,它會(huì)產(chǎn)生若干層稀疏的鏈表,這些鏈表里面的指針故意跳過了一些節(jié)點(diǎn)(而且越高層的鏈表跳過的節(jié)點(diǎn)越多)总寻。這就使得我們?cè)诓檎覕?shù)據(jù)的時(shí)候能夠先在高層的鏈表中進(jìn)行查找器罐,然后逐層降低,最終降到第1層鏈表來精確地確定數(shù)據(jù)位置渐行。在這個(gè)過程中轰坊,我們跳過了一些節(jié)點(diǎn),從而也就加快了查找速度祟印。
剛剛創(chuàng)建的這個(gè)skiplist總共包含4層鏈表肴沫,現(xiàn)在假設(shè)我們?cè)谒锩嬉廊徊檎?3,下圖給出了查找路徑:
需要注意的是蕴忆,前面演示的各個(gè)節(jié)點(diǎn)的插入過程颤芬,實(shí)際上在插入之前也要先經(jīng)歷一個(gè)類似的查找過程,在確定插入位置后套鹅,再完成插入操作站蝠。
至此,skiplist的查找和插入操作卓鹿,我們已經(jīng)很清楚了菱魔。而刪除操作與插入操作類似,我們也很容易想象出來吟孙。這些操作我們也應(yīng)該能很容易地用代碼實(shí)現(xiàn)出來澜倦。
當(dāng)然聚蝶,實(shí)際應(yīng)用中的skiplist每個(gè)節(jié)點(diǎn)應(yīng)該包含key和value兩部分。前面的描述中我們沒有具體區(qū)分key和value藻治,但實(shí)際上列表中是按照key進(jìn)行排序的碘勉,查找過程也是根據(jù)key在比較。
但是栋艳,如果你是第一次接觸skiplist恰聘,那么一定會(huì)產(chǎn)生一個(gè)疑問:節(jié)點(diǎn)插入時(shí)隨機(jī)出一個(gè)層數(shù),僅僅依靠這樣一個(gè)簡單的隨機(jī)數(shù)操作而構(gòu)建出來的多層鏈表結(jié)構(gòu)吸占,能保證它有一個(gè)良好的查找性能嗎晴叨?為了回答這個(gè)疑問,我們需要分析skiplist的統(tǒng)計(jì)性能矾屯。
在分析之前兼蕊,我們還需要著重指出的是,執(zhí)行插入操作時(shí)計(jì)算隨機(jī)數(shù)的過程件蚕,是一個(gè)很關(guān)鍵的過程孙技,它對(duì)skiplist的統(tǒng)計(jì)特性有著很重要的影響。這并不是一個(gè)普通的服從均勻分布的隨機(jī)數(shù)排作,它的計(jì)算過程如下:
首先牵啦,每個(gè)節(jié)點(diǎn)肯定都有第1層指針(每個(gè)節(jié)點(diǎn)都在第1層鏈表里)。
如果一個(gè)節(jié)點(diǎn)有第i層(i>=1)指針(即節(jié)點(diǎn)已經(jīng)在第1層到第i層鏈表中)妄痪,那么它有第(i+1)層指針的概率為p哈雏。
節(jié)點(diǎn)最大的層數(shù)不允許超過一個(gè)最大值,記為MaxLevel衫生。
這個(gè)計(jì)算隨機(jī)層數(shù)的偽碼如下所示:
randomLevel()
level := 1
// random()返回一個(gè)[0...1)的隨機(jī)數(shù)
while random() < p and level < MaxLevel do
level := level + 1
return level
randomLevel()的偽碼中包含兩個(gè)參數(shù)裳瘪,一個(gè)是p,一個(gè)是MaxLevel罪针。在Redis的skiplist實(shí)現(xiàn)中彭羹,這兩個(gè)參數(shù)的取值為:
p = 1/4
MaxLevel = 32
skiplist的算法性能分析
在這一部分,我們來簡單分析一下skiplist的時(shí)間復(fù)雜度和空間復(fù)雜度泪酱,以便對(duì)于skiplist的性能有一個(gè)直觀的了解派殷。如果你不是特別偏執(zhí)于算法的性能分析,那么可以暫時(shí)跳過這一小節(jié)的內(nèi)容西篓。
我們先來計(jì)算一下每個(gè)節(jié)點(diǎn)所包含的平均指針數(shù)目(概率期望)愈腾。節(jié)點(diǎn)包含的指針數(shù)目,相當(dāng)于這個(gè)算法在空間上的額外開銷(overhead)岂津,可以用來度量空間復(fù)雜度。
根據(jù)前面randomLevel()的偽碼悦即,我們很容易看出吮成,產(chǎn)生越高的節(jié)點(diǎn)層數(shù)橱乱,概率越低。定量的分析如下:
節(jié)點(diǎn)層數(shù)至少為1粱甫。而大于1的節(jié)點(diǎn)層數(shù)泳叠,滿足一個(gè)概率分布。
節(jié)點(diǎn)層數(shù)恰好等于1的概率為1-p茶宵。
節(jié)點(diǎn)層數(shù)大于等于2的概率為p危纫,而節(jié)點(diǎn)層數(shù)恰好等于2的概率為p(1-p)。
節(jié)點(diǎn)層數(shù)大于等于3的概率為p2乌庶,而節(jié)點(diǎn)層數(shù)恰好等于3的概率為p2(1-p)种蝶。
節(jié)點(diǎn)層數(shù)大于等于4的概率為p3,而節(jié)點(diǎn)層數(shù)恰好等于4的概率為p3(1-p)瞒大。
......
因此螃征,一個(gè)節(jié)點(diǎn)的平均層數(shù)(也即包含的平均指針數(shù)目),計(jì)算如下:
現(xiàn)在很容易計(jì)算出:
當(dāng)p=1/2時(shí)透敌,每個(gè)節(jié)點(diǎn)所包含的平均指針數(shù)目為2盯滚;
當(dāng)p=1/4時(shí),每個(gè)節(jié)點(diǎn)所包含的平均指針數(shù)目為1.33酗电。這也是Redis里的skiplist實(shí)現(xiàn)在空間上的開銷魄藕。
接下來,為了分析時(shí)間復(fù)雜度撵术,我們計(jì)算一下skiplist的平均查找長度背率。查找長度指的是查找路徑上跨越的跳數(shù),而查找過程中的比較次數(shù)就等于查找長度加1荷荤。以前面圖中標(biāo)出的查找23的查找路徑為例退渗,從左上角的頭結(jié)點(diǎn)開始,一直到結(jié)點(diǎn)22蕴纳,查找長度為6会油。
為了計(jì)算查找長度,這里我們需要利用一點(diǎn)小技巧古毛。我們注意到翻翩,每個(gè)節(jié)點(diǎn)插入的時(shí)候,它的層數(shù)是由隨機(jī)函數(shù)randomLevel()計(jì)算出來的稻薇,而且隨機(jī)的計(jì)算不依賴于其它節(jié)點(diǎn)嫂冻,每次插入過程都是完全獨(dú)立的。所以塞椎,從統(tǒng)計(jì)上來說桨仿,一個(gè)skiplist結(jié)構(gòu)的形成與節(jié)點(diǎn)的插入順序無關(guān)。
這樣的話案狠,為了計(jì)算查找長度服傍,我們可以將查找過程倒過來看钱雷,從右下方第1層上最后到達(dá)的那個(gè)節(jié)點(diǎn)開始,沿著查找路徑向左向上回溯吹零,類似于爬樓梯的過程罩抗。我們假設(shè)當(dāng)回溯到某個(gè)節(jié)點(diǎn)的時(shí)候,它才被插入灿椅,這雖然相當(dāng)于改變了節(jié)點(diǎn)的插入順序套蒂,但從統(tǒng)計(jì)上不影響整個(gè)skiplist的形成結(jié)構(gòu)。
現(xiàn)在假設(shè)我們從一個(gè)層數(shù)為i的節(jié)點(diǎn)x出發(fā)茫蛹,需要向左向上攀爬k層操刀。這時(shí)我們有兩種可能:
如果節(jié)點(diǎn)x有第(i+1)層指針,那么我們需要向上走麻惶。這種情況概率為p馍刮。
如果節(jié)點(diǎn)x沒有第(i+1)層指針,那么我們需要向左走窃蹋。這種情況概率為(1-p)卡啰。
這兩種情形如下圖所示:
用C(k)表示向上攀爬k個(gè)層級(jí)所需要走過的平均查找路徑長度(概率期望),那么:
C(0)=0
C(k)=(1-p)×(上圖中情況b的查找長度) + p×(上圖中情況c的查找長度)
代入警没,得到一個(gè)差分方程并化簡:
C(k)=(1-p)(C(k)+1) + p(C(k-1)+1)
C(k)=1/p+C(k-1)
C(k)=k/p
這個(gè)結(jié)果的意思是匈辱,我們每爬升1個(gè)層級(jí),需要在查找路徑上走1/p步杀迹。而我們總共需要攀爬的層級(jí)數(shù)等于整個(gè)skiplist的總層數(shù)-1亡脸。
那么接下來我們需要分析一下當(dāng)skiplist中有n個(gè)節(jié)點(diǎn)的時(shí)候,它的總層數(shù)的概率均值是多少树酪。這個(gè)問題直觀上比較好理解浅碾。根據(jù)節(jié)點(diǎn)的層數(shù)隨機(jī)算法,容易得出:
第1層鏈表固定有n個(gè)節(jié)點(diǎn)续语;
第2層鏈表平均有np個(gè)節(jié)點(diǎn)垂谢;
第3層鏈表平均有np2個(gè)節(jié)點(diǎn);
...
所以疮茄,從第1層到最高層滥朱,各層鏈表的平均節(jié)點(diǎn)數(shù)是一個(gè)指數(shù)遞減的等比數(shù)列。容易推算出力试,總層數(shù)的均值為log1/pn徙邻,而最高層的平均節(jié)點(diǎn)數(shù)為1/p。
綜上畸裳,粗略來計(jì)算的話缰犁,平均查找長度約等于:
- C(log1/pn-1)=(log1/pn-1)/p
即,平均時(shí)間復(fù)雜度為O(log n)。
當(dāng)然民鼓,這里的時(shí)間復(fù)雜度分析還是比較粗略的薇芝。比如蓬抄,沿著查找路徑向左向上回溯的時(shí)候丰嘉,可能先到達(dá)左側(cè)頭結(jié)點(diǎn),然后沿頭結(jié)點(diǎn)一路向上嚷缭;還可能先到達(dá)最高層的節(jié)點(diǎn)饮亏,然后沿著最高層鏈表一路向左。但這些細(xì)節(jié)不影響平均時(shí)間復(fù)雜度的最后結(jié)果阅爽。另外路幸,這里給出的時(shí)間復(fù)雜度只是一個(gè)概率平均值,但實(shí)際上計(jì)算一個(gè)精細(xì)的概率分布也是有可能的付翁。詳情還請(qǐng)參見William Pugh的論文《Skip Lists: A Probabilistic Alternative to Balanced Trees》简肴。
skiplist與平衡樹、哈希表的比較
skiplist和各種平衡樹(如AVL百侧、紅黑樹等)的元素是有序排列的砰识,而哈希表不是有序的。因此佣渴,在哈希表上只能做單個(gè)key的查找辫狼,不適宜做范圍查找。所謂范圍查找辛润,指的是查找那些大小在指定的兩個(gè)值之間的所有節(jié)點(diǎn)膨处。
在做范圍查找的時(shí)候,平衡樹比skiplist操作要復(fù)雜砂竖。在平衡樹上真椿,我們找到指定范圍的小值之后,還需要以中序遍歷的順序繼續(xù)尋找其它不超過大值的節(jié)點(diǎn)乎澄。如果不對(duì)平衡樹進(jìn)行一定的改造突硝,這里的中序遍歷并不容易實(shí)現(xiàn)。而在skiplist上進(jìn)行范圍查找就非常簡單三圆,只需要在找到小值之后狞换,對(duì)第1層鏈表進(jìn)行若干步的遍歷就可以實(shí)現(xiàn)。
平衡樹的插入和刪除操作可能引發(fā)子樹的調(diào)整舟肉,邏輯復(fù)雜修噪,而skiplist的插入和刪除只需要修改相鄰節(jié)點(diǎn)的指針,操作簡單又快速路媚。
從內(nèi)存占用上來說黄琼,skiplist比平衡樹更靈活一些。一般來說,平衡樹每個(gè)節(jié)點(diǎn)包含2個(gè)指針(分別指向左右子樹)脏款,而skiplist每個(gè)節(jié)點(diǎn)包含的指針數(shù)目平均為1/(1-p)围苫,具體取決于參數(shù)p的大小。如果像Redis里的實(shí)現(xiàn)一樣撤师,取p=1/4剂府,那么平均每個(gè)節(jié)點(diǎn)包含1.33個(gè)指針,比平衡樹更有優(yōu)勢(shì)剃盾。
查找單個(gè)key腺占,skiplist和平衡樹的時(shí)間復(fù)雜度都為O(log n),大體相當(dāng)痒谴;而哈希表在保持較低的哈希值沖突概率的前提下衰伯,查找時(shí)間復(fù)雜度接近O(1),性能更高一些积蔚。所以我們平常使用的各種Map或dictionary結(jié)構(gòu)意鲸,大都是基于哈希表實(shí)現(xiàn)的。
從算法實(shí)現(xiàn)難度上來比較尽爆,skiplist比平衡樹要簡單得多怎顾。
Redis中的skiplist實(shí)現(xiàn)
在Redis中,skiplist被用于實(shí)現(xiàn)暴露給外部的一個(gè)數(shù)據(jù)結(jié)構(gòu):sorted set教翩。準(zhǔn)確地說杆勇,sorted set底層不僅僅使用了skiplist,還使用了ziplist和dict饱亿。這幾個(gè)數(shù)據(jù)結(jié)構(gòu)的關(guān)系蚜退,我們以后再討論。現(xiàn)在彪笼,我們先花點(diǎn)時(shí)間把sorted set的關(guān)鍵命令看一下钻注。這些命令對(duì)于Redis里skiplist的實(shí)現(xiàn),有重要的影響配猫。
sorted set的命令舉例
sorted set是一個(gè)有序的數(shù)據(jù)集合幅恋,對(duì)于像類似排行榜這樣的應(yīng)用場(chǎng)景特別適合。
現(xiàn)在我們來看一個(gè)例子泵肄,用sorted set來存儲(chǔ)代數(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ù)存儲(chǔ)到sorted set里面去:
對(duì)于上面的這些命令腐巢,我們需要的注意的地方包括:
前面的6個(gè)zadd命令品追,將6位同學(xué)的名字和分?jǐn)?shù)(score)都輸入到一個(gè)key值為algebra的sorted set里面了。注意Alice和Fred的分?jǐn)?shù)相同冯丙,都是87.5分肉瓦。
zrevrank命令查詢Alice的排名(命令中的rev表示按照倒序排列,也就是從大到小)泞莉,返回3哪雕。排在Alice前面的分別是Emily、Bob鲫趁、Fred斯嚎,而排名(rank)從0開始計(jì)數(shù)蹲盘,所以Alice的排名是3蔗彤。注意,其實(shí)Alice和Fred的分?jǐn)?shù)相同,這種情況下sorted set會(huì)把分?jǐn)?shù)相同的元素幽崩,按照字典順序來排列。按照倒序寞钥,F(xiàn)red排在了Alice的前面慌申。
zscore命令查詢了Charles對(duì)應(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中的每個(gè)元素主要表現(xiàn)出3個(gè)屬性:
- 數(shù)據(jù)本身(在前面的例子中我們把名字存成了數(shù)據(jù))柒爵。
- 每個(gè)數(shù)據(jù)對(duì)應(yīng)一個(gè)分?jǐn)?shù)(score)。
- 根據(jù)分?jǐn)?shù)大小和數(shù)據(jù)本身的字典排序赚爵,每個(gè)數(shù)據(jù)會(huì)產(chǎn)生一個(gè)排名(rank)棉胀。可以按正序或倒序冀膝。
Redis中skiplist實(shí)現(xiàn)的特殊性
我們簡單分析一下前面出現(xiàn)的幾個(gè)查詢命令:
- zrevrank由數(shù)據(jù)查詢它對(duì)應(yīng)的排名唁奢,這在前面介紹的skiplist中并不支持。
- zscore由數(shù)據(jù)查詢它對(duì)應(yīng)的分?jǐn)?shù)窝剖,這也不是skiplist所支持的麻掸。
- zrevrange根據(jù)一個(gè)排名范圍,查詢排名在這個(gè)范圍內(nèi)的數(shù)據(jù)赐纱。這在前面介紹的skiplist中也不支持脊奋。
- zrevrangebyscore根據(jù)分?jǐn)?shù)區(qū)間查詢數(shù)據(jù)集合,是一個(gè)skiplist所支持的典型的范圍查找(score相當(dāng)于key)疙描。
實(shí)際上诚隙,Redis中sorted set的實(shí)現(xiàn)是這樣的:
- 當(dāng)數(shù)據(jù)較少時(shí),sorted set是由一個(gè)ziplist來實(shí)現(xiàn)的淫痰。
- 當(dāng)數(shù)據(jù)多的時(shí)候最楷,sorted set是由一個(gè)dict + 一個(gè)skiplist來實(shí)現(xiàn)的。簡單來講,dict用來查詢數(shù)據(jù)到分?jǐn)?shù)的對(duì)應(yīng)關(guān)系籽孙,而skiplist用來根據(jù)分?jǐn)?shù)查詢數(shù)據(jù)(可能是范圍查找)烈评。
現(xiàn)在我們集中精力來看一下sorted set與skiplist的關(guān)系,:
- zscore的查詢犯建,不是由skiplist來提供的讲冠,而是由那個(gè)dict來提供的。
- 為了支持排名(rank)适瓦,Redis里對(duì)skiplist做了擴(kuò)展竿开,使得根據(jù)排名能夠快速查到數(shù)據(jù),或者根據(jù)分?jǐn)?shù)查到數(shù)據(jù)之后玻熙,也同時(shí)很容易獲得排名否彩。而且,根據(jù)排名的查找嗦随,時(shí)間復(fù)雜度也為O(log n)列荔。
- zrevrange的查詢,是根據(jù)排名查數(shù)據(jù)枚尼,由擴(kuò)展后的skiplist來提供贴浙。
- zrevrank是先在dict中由數(shù)據(jù)查到分?jǐn)?shù),再拿分?jǐn)?shù)到skiplist中去查找署恍,查到后也同時(shí)獲得了排名崎溃。
前述的查詢過程,也暗示了各個(gè)操作的時(shí)間復(fù)雜度:
- zscore只用查詢一個(gè)dict盯质,所以時(shí)間復(fù)雜度為O(1)
- zrevrank, zrevrange, zrevrangebyscore由于要查詢skiplist袁串,所以zrevrank的時(shí)間復(fù)雜度為O(log n),而zrevrange, zrevrangebyscore的時(shí)間復(fù)雜度為O(log(n)+M)唤殴,其中M是當(dāng)前查詢返回的元素個(gè)數(shù)般婆。
總結(jié)起來,Redis中的skiplist跟前面介紹的經(jīng)典的skiplist相比朵逝,有如下不同:
分?jǐn)?shù)(score)允許重復(fù)蔚袍,即skiplist的key允許重復(fù)。這在最開始介紹的經(jīng)典skiplist中是不允許的配名。
在比較時(shí)啤咽,不僅比較分?jǐn)?shù)(相當(dāng)于skiplist的key),還比較數(shù)據(jù)本身渠脉。在Redis的skiplist實(shí)現(xiàn)中宇整,數(shù)據(jù)本身的內(nèi)容唯一標(biāo)識(shí)這份數(shù)據(jù),而不是由key來唯一標(biāo)識(shí)芋膘。另外鳞青,當(dāng)多個(gè)元素分?jǐn)?shù)相同的時(shí)候霸饲,還需要根據(jù)數(shù)據(jù)內(nèi)容來進(jìn)字典排序。
第1層鏈表不是一個(gè)單向鏈表臂拓,而是一個(gè)雙向鏈表厚脉。這是為了方便以倒序方式獲取一個(gè)范圍內(nèi)的元素。
在skiplist中可以很方便地計(jì)算出每個(gè)元素的排名(rank)胶惰。
skiplist的數(shù)據(jù)結(jié)構(gòu)定義
#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;
開頭定義了兩個(gè)常量傻工,ZSKIPLIST_MAXLEVEL和ZSKIPLIST_P,分別對(duì)應(yīng)我們前面講到的skiplist的兩個(gè)參數(shù):一個(gè)是MaxLevel孵滞,一個(gè)是p中捆。
-
zskiplistNode定義了skiplist的節(jié)點(diǎn)結(jié)構(gòu)。
- obj字段存放的是節(jié)點(diǎn)數(shù)據(jù)坊饶,它的類型是一個(gè)string robj泄伪。本來一個(gè)string robj可能存放的不是sds,而是long型幼东,但zadd命令在將數(shù)據(jù)插入到skiplist里面之前先進(jìn)行了解碼臂容,所以這里的obj字段里存儲(chǔ)的一定是一個(gè)sds。這樣做的目的應(yīng)該是為了方便在查找的時(shí)候?qū)?shù)據(jù)進(jìn)行字典序的比較根蟹,而且,skiplist里的數(shù)據(jù)部分是數(shù)字的可能性也比較小糟秘。
score字段是數(shù)據(jù)對(duì)應(yīng)的分?jǐn)?shù)简逮。
backward字段是指向鏈表前一個(gè)節(jié)點(diǎn)的指針(前向指針)。節(jié)點(diǎn)只有1個(gè)前向指針尿赚,所以只有第1層鏈表是一個(gè)雙向鏈表散庶。
level[]存放指向各層鏈表后一個(gè)節(jié)點(diǎn)的指針(后向指針)。每層對(duì)應(yīng)1個(gè)后向指針凌净,用forward字段表示悲龟。另外,每個(gè)后向指針還對(duì)應(yīng)了一個(gè)span值冰寻,它表示當(dāng)前的指針跨越了多少個(gè)節(jié)點(diǎn)须教。span用于計(jì)算元素排名(rank),這正是前面我們提到的Redis對(duì)于skiplist所做的一個(gè)擴(kuò)展斩芭。需要注意的是轻腺,level[]是一個(gè)柔性數(shù)組(flexible array member),因此它占用的內(nèi)存不在zskiplistNode結(jié)構(gòu)里面划乖,而需要插入節(jié)點(diǎn)的時(shí)候單獨(dú)為它分配贬养。也正因?yàn)槿绱耍瑂kiplist的每個(gè)節(jié)點(diǎn)所包含的指針數(shù)目才是不固定的琴庵,我們前面分析過的結(jié)論——skiplist每個(gè)節(jié)點(diǎn)包含的指針數(shù)目平均為1/(1-p)——才能有意義误算。
-
zskiplist定義了真正的skiplist結(jié)構(gòu)仰美,它包含:
- 頭指針header和尾指針tail。
- 鏈表長度length儿礼,即鏈表包含的節(jié)點(diǎn)總數(shù)咖杂。注意,新創(chuàng)建的skiplist包含一個(gè)空的頭指針蜘犁,這個(gè)頭指針不包含在length計(jì)數(shù)中翰苫。
- level表示skiplist的總層數(shù),即所有節(jié)點(diǎn)層數(shù)的最大值这橙。
下圖以前面插入的代數(shù)課成績表為例奏窑,展示了Redis中一個(gè)skiplist的可能結(jié)構(gòu)
注意:圖中前向指針上面括號(hào)中的數(shù)字,表示對(duì)應(yīng)的span的值屈扎。即當(dāng)前指針跨越了多少個(gè)節(jié)點(diǎn)埃唯,這個(gè)計(jì)數(shù)不包括指針的起點(diǎn)節(jié)點(diǎn),但包括指針的終點(diǎn)節(jié)點(diǎn)鹰晨。
假設(shè)我們?cè)谶@個(gè)skiplist中查找score=89.0的元素(即Bob的成績數(shù)據(jù))墨叛,在查找路徑中,我們會(huì)跨域圖中標(biāo)紅的指針模蜡,這些指針上面的span值累加起來漠趁,就得到了Bob的排名(2+2+1)-1=4(減1是因?yàn)閞ank值以0起始)。需要注意這里算的是從小到大的排名忍疾,而如果要算從大到小的排名闯传,只需要用skiplist長度減去查找路徑上的span累加值,即6-(2+2+1)=1卤妒。
可見甥绿,在查找skiplist的過程中,通過累加span值的方式则披,我們就能很容易算出排名共缕。相反,如果指定排名來查找數(shù)據(jù)(類似zrange和zrevrange那樣)士复,也可以不斷累加span并時(shí)刻保持累加值不超過指定的排名图谷,通過這種方式就能得到一條O(log n)的查找路徑。
Redis中的sorted set
我們前面提到過判没,Redis中的sorted set蜓萄,是在skiplist, dict和ziplist基礎(chǔ)上構(gòu)建起來的:
當(dāng)數(shù)據(jù)較少時(shí),sorted set是由一個(gè)ziplist來實(shí)現(xiàn)的澄峰。
當(dāng)數(shù)據(jù)多的時(shí)候嫉沽,sorted set是由一個(gè)叫zset的數(shù)據(jù)結(jié)構(gòu)來實(shí)現(xiàn)的,這個(gè)zset包含一個(gè)dict + 一個(gè)skiplist俏竞。dict用來查詢數(shù)據(jù)到分?jǐn)?shù)(score)的對(duì)應(yīng)關(guān)系绸硕,而skiplist用來根據(jù)分?jǐn)?shù)查詢數(shù)據(jù)(可能是范圍查找)堂竟。
在這里我們先來討論一下前一種情況——基于ziplist實(shí)現(xiàn)的sorted set。
ziplist就是由很多數(shù)據(jù)項(xiàng)組成的一大塊連續(xù)內(nèi)存玻佩。由于sorted set的每一項(xiàng)元素都由數(shù)據(jù)和score組成出嘹,因此,當(dāng)使用zadd命令插入一個(gè)(數(shù)據(jù), score)對(duì)的時(shí)候咬崔,底層在相應(yīng)的ziplist上就插入兩個(gè)數(shù)據(jù)項(xiàng):數(shù)據(jù)在前税稼,score在后。
ziplist的主要優(yōu)點(diǎn)是節(jié)省內(nèi)存垮斯,但它上面的查找操作只能按順序查找(可以正序也可以倒序)郎仆。因此,sorted set的各個(gè)查詢操作兜蠕,就是在ziplist上從前向后(或從后向前)一步步查找扰肌,每一步前進(jìn)兩個(gè)數(shù)據(jù)項(xiàng),跨域一個(gè)(數(shù)據(jù), score)對(duì)熊杨。
隨著數(shù)據(jù)的插入曙旭,sorted set底層的這個(gè)ziplist就可能會(huì)轉(zhuǎn)成zset的實(shí)現(xiàn)(轉(zhuǎn)換過程詳見t_zset.c的zsetConvert)。那么到底插入多少才會(huì)轉(zhuǎn)呢晶府?
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
這個(gè)配置的意思是說桂躏,在如下兩個(gè)條件之一滿足的時(shí)候,ziplist會(huì)轉(zhuǎn)成zset(具體的觸發(fā)條件參見t_zset.c中的zaddGenericCommand相關(guān)代碼):
- 當(dāng)sorted set中的元素個(gè)數(shù)川陆,即(數(shù)據(jù), score)對(duì)的數(shù)目超過128的時(shí)候沼头,也就是ziplist數(shù)據(jù)項(xiàng)超過256的時(shí)候。
- 當(dāng)sorted set中插入的任意一個(gè)數(shù)據(jù)的長度超過了64的時(shí)候书劝。
最后,zset結(jié)構(gòu)的代碼定義如下:
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
Redis為什么用skiplist而不用平衡樹土至?
在前面我們對(duì)于skiplist和平衡樹购对、哈希表的比較中,其實(shí)已經(jīng)不難看出Redis里使用skiplist而不用平衡樹的原因了√找颍現(xiàn)在我們看看骡苞,對(duì)于這個(gè)問題,Redis的作者 @antirez 是怎么說的:
There are a few reasons:
- 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.
- 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.
- 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.
這里從內(nèi)存占用楷扬、對(duì)范圍查找的支持和實(shí)現(xiàn)難易程度這三方面總結(jié)的原因解幽,我們?cè)谇懊嫫鋵?shí)也都涉及到了。