Redis源碼閱讀【2-跳躍表】

Redis源碼閱讀【1-簡單動態字符串】

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性能有較高的提升,大量數據的情況下而且紅黑樹的深度也會是一個問題。

其次,跳躍表雖然會和紅黑樹對比,但是本質上還是線性的數據結構,與樹形結構還是有所不同,線性結構在順序相關的需求上會有天然的優勢,而且通過鏈表可以很快速獲取當前節點的先驅或者後繼。

以上內容是本人自己的見解,並不代表官方權威解釋

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章