文章目錄
1、介紹
有序集合在我們日常生活中非常常見,例如排名,大小排序等等,我們經常會使用例如:數組,鏈表,平衡樹等結構來實現。但是數組和鏈表在海量數據面前性能低下,平衡樹等結構雖然效率高,但是實現複雜。在這裏Redis使用了一種新的數據結構:【跳躍表】一種用空間換取時間,性能堪比紅黑樹,實現卻遠比紅黑樹簡單。Redis在有序集合鍵,和集羣節點中使用了跳躍表這種結構
2、普通鏈表
有序鏈表的結構如下:
在有序鏈表中,下一個元素的獲取始終要經過上一個元素,不像數組可用直接通過偏移量的方式快速定位到指定數據的位置,但是鏈表有個好處,插入效率高,例如我要在15的位置插入一個17,那麼我只需要把15的 next 指針和17的 next 指針替換一下即可,所以其實鏈表的插入並不低效,只是在定位元素,查找的時候浪費太多的時間而已,所以我們希望能解決鏈表查詢低效的問題,跳躍表正是爲了解決這個問題而來的。
3、什麼是跳躍表
跳躍表,顧名思義是一種可以用類似於跳躍跨度的方式去查找數據的結構,我們可以把一個跳躍表看成是多個分層的有序鏈表,其中每一層的元素數據都不一樣,從最上層開始往下,每層的元素依次增多,最底層就是實際數據存儲的鏈表
如圖:
如圖所示,如果我要查找yu鏈表中61的元素,那麼傳統鏈表我需要經過6次掃描,但是跳躍表我只需要三次掃描即可找到目標元素
從中也能看出,跳躍表性能其實也和分層掛鉤,當高層查找到最後一個元素或者,發現當前元素小於當前層的下一個元素的時候,會自動下降一層繼續查找,直到找打目標元素。例如:要查找元素31,那麼跳躍表的路徑就是 1->21->31。
綜上所述,跳躍表就是將有序列表分層,由最上層開始依次往 下後方 查找,直到找到目標元素。
4、跳躍表的結構
那麼看完跳躍表的思路,我們瞭解一下Redis使用跳躍表的地方有哪些:
1、數據量大時,實現有序集合鍵
2、是在集羣節點中用作內部數據結構
Redis的跳躍表結構基本如下圖所示:
別慌,看似很多內容的圖片,其實仔細觀察,發現內部實現還是比較簡潔又規律性的,從圖中我們可以看出Redis的跳躍表有以下幾個性質:
1、Redis的跳躍表由多層組成(最大64層)
2、跳躍表有一個Header結點,頭結點有一個64層的結構,每層結構包含,指向該層下一個結點的next指針 也包含本層跨度 (span)
3、除了頭結點外其餘結點的最高層的層數就是當前跳躍表最高層數
4、每一層都是一個有序鏈表,數據遞增
5、除了Header結點外,一個元素在上層出現,那麼它一定會在下層出現
6、跳躍表沒層最後一個結點是NULL
7、跳躍表有一個tail指針,指向跳躍表的最後一個結點
8、最底層有序鏈表包含所有元素,最底層鏈表的(length)爲跳躍表的長度
9、每個結點都有一個backward指針,指向上一個結點(雙向鏈表)
10、score作爲跳躍表各層的排序依據
4.1、跳躍表節點結構
zskiplistNode 是跳躍表的結構體,它在源碼文件 (server.h)中
typedef struct zskiplistNode {
sds ele; //用於存儲字符串類型的數據
double score; //用於排序的數據分值
struct zskiplistNode *backward; // 回退結點指針
struct zskiplistLevel {
struct zskiplistNode *forward; // 本層下一個結點指針
unsigned long span; //用於記錄當前層結點的跨度
} level[]; //分層數組,如果是header是 64層
} zskiplistNode;
結構體有以下內容:
字段 | 含義 |
---|---|
ele | 用於存儲字符串類型的數據 |
score | 用於存儲排序分值 |
backward | 後退指針,只能當前最底層的前一個結點 |
level | 分層數組,通過索引獲取當前node的不同層的位置,每個node的層數會不一樣 |
forward | 指向本層的下一個結點,尾結點指向NULL |
span | 記錄forward指向的結點與本結點直接的元素個數。span越大,跳過的結點個數越多,span最終相加是等於鏈表長度的 |
跳躍表的Header結點是一個特殊的結點,其具有跳躍表的最高層數64層,從中我們也能發現通過level[i] 的方式程序可以在O(1) 的情況下快速定位到任意一層。
4.2、跳躍表外結構
zskiplist 是跳躍表主結構,它的源碼在(server.h)中
typedef struct zskiplist {
struct zskiplistNode *header, *tail; //zskiplistNode 指向頭尾的指針
unsigned long length; // 當前跳躍表的長度
int level; //當前跳躍表的高度
} zskiplist;
字段 | 含義 |
---|---|
header | 指向跳躍表頭結點的指針 |
tail | 指向跳躍表尾結點的指針 |
length | 當前跳躍表的長度 |
level | 當前跳躍表的層高 |
可以看到zskiplistNode是zskiplist結構中的一個成員,正如上面結構圖所示,跳躍表是頭尾兩個指針,以及當前表的層高和長度。
5、創建跳躍表
5.1、獲取新結點層高
結點層高最小值爲1 ,最大值爲64 ,其中最大值是 ZSKIPLIST_MAXLEVEL(64) 跳躍表除了Header結點以外,其餘的結點層高是隨機的,比如往跳躍表裏面插入一個zskiplistNode結點其中當前結點的層高是隨機的,越往高層概率越低,其中生成層高是通過zslRandomLevel的函數實現的,具體代碼如下,代碼在(t_zset.c)中:
int zslRandomLevel(void) {
int level = 1; //默認層高是1
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) // ZSKIPLIST_P 默認是 0.25
level += 1; //層數加一
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL; //如果大於最大層數,則直接返回最大層數64
}
如代碼所示,隨機樹會在0XFFFF中隨機生成一個數,如果該數小於0XFFFF的0.25倍(一個實驗出來的常量值)的時候,返回的層數會加一,如果最終返回的層數大於最大層數,那麼直接返回最大層數64,通過這種方式可以隨機的獲取跳躍表每個結點的層數,而且也滿足跳躍表的原則:越高層元素越少的特點
5.2、創建跳躍表節點
跳躍表的每個節點都是有序集合的一個元素,在創建跳躍表節點時,待創建節點的層高,分值等基本數據都能確定,對於每個結點我們都需要申請內存存儲,代碼如下,代碼在(t_zset.c)中:
/**
*
* @param level 層高
* @param score 結點分數
* @param ele sds 數據
* @return
*/
zskiplistNode *zslCreateNode(int level, double score, sds ele) {
zskiplistNode *zn =
zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel)); //爲跳躍表結點分配內存
zn->score = score; //保存分數
zn->ele = ele; // sds數據
return zn;
}
通過圖上可以看出來,每個創建的zskiplistNode佔用的空間大小等於:(zskiplistNode大小 + 當前結點層數 * zskiplistLevel大小)
5.2.1、創建頭結點
頭結點是一個特殊的結點,除了有64層,以及像普通節點一樣分配內存外,同時還會初始化每一層的forward爲空,score爲0 ,如下(t_zset.c):
//這裏指定創建層數爲64,score爲0 ,sds爲null
zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL); //zslCreateNode 分配內存
//循環遍歷初始化每一層
for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
zsl->header->level[j].forward = NULL;
zsl->header->level[j].span = 0;
}
//backward也設置成null
zsl->header->backward = NULL;
5.3、跳躍表初始化
跳躍表會使用zslCreate函數去創建並且初始化一個空的跳躍表結構
zskiplist *zslCreate(void) {
int j;
zskiplist *zsl;
zsl = zmalloc(sizeof(*zsl)); //爲跳躍外層主結構表分配空間
zsl->level = 1; //默認層高
zsl->length = 0; //默認長度
//創建頭節點這裏指定創建層數爲64,score爲0 ,sds爲null
zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
//循環遍歷頭節點並初始化每一層
for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
zsl->header->level[j].forward = NULL;
zsl->header->level[j].span = 0;
}
//頭節點的backward也設置成null
zsl->header->backward = NULL;
zsl->tail = NULL;
return zsl;
}
5.4、插入節點
創建好跳躍表後,下一步就是往跳躍表中插入數據了,插入一般分爲5步驟走:
1、創建待插入節點(如上所示)
2、根據score查找節點需要插入的位置
3、調整跳躍表高度
4、插入節點
5、調整backward指針
5.4.1、插入步驟一(查找插入節點位置)
無論是有序數組插入還是,普通有序鏈表插入,第一件事情都是查找到需要插入的位置,前面我們大致介紹了跳躍表的查找結點的方式
/**
* 這裏有兩個循環,外層按照當前跳躍表的層高循環
* 內層根據結點的位置循環判斷查找目標位置
*
*/
for (i = zsl->level-1; i >= 0; i--) {
/* store rank that is crossed to reach the insert position */
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
/**
* 這裏的判斷邏輯是有兩塊:
* 1、當前點forward不爲空並且當前score 小於目標插入score 或者
* 2、當前點forward不爲空並且當前score 等於目標插入score 並且 當前sds字符串 小於 待插入結點字符串
*/
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
sdscmp(x->level[i].forward->ele,ele) < 0)))
{
rank[i] += x->level[i].span;
x = x->level[i].forward; //當前層的下一個結點
}
update[i] = x;
}
從代碼中看出,代碼裏面使用了兩個長度爲64的數組來輔助操作,update[] 和 rank[]
update[]:記錄插入結點時候,所涉及會影響到的結點位置的指針
rank[]:記錄當前層從header結點到update[i]結點經理的步長
如圖所示,假設當前跳錶的狀態如下圖:
假設需要插入的結點爲:(score=31 ,Level =3 )那麼過程如下:
1、第一次循環, i=1 。x爲跳躍表的頭表節點
2、此時i值和 zsl->Level -1 相等,所以rank[1]=0
3、內層 while 條件也滿足進入while循環,循環中保存 rank[1] 爲1
4、while結束後update[1] 保存 x的指針(score=1)
5、第二次循環,i=0 x 爲 score =1 結點的第L0層
6、此時i的值與zsl->level -1 不相等,所以rank[0]等於rank[1]的值爲1
7、內層 while 條件也滿足進入while循環,循環中rank[0]=1+1
8、最終循環都退出rank[0]=2 update[0]=(score=21)
結果如圖所示:
5.4.2、插入步驟二(調整跳躍表層高)
由函數zslRandomLevel得知,跳躍表每個插入結點的層高是隨機的,那麼跳躍表整體層高也是隨機的,如上所示,當出現新增的結點層高大於當前跳躍表層高的時候(上面跳躍表層高是2,新增結點層高假設是3)我們需要調整跳躍表的整體層高,代碼如下(t_zset.c):
level = zslRandomLevel(); //隨機獲取當前新增結點的層高
if (level > zsl->level) { //如果生成的層高大於當前跳躍表的層高
for (i = zsl->level; i < level; i++) {
rank[i] = 0; //對rank[當前層高]賦值爲默認值0
update[i] = zsl->header; //update[當前層高]設置爲頭結點
update[i]->level[i].span = zsl->length;// 對當前層的span賦值
}
zsl->level = level; //更新當前跳躍表爲新的層高
}
調整後的結果如下:
5.4.3、插入步驟三(插入結點)
當update和rank都賦值好後,便可以插入結點了,代碼如下(t_zset.c)
x = zslCreateNode(level,score,ele); //創建新的結點
//和前面不同的是,這個循環是從底層往高層爬的
for (i = 0; i < level; i++) {
x->level[i].forward = update[i]->level[i].forward; //將新結點每一層的forward都等於update[i]當前層結點的forward
update[i]->level[i].forward = x; //被更新結點的forward等與當前結點,因爲當前節點是最高的
//上面這裏有一個技巧,無論level是比原本跳錶小還是大,這個代碼都滿足,主要取決於跳躍表的結構特點
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}
和以往不同這裏的循環是從低層往高層爬的
插入過程如下:
第一次循環:
1、x的 level[0] 的forward爲update[0]的level[0]的forward節點,即x->level[0].forward爲score=41的節點
2、update[0]的level[0]的下一個節點爲新插入的節點
3、rank[0]-rank[0]=0,update[0]->level[0].span=1,所以x->level[0].span=1
4、update[0]->level[0].span=0+1
第一次循環結果如下圖:
第二次循環:
1、x的level[1]的forward爲update[1]的level[1]的forward節點,即x->level[1].forward爲NULL
2、update[1]的level[1]的下一個節點爲新插入的節點
3、rank[0]-rank[1]=1,update[1]->level[1].span=2,所以x->level[1].span=1
4、update[1]->level[1].span=1+1=2
第二次循環結果如下:
第三次循環:
1、x的level[1]的forward爲update[1]的level[1]的forward節點,即x->level[1].forward爲NULL
2、update[2]的level[2]的下一個節點爲新插入的節點
3、rank[0]-rank[2]=2,因爲update[2]->level[2].span=3,所以x->level[2].span=1
4、update[2]->leve[2].span=2+1=3
第三次循環結果如下:
以上就是插入的完整流程,但是除了以上的步驟之外還有一段代碼如下(t_zset.c):
for (i = level; i < zsl->level; i++) {
update[i]->level[i].span++;
}
如果插入的節點level大於當前跳躍表的層高,那麼這段代碼不會執行,如果插入的節點的level小於跳躍表的層高那麼其餘節點大於level層的span都需要+1
5.5、調整backward
根據update的賦值過程,新插入節點的前一個結點一定是update[0],由於每個結點的後退指針只有一個,與此結點的層數無關,所以當插入結點不是最後一個結點時,需要初始化插入結點的backward和他後一個結點的backward,如果插入結點是最後一個結點,只需要初始化自身的backward即可
代碼如下(t_zset.c):
x->backward = (update[0] == zsl->header) ? NULL : update[0]; //如果插入結點前一個結點是header 則初始化爲backward爲 NULL否則爲前一個結點
if (x->level[0].forward) //判斷插入結點是否爲最後一個結點
x->level[0].forward->backward = x; //如果不是最後一個結點更新下一個結點的backward爲自己
else
zsl->tail = x; //插入結點是最後一個結點,更新跳躍表的尾指針爲當前結點
zsl->length++; //跳躍表總長度加1
結果如下圖:
到目前爲止,整個跳躍表的插入工作就已經結束了
6、刪除結點
刪除是不可少的一種操作,跳躍表的刪除結點操作分爲兩步:
1、查找目標結點
2、設置span和forward
6.1、查找目標結點
和插入一樣,查找的時候也需要藉助update數組,流程和上面插入的過程是一樣的,假設需要刪除的結點是score=31那麼標記結果如下圖:
標記結點代碼如下(t_zset.c):
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x; //創建update數組
int i;
x = zsl->header;//從頭結點開始
for (i = zsl->level-1; i >= 0; i--) {
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
sdscmp(x->level[i].forward->ele,ele) < 0)))
//sdscmp 是 sds的比較函數
{
x = x->level[i].forward;
}
update[i] = x; //添加進update數組
}
內容和上面的思路差不多
6.2、設置span和forward
刪除結點需要設置update數組中每個結點的span和forward,由於刪除了結點,必然導致刪除結點的前後每層span變化,代碼如下(t_zset.c):
for (i = 0; i < zsl->level; i++) {
if (update[i]->level[i].forward == x) {
update[i]->level[i].span += x->level[i].span - 1;
update[i]->level[i].forward = x->level[i].forward;
} else {
update[i]->level[i].span -= 1;
}
}
如果update[i]第i層的forward不爲x,說明update[i]的層高大於x的層高,即update[i]第i層指向了x的後續結點或指向NULL。由於刪除了一個結點,所以update[i]的level[i]的span減1。
如果update[i]的forward不爲x,在要刪除結點的高度小於跳錶高度的情況下出現,i大於x高度的結點的forward與x無關,所以這些結點只需要更新其span減1即可
操作後的狀態如下圖:
update結點更新完之後,需要更新backward指針、跳躍表高度和長度。如果x不爲最後一個節點,直接將第0層後一個節點的backward賦值爲x的backward即可,否則,將跳躍表的尾指針指向x的backward節點即可。代碼如下(t_zset.c):
if (x->level[0].forward) { //被刪除的不是尾節點
x->level[0].forward->backward = x->backward; //調整尾節點backward的指針
} else {
zsl->tail = x->backward; //是尾節點,更新跳躍表的tail指針
}
while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL) //調整跳躍表高度
zsl->level--;
zsl->length--; //調整跳躍表長度
7、刪除跳躍表
刪除跳躍表是一個比較極端的操作,其中會遍歷整個跳躍表,從頭結點0層開始,通過forward一個個遍歷去釋放節點內存,和刪除節點不一樣,刪除跳躍表時候的刪除節點更加暴力不留餘地,代碼如下(t_zset.c):
void zslFree(zskiplist *zsl) {
zskiplistNode *node = zsl->header->level[0].forward, *next;
zfree(zsl->header); //釋放頭結點
while(node) {
next = node->level[0].forward; //按照0層遍歷到下一個
zslFreeNode(node); //內部根據不同的結構進行不同的釋放方式
node = next;
}
zfree(zsl); //釋放整個跳躍表主體外層結構
}
zslFreeNode的內部
void zslFreeNode(zskiplistNode *node) {
sdsfree(node->ele);
zfree(node);
}
8、結束
到這裏Redis的整個跳躍表講解就結束了,這裏我們瞭解了跳躍表在Redis裏面的實現方式,以及增刪改查等操作,同時也對比了普通鏈表和跳躍表的區別,順便這裏也可以說明一下,爲什麼Redis不太願意使用紅黑樹的方式來替代跳躍表。
首先,跳躍表的實現方式確實是比紅黑樹簡單,並且容易維護,況且Redis使用跳躍表的地方只有Redis有序集合鍵,和集羣節點管理,其餘之處並未使用,可見就算使用紅黑樹,並不能讓Redis性能有較高的提升,大量數據的情況下而且紅黑樹的深度也會是一個問題。
其次,跳躍表雖然會和紅黑樹對比,但是本質上還是線性的數據結構,與樹形結構還是有所不同,線性結構在順序相關的需求上會有天然的優勢,而且通過鏈表可以很快速獲取當前節點的先驅或者後繼。
以上內容是本人自己的見解,並不代表官方權威解釋