Redis 的底層數據結構(跳躍表)

咱們都知道單鏈表有一個致命的弱點,查找任一節點都至少 O(n) 的時間複雜度,它須要遍歷一遍整個鏈表,那麼有沒有辦法提高鏈表的搜索效率?java

跳躍表(SkipList)這種數據結構使用空間換時間的策略,經過給鏈表創建多層索引來加快搜索效率,咱們先介紹跳躍表的基本理論,再來看看 redis 中的實現狀況。node

1、跳躍表(SkipList)

image

這是一條帶哨兵的雙端鏈表,大部分場景下的鏈表都是這種結構,它的好處是,不管是頭插法仍是尾插法,插入操做都是常量級別的時間複雜度,刪除也是同樣。但缺點就是,若是想要查詢某個節點,則須要 O(n)。git

那若是咱們給鏈表加一層索引呢?固然前提是最底層的鏈表是有序的,否則索引也沒有意義了。程序員

image

讓 HEAD 頭指針指向最高索引,我抽出來一層索引,這樣即使你查找節點 2222 三次比較。github

第一次:與 2019 節點比較,發現大於 2019,日後繼續redis

第二次:與 2100 節點比較,發現依然大於,日後繼續算法

第三次:本層索引到頭了,指向低層索引的下一個節點,繼續比較,找到節點數組

而無索引的鏈表須要四次,效率看起來不是很明顯,可是隨着鏈表節點數量增多,索引層級增多,效率差距會很明顯。圖就不本身畫了,取自極客時間王爭老師的一張圖。bash

image

你看,本來須要 62 次比較操做,經過五層索引,只須要 4 次比較,跳躍表的效率可見一瞥。微信

想要知道具體跳躍表與鏈表差距多少,咱們接下來進行它們各個操做的時間複雜度分析對比。

一、插入節點操做

雙端鏈表(如下咱們簡稱鏈表)的本來插入操做是 O(1) 的時間複雜度,可是這裏咱們討論的是有序鏈表,因此插入一個節點至少還要找到它該插入的位置,而後才能執行插入操做,因此鏈表的插入效率是 O(n)。

跳躍表(如下咱們簡稱跳錶)也依然是須要兩個步驟才能完成插入操做,先找到該插入的位置,再進行插入操做。咱們設定一個具備 N 個節點的鏈表,它建有 K 層索引並假設每兩個節點間隔就向上分裂一層索引。

k 層兩個節點,k-1 層 4 個節點,k-2 層 8 個節點 ... 第一層 n 個節點,

1:n
2:1/2 * n
3:1/2^2 * n
.....
.....
k:1/2^(k-1) * n
複製代碼

1/2^(k-1) * n 表示第 k 層節點數,1/2^(k-1) * n=2 能夠獲得,k 等於 logn,也就是說 ,N 個節點構建跳錶將須要 logn 層索引,包括自身那層鏈表層。

而當咱們要搜索某個節點時,須要從最高層索引開始,按照咱們的構建方式,某個節點必然位於兩個索引節點之間,因此每一層都最多訪問三個節點。這一點你可能須要理解理解,由於每一層索引的搜索都是基於上一層索引的,從上一層索引下來,要麼是大於(小於)當前的索引節點,但不會大於(小於)其日後兩個位置的節點,也就是當前索引節點的上一層後一索引節點,因此它最多訪問三個節點。

有了這一結論,咱們向跳錶中插入一個元素的時間複雜度就爲:O(logn)。這個時間複雜度等於二分查找的時間複雜度,全部有時咱們又稱跳錶是實現了二分查找的鏈表。

很明顯,插入操做,跳錶完勝鏈表。

二、修改刪除查詢

這三個節點操做其實沒什麼可比性,修改刪除操做,鏈表等效於跳錶。而查詢,咱們上面也說了,鏈表至少 O(n),跳錶在 O(logn)。

除此以外,咱們都知道紅黑樹在每次插入節點後會自旋來進行樹的平衡,那麼跳錶其實也會有這麼一個問題,就是不斷的插入,會致使底層鏈表節點瘋狂增加,而索引層依然那麼多,極端狀況全部節點都新增到最後一級索引節點的右邊,進而使跳錶退化成鏈表。

簡單一句話來講,就是大量的節點插入以後,而不更新索引的話,跳錶將沒法一如既往的保證效率。解決辦法也很簡單,就是每一次節點的插入,觸發索引節點的更新,咱們具體來看一下更新策略。

通常跳錶會使用一個隨機函數,這個隨機函數會在跳錶新增了一個節點後,根據跳錶的目前結構生成一個隨機數,這個數值固然要小於最大的索引層值,假定這個值等於 m,那麼跳錶會生成從 1 到 m 層的索引。因此這個隨機函數的選擇或者說實現就顯得很重要了,關於它咱們這裏不作討論,你們能夠看看各類跳錶的實現中是如何實現這個隨機函數的,典型的就是 Java 中 ConcurrentSkipListMap 內部實現的 SkipList 結構,固然還有咱們立刻要介紹的 redis 中的實現。

以上就是跳錶這種數據結構的基本理論內容,接下來咱們看 redis 中的實現狀況。

2、Redis 中的跳躍表

說在前面的是,redis 本身實現了跳錶,但目的是爲它的有序集合等高層抽象數據結構提供服務,因此等下咱們分析源代碼的時候其中必然會涉及到一些看似無用的結構和代碼邏輯,但那些也是很是重要的,咱們也會說起有序集合相關的內容,但不會拆分細緻,重點仍是看跳錶的實現。

跳錶的數據結構定義以下:

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;
複製代碼

跳錶中的每一個節點用數據結構 zskiplistNode 表示,head 和 tail 分別指向最底層鏈表的頭尾節點。length 表示當前跳錶最底層鏈表有多少個節點,level 記錄當前跳錶最高索引層數。

zskiplistNode 結構以下:

typedef struct zskiplistNode {
    sds ele;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned int span;
    } level[];
} zskiplistNode;
複製代碼

我這裏摘取的 redis 源碼是 4.0 版本的,之前版本 ele 屬性是一個 RedisObject 類型,如今是一個字符串類型,也即表示跳錶如今只用於存儲字符串數據。

score 記錄當前節點的一個分值,最底層的鏈表就是按照分值大小有序的串聯的,而且咱們查詢一個節點,通常也會傳入該節點的 score 值,畢竟數值類型比較起來方便。

backward 指針指向前一個節點,爲何是倒着往前,咱們待會會說。

level 是比較關鍵的一個點,這裏面是一個 level 數組,而每一個元素又都是一個 zskiplistLevel 類型的結構,zskiplistLevel 類型包括一個 forward 前向指針,一個 span 跨度值,具體是什麼意思,咱們一點點說。

跳錶理論上在最底層是一條雙端鏈表,而後基於此創建了多層索引節點以實現的,但在實際的代碼實現上,這種結構是很差表述的,因此你要打破既有的慣性思惟,而後才能好理解 redis 中的實現。實際上正如咱們上述介紹的 zskiplistNode 結構同樣,每一個節點除了存儲節點自身的數據外,還經過 level 數組保存了該節點在整個跳錶各個索引層的節點引用,具體結構就是這樣的:

image

而整張跳錶基本就是這樣的結構:

image

每個節點的 backward 指針指向本身前面的一個節點,而每一個節點中的 level 數組記錄的就是當前節點在跳錶的哪些索引層出現,並經過其 forward 指針順序串聯這一層索引的各個節點,0 表示第一層,1 表示第二層,等等以此類推。span 表示的是當前節點與後面一個節點的跨度,咱們等下還會在代碼裏說到,暫時不理解也不要緊。

基本上跳錶就是這樣一個結構,上面那張圖仍是很重要的,包括咱們等下介紹源碼實現,也對你理解有很大幫助的。(畢竟我畫了半天。。)

這裏多插一句,與跳錶相關結構定義在一塊兒的還有一個有序集合結構,不少人會說 redis 中的有序集合是跳錶實現的,這句話不錯,但有失偏駁。

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;
複製代碼

準確來講,redis 中的有序集合是由咱們以前介紹過的字典加上跳錶實現的,字典中保存的數據和分數 score 的映射關係,每次插入數據會從字典中查詢,若是已經存在了,就再也不插入,有序集合中是不容許重複數據。

下面咱們看看 redis 中跳錶的相關代碼的實現狀況。

一、跳錶初始化

redis 中初始化一個跳錶的代碼以下:

zskiplistNode *zslCreateNode(int level, double score, sds ele) {
    zskiplistNode *zn =
        zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel));
    zn->score = score;
    zn->ele = ele;
    return zn;
}

/* Create a new skiplist. */
zskiplist *zslCreate(void) {
    int j;
    zskiplist *zsl;
    //分配內存空間
    zsl = zmalloc(sizeof(*zsl));
    //默認只有一層索引
    zsl->level = 1;
    //0 個節點
    zsl->length = 0;
    //一、建立一個 node 節點,這是個哨兵節點
    //二、爲 level 數組分配 ZSKIPLIST_MAXLEVEL=32 內存大小
    //三、也即 redis 中支持索引最大 32 層
    zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
    //爲哨兵節點的 level 初始化
    for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
        zsl->header->level[j].forward = NULL;
        zsl->header->level[j].span = 0;
    }
    zsl->header->backward = NULL;
    zsl->tail = NULL;
    return zsl;
}
複製代碼

zslCreate 用於初始化一個跳錶,比較簡單,我也給出了基本的註釋,這裏再也不贅述了,強調一點的是,redis 中實現的跳錶最高容許 32 層索引,這麼作也是一種性能與內存之間的衡量,過多的索引層必然佔用更多的內存空間,32 是一個比較合適值。

二、插入一個節點

插入一個節點的代碼比較多,也稍微有點複雜,但願你也有耐心和我一塊兒來分析。

zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    //update數組將用於記錄新節點在每一層索引的目標插入位置
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    //rank數組記錄目標節點每一層的排名
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;

    serverAssert(!isnan(score));
    //指向哨兵節點
    x = zsl->header;
    //這一段就是遍歷每一層索引,找到最後一個小於當前給定score值的節點
    //從高層索引向底層索引遍歷
    for (i = zsl->level-1; i >= 0; i--) {
        //rank記錄的是節點的排名,正常狀況下給它初始值等於上一層目標節點的排名
        //若是當前正在遍歷最高層索引,那麼這個初始值暫時給0
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
        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)))
        {
            //咱們說過level結構中,span表示的是與後面一個節點的跨度
            //rank[i]最終會獲得咱們要找的目標節點的排名,也就是它前面有多少個節點
            rank[i] += x->level[i].span;
            //挪動指針
            x = x->level[i].forward;
        }
        update[i] = x;
    }
    //至此,update數組中已經記錄好,每一層最後一個小於給定score值的節點
    //咱們的新節點只須要插在他們後便可
    
    //random算法獲取一個平衡跳錶的level值,標誌着咱們的新節點將要在哪些索引出現
    //具體算法這裏不作分析,你也能夠私下找我討論
    level = zslRandomLevel();
    //若是產生值大於當前跳錶最高索引
    if (level > zsl->level) {
        //爲高出來的索引層賦初始值,update[i]指向哨兵節點
        for (i = zsl->level; i < level; i++) {
            rank[i] = 0;
            update[i] = zsl->header;
            update[i]->level[i].span = zsl->length;
        }
        zsl->level = level;
    }
    //根據score和ele建立節點
    x = zslCreateNode(level,score,ele);
    //每一索引層得進行新節點插入,建議對照我以前給出的跳錶示意圖
    for (i = 0; i < level; i++) {
        //斷開指針,插入新節點
        x->level[i].forward = update[i]->level[i].forward;
        update[i]->level[i].forward = x;

        //rank[0]等於新節點再最底層鏈表的排名,就是它前面有多少個節點
        //update[i]->level[i].span記錄的是目標節點與後一個索引節點之間的跨度,即跨越了多少個節點
        //獲得新插入節點與後一個索引節點之間的跨度
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
        //修改目標節點的span值
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }

    //若是上面產生的平衡level大於跳錶最高使用索引,咱們上面說會爲高出部分作初始化
    //這裏是自增他們的span值,由於新插入了一個節點,跨度天然要增長
    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }

    //修改 backward 指針與 tail 指針
    x->backward = (update[0] == zsl->header) ? NULL : update[0];
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;
    zsl->length++;
    return x;
}
複製代碼

整個方法我都已經給出了註釋,具體的再也不細說,歡迎你與我交流討論,總體的邏輯分爲三個步驟。

  1. 從最高索引層開始遍歷,根據 score 找到它的前驅節點,用 update 數組進行保存
  2. 每一層得進行節點的插入,並計算更新 span 值
  3. 修改 backward 指針與 tail 指針

刪除節點也是相似的,首先須要根據 score 值找到目標節點,而後斷開先後節點的鏈接,完成節點刪除。

三、特殊的查詢操做

由於 redis 的跳錶實現中,增設了 span 這個跨度字段,它記錄了與當前節點與後一個節點之間的跨度,因此就具備如下一些查詢方法。

a、zslGetRank

返回包含給定成員和分值的節點在跳躍表中的排位。

unsigned long zslGetRank(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *x;
    unsigned long rank = 0;
    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))) {
            rank += x->level[i].span;
            x = x->level[i].forward;
        }

        /* x might be equal to zsl->header, so test if obj is non-NULL */
        if (x->ele && sdscmp(x->ele,ele) == 0) {
            return rank;
        }
    }
    return 0;
}
複製代碼

你會發現,這個方法的核心代碼其實就是咱們插入節點方法的一個部分,經過累計 span 獲得目標節點的一個排名值。

b、zslGetElementByRank

經過給定排名查詢元素。這個方法就更簡單了。

c、zslIsInRange

給定一個分值範圍(range), 好比 0 到 10, 若是給定的分值範圍包含在跳躍表的分值範圍以內, 那麼返回 1 ,不然返回 0 。

d、zslFirstInRange

給定一個分值範圍, 返回跳躍表中第一個符合這個範圍的節點。

e、zslDeleteRangeByScore

給定一個分值範圍, 刪除跳躍表中全部在這個範圍以內的節點。

f、zslDeleteRangeByRank

給定一個排名範圍, 刪除跳躍表中全部在這個範圍以內的節點。

其實,後面列出來的那些根據排名,甚至一個範圍查詢刪除節點的方法,都仰仗的是 span 這個字段,這也是爲何 insert 方法中須要經過那麼複雜的計算邏輯對 span 字段進行計算的一個緣由。

總結一下,跳錶是爲有序集合服務的,經過多層索引把鏈表的搜索效率提高到 O(logn)級別,但修改刪除依然是 O(1),是一個較爲優秀的數據結構,而 redis 中的實現把每一個節點實現成相似樓房同樣的結構,也即咱們的索引層,很是的巧妙。

關於跳錶咱們暫時介紹到這,若是有疑問也很是歡迎你與我交流討論。


關注公衆不迷路,一個愛分享的程序員。
公衆號回覆「1024」加做者微信一塊兒探討學習!
每篇文章用到的全部案例代碼素材都會上傳我我的 github
github.com/SingleYam/o…
歡迎來踩!

YangAM 公衆號
相關文章
相關標籤/搜索