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

注意:本系列文章分析的 Redis 源碼版本:github.com/Sidfate/red… ,是文章發佈時間的最新版。node

你們知道 redis 五種經常使用的數據結構有:字符串(string), 散列(hash), 列表(list), 集合(set)和有序集合(sorted set) 。相對而言 sorted set(如下簡稱爲zset) 用的相對較少,它他的實現結構卻頗有趣,這種結構被稱爲 跳躍列表 skiplist ,後面我還會結合一個平常生活的例子來解釋它。git

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

有序集合的數據類型,相似於集合(set)和散列表(hash)之間的混合。有序集合和集合同樣,元素是惟一的。而且有序集合中的每一個元素均可以對應一個score值,因此它也像一個散列表。另外,有序集合中的元素能夠根據score值進行排序遍歷。redis

zset

看了上面的介紹,你可能已經猜到了,zset 是 2 種數據結構的結合,在源碼的註釋中是這麼解釋的:數組

Zset 是使用了 2 個數據結構來保存相同元素的有序集合,同時還能保證複雜度爲 O(log(N)) 的插入和刪除操做。Zset 中的元素被添加到一個散列表中,保存着 Redis對象 - score 的映射關係。同時,這些元素被添加到一個跳躍列表 skiplist 中,將 score 映射到 Redis對象(對象根據 score 排序)。數據結構

注意下這句: 「同時還能保證複雜度爲 O(log(N)) 的插入和刪除操做」,有沒有一種婆婆介紹兒子的趕腳,言語間透露的自豪感,請記住它,下面我還會提到。散列表(hash),在以前的文章中已經分析過了,具體能夠查看個人文章《【最完整系列】Redis-結構篇-字典》,因此再也不說明了,接下來着重分析下 skiplist。less

skiplist

跳躍列表在很早以前就已經被髮明瞭,有興趣的能夠看下[它的歷史](Skip Lists: A Probabilistic Alternative to Balanced Trees)。首先從名字上看它是一個 list,而且咱們以前說過它仍是有序的。通常來講,有序鏈表長這個樣子:dom

最左側節點爲空的頭節點,a 是我本身取得名字,方便作區分。函數

思考下,咱們插入一個新的元素 「23」 須要怎麼作,首先要遍歷鏈表,比較節點元素直到找到一個大於 「23」 的元素,因此複雜度爲 O(N),刪除某個元素也是一個道理,大家發現沒,其實添加和刪除後就是一個查詢的過程。post

爲此,若是咱們稍作優化,小小地改變一下鏈表的結構,爲相鄰的節點增長一個指針,指向下下個節點:

上圖中能夠發現造成了一個新鏈表 b(7 - 19 - 26),節點個數爲原先鏈表,這時候咱們從新去查詢 「23」 :

  1. 遍歷鏈表 b,查詢到第一個大於 23 的元素 「26」
  2. 回到鏈表 a,發現 21 比 23 小,因此插入到 「21」 和 「26」 節點之間。

大家有沒有發現,是否是很相似於二分查找,最終咱們減小了查詢的次數,咱們甚至還能夠再分一次建立一個新鏈表 c:

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

  1. 遍歷鏈表 c,只有一個元素 「19「,發現 23 大於 19,因此在 「19「 後面找。
  2. 回到鏈表 b,查詢到第一個大於 23 的元素 「26」 。
  3. 回到鏈表 a,發現 21 比 23 小,因此插入到 「21」 和 「26」 節點之間。

能夠發現咱們遍歷的元素個數在逐漸減小,可想而知若是包含的元素個數足夠大,查詢的效率也會大幅提高。下面我舉一個平常生活的例子來講明這種作法的優點:

咱們在有序鏈表中查詢一個元素的過程就比如在酒店裏坐電梯。假如酒店有10層樓高,1-5層是普通套房,住的人多;6-10層是高級套房,住的人少。我住在9樓(嘿嘿嘿),那麼我坐電梯下去 1 層的時候極可能在各個低層會停留(因低層住的人多),這對於高層客人確定不爽。後面酒店改了,新造了電梯,分紅了單雙停靠,對我來講確定是比以前更快了,可是一、三、5層仍是會常常停靠,高層客人仍是不滿意。在以後酒店作絕了,直接造了一個1-5層不停留直達高層的電梯,這下舒服了,客戶評價立刻上去了。

若是你看懂了上面的例子,其實也發現了這個作法的一個劣勢:須要造更多的 「電梯「,也就是須要建立更多的鏈表,黑話叫 「空間換時間」。

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

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

爲了減小一部分童鞋的疑惑,我先總結一下上圖過程的 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 的圖:

箭頭中上的數字就是 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) 的時間複雜度。

相關文章
相關標籤/搜索