【最完整系列】Redis-結構篇-跳躍列表

sidfate發表於2020-01-31

注意:本系列文章分析的 Redis 原始碼版本:github.com/Sidfate/red… ,是文章釋出時間的最新版。

大家知道 redis 五種常用的資料結構有:字串(string), 雜湊(hash), 列表(list), 集合(set)和有序集合(sorted set) 。相對而言 sorted set(以下簡稱為zset) 用的相對較少,它他的實現結構卻很有趣,這種結構被稱為 跳躍列表 skiplist ,後面我還會結合一個日常生活的例子來解釋它。

首先簡單介紹下 zset 是個啥有什麼用,已經瞭解的童鞋可以直接到下一章節。

有序集合的資料型別,類似於集合(set)和雜湊表(hash)之間的混合。有序集合和集合一樣,元素是唯一的。並且有序集合中的每個元素都可以對應一個score值,所以它也像一個雜湊表。另外,有序集合中的元素可以根據score值進行排序遍歷。

zset

看了上面的介紹,你可能已經猜到了,zset 是 2 種資料結構的結合,在原始碼的註釋中是這麼解釋的:

Zset 是使用了 2 個資料結構來儲存相同元素的有序集合,同時還能保證複雜度為 O(log(N)) 的插入和刪除操作。Zset 中的元素被新增到一個雜湊表中,儲存著 Redis物件 - score 的對映關係。同時,這些元素被新增到一個跳躍列表 skiplist 中,將 score 對映到 Redis物件(物件根據 score 排序)。

注意下這句: “同時還能保證複雜度為 O(log(N)) 的插入和刪除操作”,有沒有一種婆婆介紹兒子的趕腳,言語間透露的自豪感,請記住它,下面我還會提到。雜湊表(hash),在之前的文章中已經分析過了,具體可以檢視我的文章《【最完整系列】Redis-結構篇-字典》,所以不再說明了,接下來著重分析下 skiplist。

skiplist

跳躍列表在很早之前就已經被發明瞭,有興趣的可以看下[它的歷史](Skip Lists: A Probabilistic Alternative to Balanced Trees)。首先從名字上看它是一個 list,並且我們之前說過它還是有序的。一般來說,有序連結串列長這個樣子:

【最完整系列】Redis-結構篇-跳躍列表

最左側節點為空的頭節點,a 是我自己取得名字,方便做區分。

思考下,我們插入一個新的元素 “23” 需要怎麼做,首先要遍歷連結串列,比較節點元素直到找到一個大於 “23” 的元素,所以複雜度為 O(N),刪除某個元素也是一個道理,你們發現沒,其實新增和刪除後就是一個查詢的過程。

為此,如果我們稍做優化,小小地改變一下連結串列的結構,為相鄰的節點增加一個指標,指向下下個節點:

【最完整系列】Redis-結構篇-跳躍列表

上圖中可以發現形成了一個新連結串列 b(7 - 19 - 26),節點個數為原先連結串列,這時候我們重新去查詢 “23” :

  1. 遍歷連結串列 b,查詢到第一個大於 23 的元素 “26”
  2. 回到連結串列 a,發現 21 比 23 小,所以插入到 “21” 和 “26” 節點之間。

你們有沒有發現,是不是很類似於二分查詢,最終我們減少了查詢的次數,我們甚至還可以再分一次建立一個新連結串列 c:

【最完整系列】Redis-結構篇-跳躍列表

這時候的查詢步驟變成了:

  1. 遍歷連結串列 c,只有一個元素 “19“,發現 23 大於 19,所以在 “19“ 後面找。
  2. 回到連結串列 b,查詢到第一個大於 23 的元素 “26” 。
  3. 回到連結串列 a,發現 21 比 23 小,所以插入到 “21” 和 “26” 節點之間。

可以發現我們遍歷的元素個數在逐漸減少,可想而知如果包含的元素個數足夠大,查詢的效率也會大幅提升。下面我舉一個日常生活的例子來說明這種做法的優勢:

我們在有序連結串列中查詢一個元素的過程就好比在酒店裡坐電梯。假如酒店有10層樓高,1-5層是普通套房,住的人多;6-10層是高階套房,住的人少。我住在9樓(嘿嘿嘿),那麼我坐電梯下去 1 層的時候很可能在各個低層會停留(因低層住的人多),這對於高層客人肯定不爽。後面酒店改了,新造了電梯,分成了單雙停靠,對我來說肯定是比之前更快了,但是1、3、5層還是會經常停靠,高層客人還是不滿意。在之後酒店做絕了,直接造了一個1-5層不停留直達高層的電梯,這下舒服了,客戶評價馬上上去了。

如果你看懂了上面的例子,其實也發現了這個做法的一個劣勢:需要造更多的 “電梯“,也就是需要建立更多的連結串列,黑話叫 “空間換時間”。

我們的 skiplist 正是在上面這種多層連結串列基礎上設計而來的。實際上,按照上面生成連結串列的方式,上面每一層連結串列的節點個數,是下面一層的節點個數的一半,類似於一個二分查詢,使得查詢的時間複雜度可以降低到 O(log n) (還記得我之前讓你記住的那個複雜度嗎?)。但是,這種方法在插入資料的時候有很大的問題。新插入一個節點之後,就會破壞了上下相鄰兩層連結串列上節點個數 2:1 的比例關係。要維持這種對應關係,就必須把新插入的節點後面的所有節點(也包括新插入的節點)重新進行調整,這會讓時間複雜度又降低成了 O(n),刪除節點也一樣。

shiplist 當然也考慮到這個問題,為此,它不要求上下相鄰兩層連結串列之間的節點個數有嚴格的比例關係,而是每個節點隨機一個層數 level 。比如,一個節點隨機出的層數是 3,那麼就把它鏈入到第 1 層到第 3 層這三層連結串列中。為了表達清楚,下圖展示瞭如何通過一步步的插入操作從而形成一個skiplist的過程:

【最完整系列】Redis-結構篇-跳躍列表

為了減少一部分童鞋的疑惑,我先總結一下上圖過程的 3 個特點:

  • 第一層連結串列永遠儲存著最完整的元素資料。
  • 位於第n層的元素,也肯定在 1~n-1層 之中。
  • 新插入一個元素不會影響其它元素的層數。

前方高暈預警,以下內容涉及一些概率學和數學知識,摘自發明者的論文。可以直接跳過檢視下一章節。

比較有意思的一點是元素的層數隨機,這意味著 skiplist 是一個 “概率型” 的資料結構。實際上決定層數的隨機計算對跳錶的查詢效能有著很大影響,這並不是一個普通的服從均勻分佈的隨機數,它的計算過程如下:

  1. 指定一個節點最大的層數 MaxLevel,指定一個概率 p, 層數 level 預設為 1 。
  2. 生成一個 0~1 的隨機數 r,若 r < p,且 level < MaxLevel ,則執行 level++。
  3. 重複第 2 步,直至生成的 r > p 為止,此時的 level 就是要插入的層數。

虛擬碼如下:

randomLevel()
    level = 1
    // random()返回一個[0...1)的隨機數
    while random() < p and level < MaxLevel do
        level := level + 1
    return level
複製程式碼

在 Redis 的 skiplist 實現中,p=1/4 ,MaxLevel=64。

原始碼結構

Redis 在 skiplist 的基礎結構上做了一些變化來滿足自己的需求,首先 show you source code:

#define ZSKIPLIST_MAXLEVEL 64 /* Should be enough for 2^64 elements */
#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */

typedef struct zskiplistNode {
    sds ele;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

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

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

ZSKIPLIST_MAXLEVELZSKIPLIST_P 2個常量就是我們上一章節最後提到的部分。

屬性 含義
header 頭指標
tail 尾指標
length 連結串列長度,即連結串列包含的節點總數,不包含頭指標。
level skiplist 的總層數

為什麼 zskiplistNode 的前向指標 backward 只有一個?

我發現基本上沒有文章提到這一點,但是我覺得還是有必要解釋下的。首先節點只有一個後向指標,也就意味著只有第一層的連結串列是一個雙向連結串列,之前我們的例子裡的連結串列都是單向的,為什麼要把第一層變成雙向呢?原因之一是第一層的資料最完整,原因之二是:試想一下,我們有一個元素的 score 為 8,其第一層的相鄰節點 score 為 7 和 10,現在我們想要更新這個元素的 score 為 9,理論上我們要刪除在插入,但其實這個元素的位置根本不需要改動,這種情況下可以先判斷第一層相鄰節點的大小,如果還是在區間內,就直接更新值,省去了刪除插入的步驟。

level 中 span 的意義?

解釋這個問題需要圖片幫助,首先放一個 skiplist 的圖:

【最完整系列】Redis-結構篇-跳躍列表

箭頭中上的數字就是 span 的值,span有很多好處,例如我們要找score為 3 的排名,直接取頭指標中 L5 的span = 3 就行了,如果存在需要多層查詢的情況就是累加的過程,反之還可以通過長度-累加值的操作計算逆序的排名。

屬性 含義
ele 資料本體。這裡可以看到它是一個 sds 結構,sds 是 redis 中的字串結構。關於 sds 結構的結構的詳情你參考我的文章《【最完整系列】Redis-結構篇-字串》。
score 資料對應的分數。
backward 指向連結串列前一個節點的指標(前向指標)。
level[] zskiplistLevel 陣列,存放指向各層連結串列後一個節點的指標(後向指標)。
level[].forward 表示單層的後向指標。
level[].span 表示當前的指標跨越了多少個節點。

總結下 redis 針對 skiplist 做出的 3 點調整:

  • 資料不允許重複。
  • 在比較時,不僅比較分數(相當於 skiplist 的 key),還比較資料本身。在 Redis 的 skiplist 實現中,資料本身的內容唯一標識這份資料,而不是由 key 來唯一標識。另外,當多個元素分數相同的時候,還需要根據資料內容來進字典排序。
  • 有一個後向指標,所有第一層連結串列是一個雙向連結串列。

Redis Zset 採用 skiplist 而不是平衡樹的原因

擴充套件一下,看看作者是怎麼說的:

  1. 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.

    也不是非常耗費記憶體,實際上取決於生成層數函式裡的概率 p,取決得當的話其實和平衡樹差不多。

  2. 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.

    因為有序集合經常會進行 ZRANGE 或 ZREVRANGE 這樣的範圍查詢操作,跳錶裡面的雙向連結串列可以十分方便地進行這類操作。

  3. 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.

    實現簡單,ZRANK 操作還能達到 O(logN) 的時間複雜度。

相關文章