Redis中的強大的資料結構跳躍表(skiplist)的內部詳解及實際運用

spacedong發表於2018-10-30

前言

跳躍表結構在 Redis 中的運用場景只有一個,那就是作為有序列表 (Zset) 的使用。跳躍表的效能可以保證在查詢,刪除,新增等操作的時候在對數期望時間內完成,這個效能是可以和平衡樹來相比較的,而且在實現方面比平衡樹要優雅,這就是跳躍表的長處。跳躍表的缺點就是需要的儲存空間比較大,屬於利用空間來換取時間的資料結構。接下來我們思考三個問題:

思考三個問題

  • 跳躍表的底層結構是什麼樣的,為什麼可以支撐它在對數期望時間內完成基本操作(增刪改查)?
  • 在跳躍表中,完成一個元素的增刪改查的詳細過程是怎樣的?
  • 利用跳躍表作為底層資料結構的有序列表,在實際的業務場景中有什麼運用?

跳躍表結構

跳躍表結構如下:

跳躍表是長下面這樣的(圖片來自於維基百科)
在跳躍表中,每個跳躍表的節點都會維護著一個 score 的值,這個值在跳躍表中是按照大小排好序的。

跳躍表的資料結構原始碼

    typedef struct zskiplist {

    // 頭節點,尾節點
    struct zskiplistNode *header, *tail;

    // 節點數量
    unsigned long length;

    // 目前表內節點的最大層數
    int level;

} zskiplist;
複製程式碼
  • header 指向了跳躍表的頭結點,tail 指向跳躍表的尾節點
  • length 表示了跳躍表節點中的數量
  • level 表示跳躍表的表內節點的最大層數

跳躍表的節點結構如下圖所示

typedef struct zskiplistNode {

    // member 物件
    robj *obj;

    // 分值
    double score;

    // 後退指標
    struct zskiplistNode *backward;

    // 層
    struct zskiplistLevel {

        // 前進指標
        struct zskiplistNode *forward;

        // 這個層跨越的節點數量
        unsigned int span;

    } level[];

} zskiplistNode;
複製程式碼

直觀來感受下跳躍表結構

跳躍表節點圖

  • obj (成員物件):對應的是圖中的 o1, o2, o3,是用來儲存一個節點中的物件的。
  • score (分值):對應的是每一個成員物件中的 1.0,2.0 等分數值。
  • 後退指標:這個指標指向的是前面的一個跳錶節點。
  • :這個結構包括前進指標和記錄了跨越的節點數量,這塊就是跳躍表的精髓所在。

跳躍表的基本結構就是上面所展示的部分,接下來我們開始進行分析跳躍表的基礎操作過程(增刪改查)

跳躍表增刪查改過程

image
一個跳躍表的一個節點是 64 層,能夠儲存的節點數量應該 2^64 個。在原始碼中是這樣的,官方沒有其他的解釋。

define ZSKIPLIST_MAXLEVEL 64 /* Should be enough for 2^64 elements */
複製程式碼
查詢過程

按照圖中所示,我們現在需要查詢的是值為 7 的這個節點。步驟如下:

  • 從 head 節點開始,為了演示方便,這裡顯示的是4層,實際上的是64層。先是降一層到值 4 這個節點的這一層。如果不是所需要的值,那麼就再降一層,跳躍到值為 6 的這一層。最後查詢到值為 7 。這就是查詢的過程,時間複雜度為 O(lg(n))
插入過程

插入的過程和查詢的過程類似:比如要插入的值為 6

  • 從 head 節點開始,先是在 head 開始降層來查詢到最後一個比 6 小的節點,等到查到最後一個比 6 小的節點的時候(假設為 5 )。然後需要引入一個隨機層數演算法來為這個節點隨機地建立層數。把這個節點插入進去以後,同時更新一遍最高的層數即可。
隨機演算法
/* Returns a random level for the new skiplist node we are going to create.
 * The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL
 * (both inclusive), with a powerlaw-alike distribution where higher
 * levels are less likely to be returned. */
int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
複製程式碼

Redis 原始碼中的晉升概率為25%,所以相對來說,Redis 的層高數相對來說是比較扁平化,層高相對較低,所以需要遍歷的節點數量會多一些。

刪除過程
  • 刪除的過程也是和查詢的過程一樣,先是找到要刪除的那個值,再把這個值給刪除,同時把重排一下指標和更新最高的層數。
更新過程
  • 更新的過程和插入的過程都是是使用著 zadd 方法的,先是判斷這個 value 是否存在,如果存在就是更新的過程,如果不存在就是插入過程。在更新的過程是,如果找到了Value,先刪除掉,再新增,這樣的弊端是會做兩次的搜尋,在效能上來講就比較慢了,在 Redis 5.0 版本中,Redis 的作者 Antirez 優化了這個更新的過程,目前的更新過程是如果判斷這個 value是否存在,如果存在的話就直接更新,然後再調整整個跳躍表的 score 排序,這樣就不需要兩次的搜尋過程。 可以看看關於 Antirez 這次的更新優化程式碼。

實際的業務場景

Zset 資料結構

image

  • 如圖所示,Zset 的資料結構是有一個 hash 表和一個跳躍表來結合的,hash 表上儲存的是關於 String 的值和 Score 的值,跳躍表是用來輔助 hash 表來實現關於按照 score 來排序的功能。

所以跳躍表的實際運用場景就是 Zset 的實際運用場景

Zset的使用示例

//給某個集合增加權重和成員
//成員不可以為重複,權重可以重複,一個集合可以容納到2^32-1個元素
//增加元素
redis 127.0.0.1:6379> ZADD spacedong 1 redis
redis 127.0.0.1:6379> ZADD spacedong 2 mongodb
redis 127.0.0.1:6379> ZADD spacedong 3 mysql

//獲取集合中的元素個數
redis 127.0.0.1:6379> ZCARD spacedong 
"3"

//獲取集合中的某個範圍的成員
redis 127.0.0.1:6379> ZRANGE spacedong 0 2
1)  "redis"
2)  "mongodb"
3)  "mysql"
複製程式碼

Zset的實際運用場景

  • 在 Zset 中使用最多的場景就是涉及到排行榜類似的場景。例如實時統計一個關於分數的排行榜,這個時候可以使用 Redis 中的這個 ZSET 資料結構來維護。
  • 涉及到需要按照時間的順序來排行的業務場景,例如如果需要維護一個問題池,按照時間的先後順序來維護,這個時候也可以使用 Zset ,把時間當做權重,把問題當做 key 值來進行存取。

參考資料:

想要獲得更多的優質技術文章,可以關注下方的微信公眾號 spacedong

image

為優質的文章而讚賞,這是為作者能持續輸出的最大肯定!

image

相關文章