為什么有序集合需要同時使用跳躍表和字典來實現?


有序集合對象 — Redis 設計與實現 http://redisbook.com/preview/object/sorted_set.html

/* ZSETs use a specialized version of Skiplists */
/*
 * 跳躍表節點
 */
typedef struct zskiplistNode {

    // 成員對象
    robj *obj;

    // 分值
    double score;

    // 后退指針
    struct zskiplistNode *backward;

    // 層
    struct zskiplistLevel {

        // 前進指針
        struct zskiplistNode *forward;

        // 跨度
        unsigned int span;

    } level[];

} zskiplistNode;

/*
 * 跳躍表
 */
typedef struct zskiplist {

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

    // 表中節點的數量
    unsigned long length;

    // 表中層數最大的節點的層數
    int level;

} zskiplist;

/*
 * 有序集合
 */
typedef struct zset {

    // 字典,鍵為成員,值為分值
    // 用於支持 O(1) 復雜度的按成員取分值操作
    dict *dict;

    // 跳躍表,按分值排序成員
    // 用於支持平均復雜度為 O(log N) 的按分值定位成員操作
    // 以及范圍操作
    zskiplist *zsl;

} zset;

 

有序集合對象

有序集合的編碼可以是 ziplist 或者 skiplist 。

ziplist 編碼的有序集合對象使用壓縮列表作為底層實現, 每個集合元素使用兩個緊挨在一起的壓縮列表節點來保存, 第一個節點保存元素的成員(member), 而第二個元素則保存元素的分值(score)。

壓縮列表內的集合元素按分值從小到大進行排序, 分值較小的元素被放置在靠近表頭的方向, 而分值較大的元素則被放置在靠近表尾的方向。

舉個例子, 如果我們執行以下 ZADD 命令, 那么服務器將創建一個有序集合對象作為 price 鍵的值:

redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry (integer) 3 

如果 price 鍵的值對象使用的是 ziplist 編碼, 那么這個值對象將會是圖 8-14 所示的樣子, 而對象所使用的壓縮列表則會是 8-15 所示的樣子。

digraph {

    label = "\n 圖 8-14    ziplist 編碼的有序集合對象";

    rankdir = LR;

    node [shape = record];

    redisObject [label = " redisObject | type \n REDIS_ZSET | encoding \n REDIS_ENCODING_ZIPLIST | <ptr> ptr | ... "];

    ziplist [label = "壓縮列表", width = 4.0];

    redisObject:ptr -> ziplist;

}

digraph {

    label = "\n 圖 8-15    有序集合元素在壓縮列表中按分值從小到大排列";

    //

    node [shape = record];

    ziplist [label = " zlbytes | zltail | zllen | <banana> \"banana\" | <banana_price> 5.0 | <cherry> \"cherry\" | <cherry_price> 6.0 | <apple> \"apple\" | <apple_price> 8.5 | zlend  "];

    node [shape = plaintext];

    banana [label = "分值最少的元素"];
    cherry [label = "分值排第二的元素"];
    apple [label = "分值最大的元素"];

    //

    edge [style = dashed]

    banana -> ziplist:banana [label = "成員"];
    banana -> ziplist:banana_price [label = "分值"];

    cherry -> ziplist:cherry;
    cherry -> ziplist:cherry_price;

    apple -> ziplist:apple;
    apple -> ziplist:apple_price;

}

skiplist 編碼的有序集合對象使用 zset 結構作為底層實現, 一個 zset 結構同時包含一個字典和一個跳躍表:

typedef struct zset { zskiplist *zsl; dict *dict; } zset; 

zset 結構中的 zsl 跳躍表按分值從小到大保存了所有集合元素, 每個跳躍表節點都保存了一個集合元素: 跳躍表節點的 object 屬性保存了元素的成員, 而跳躍表節點的 score 屬性則保存了元素的分值。 通過這個跳躍表, 程序可以對有序集合進行范圍型操作, 比如 ZRANK 、 ZRANGE 等命令就是基於跳躍表 API 來實現的。

除此之外, zset 結構中的 dict 字典為有序集合創建了一個從成員到分值的映射, 字典中的每個鍵值對都保存了一個集合元素: 字典的鍵保存了元素的成員, 而字典的值則保存了元素的分值。 通過這個字典, 程序可以用 O(1) 復雜度查找給定成員的分值, ZSCORE 命令就是根據這一特性實現的, 而很多其他有序集合命令都在實現的內部用到了這一特性。

有序集合每個元素的成員都是一個字符串對象, 而每個元素的分值都是一個 double 類型的浮點數。 值得一提的是, 雖然 zset 結構同時使用跳躍表和字典來保存有序集合元素, 但這兩種數據結構都會通過指針來共享相同元素的成員和分值, 所以同時使用跳躍表和字典來保存集合元素不會產生任何重復成員或者分值, 也不會因此而浪費額外的內存。

為什么有序集合需要同時使用跳躍表和字典來實現?

在理論上來說, 有序集合可以單獨使用字典或者跳躍表的其中一種數據結構來實現, 但無論單獨使用字典還是跳躍表, 在性能上對比起同時使用字典和跳躍表都會有所降低。

舉個例子, 如果我們只使用字典來實現有序集合, 那么雖然以 O(1) 復雜度查找成員的分值這一特性會被保留, 但是, 因為字典以無序的方式來保存集合元素, 所以每次在執行范圍型操作 —— 比如 ZRANK 、 ZRANGE 等命令時, 程序都需要對字典保存的所有元素進行排序, 完成這種排序需要至少 O(N \log N) 時間復雜度, 以及額外的 O(N) 內存空間 (因為要創建一個數組來保存排序后的元素)。

另一方面, 如果我們只使用跳躍表來實現有序集合, 那么跳躍表執行范圍型操作的所有優點都會被保留, 但因為沒有了字典, 所以根據成員查找分值這一操作的復雜度將從 O(1) 上升為 O(\log N) 。

因為以上原因, 為了讓有序集合的查找和范圍型操作都盡可能快地執行, Redis 選擇了同時使用字典和跳躍表兩種數據結構來實現有序集合。

舉個例子, 如果前面 price 鍵創建的不是 ziplist 編碼的有序集合對象, 而是 skiplist 編碼的有序集合對象, 那么這個有序集合對象將會是圖 8-16 所示的樣子, 而對象所使用的 zset 結構將會是圖 8-17 所示的樣子。

digraph {

    label = "\n 圖 8-16    skiplist 編碼的有序集合對象";

    rankdir = LR;

    node [shape = record];

    redisObject [label = " redisObject | type \n REDIS_ZSET | encoding \n REDIS_ENCODING_SKIPLIST | <ptr> ptr | ... "];

    zset [label = " <head> zset | <dict> dict | <zsl> zsl "];

    node [shape = plaintext];

    dict [label = "..."];

    zsl [label = "..."];

    redisObject:ptr -> zset:head;
    zset:dict -> dict;
    zset:zsl -> zsl;

}

digraph {

    rankdir = LR;

    //

    node [shape = record];

    zset [label = " <head> zset | <dict> dict | <zsl> zsl "];

    dict [label = " <head> dict | ... | <ht0> ht[0] | ... "];

    ht0 [label = " <head> dictht | ... | <table> table | ... "];

    table [label = " <banana> StringObject \n \"banana\" | <apple> StringObject \n \"apple\" | <cherry> StringObject \n \"cherry\" "];

    node [shape = plaintext];

    apple_price [label = "8.5"];
    banana_price [label = "5.0"];
    cherry_price [label = "6.0"];

    //

    zset:dict -> dict:head;
    dict:ht0 -> ht0:head;
    ht0:table -> table:head;

    table:apple -> apple_price;
    table:banana -> banana_price;
    table:cherry -> cherry_price;

    //

    node [shape = record, width = "0.5"];

    //

    l [label = " <header> header | <tail> tail | level \n 5 | length \n 3 "];

    subgraph cluster_nodes {

        style = invisible;

        header [label = " <l32> L32 | ... | <l5> L5 | <l4> L4 | <l3> L3 | <l2> L2 | <l1> L1 "];

        bw_null [label = "NULL", shape = plaintext];

        level_null [label = "NULL", shape = plaintext];

        A [label = " <l4> L4 | <l3> L3 | <l2> L2 | <l1> L1 | <backward> BW | 5.0 | StringObject \n \"banana\" "];

        B [label = " <l2> L2 | <l1> L1 | <backward> BW | 6.0 | StringObject \n \"cherry\" "];

        C [label = " <l5> L5 | <l4> L4 | <l3> L3 | <l2> L2 | <l1> L1 | <backward> BW | 8.5 | StringObject \n \"apple\" "];

    }

    subgraph cluster_nulls {

        style = invisible;

        n1 [label = "NULL", shape = plaintext];
        n2 [label = "NULL", shape = plaintext];
        n3 [label = "NULL", shape = plaintext];
        n4 [label = "NULL", shape = plaintext];
        n5 [label = "NULL", shape = plaintext];

    }

    //

    l:header -> header;
    l:tail -> C;

    header:l32 -> level_null;
    header:l5 -> C:l5;
    header:l4 -> A:l4;
    header:l3 -> A:l3;
    header:l2 -> A:l2;
    header:l1 -> A:l1;

    A:l4 -> C:l4;
    A:l3 -> C:l3;
    A:l2 -> B:l2;
    A:l1 -> B:l1;

    B:l2 -> C:l2;
    B:l1 -> C:l1;

    C:l5 -> n5;
    C:l4 -> n4;
    C:l3 -> n3;
    C:l2 -> n2;
    C:l1 -> n1;

    bw_null -> A:backward -> B:backward -> C:backward [dir = back];

    zset:zsl -> l:header;

    // HACK: 放在開頭的話 NULL 指針的長度會有異樣
    label = "\n 圖 8-17    有序集合元素同時被保存在字典和跳躍表中";

}

注意

為了展示方便, 圖 8-17 在字典和跳躍表中重復展示了各個元素的成員和分值, 但在實際中, 字典和跳躍表會共享元素的成員和分值, 所以並不會造成任何數據重復, 也不會因此而浪費任何內存。

編碼的轉換

當有序集合對象可以同時滿足以下兩個條件時, 對象使用 ziplist 編碼:

  1. 有序集合保存的元素數量小於 128 個;
  2. 有序集合保存的所有元素成員的長度都小於 64 字節;

不能滿足以上兩個條件的有序集合對象將使用 skiplist 編碼。

注意

以上兩個條件的上限值是可以修改的, 具體請看配置文件中關於 zset-max-ziplist-entries 選項和 zset-max-ziplist-value 選項的說明。

對於使用 ziplist 編碼的有序集合對象來說, 當使用 ziplist 編碼所需的兩個條件中的任意一個不能被滿足時, 程序就會執行編碼轉換操作, 將原本儲存在壓縮列表里面的所有集合元素轉移到 zset 結構里面, 並將對象的編碼從 ziplist 改為 skiplist 。

以下代碼展示了有序集合對象因為包含了過多元素而引發編碼轉換的情況:

# 對象包含了 128 個元素
redis> EVAL "for i=1, 128 do redis.call('ZADD', KEYS[1], i, i) end" 1 numbers (nil) redis> ZCARD numbers (integer) 128 redis> OBJECT ENCODING numbers "ziplist" # 再添加一個新元素 redis> ZADD numbers 3.14 pi (integer) 1 # 對象包含的元素數量變為 129 個 redis> ZCARD numbers (integer) 129 # 編碼已改變 redis> OBJECT ENCODING numbers "skiplist" 

以下代碼則展示了有序集合對象因為元素的成員過長而引發編碼轉換的情況:

# 向有序集合添加一個成員只有三字節長的元素
redis> ZADD blah 1.0 www (integer) 1 redis> OBJECT ENCODING blah "ziplist" # 向有序集合添加一個成員為 66 字節長的元素 redis> ZADD blah 2.0 oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo (integer) 1 # 編碼已改變 redis> OBJECT ENCODING blah "skiplist" 

有序集合命令的實現

因為有序集合鍵的值為有序集合對象, 所以用於有序集合鍵的所有命令都是針對有序集合對象來構建的, 表 8-11 列出了其中一部分有序集合鍵命令, 以及這些命令在不同編碼的有序集合對象下的實現方法。


表 8-11 有序集合命令的實現方法

命令 ziplist 編碼的實現方法 zset 編碼的實現方法
ZADD 調用 ziplistInsert 函數, 將成員和分值作為兩個節點分別插入到壓縮列表。 先調用 zslInsert 函數, 將新元素添加到跳躍表, 然后調用 dictAdd 函數, 將新元素關聯到字典。
ZCARD 調用 ziplistLen 函數, 獲得壓縮列表包含節點的數量, 將這個數量除以 2 得出集合元素的數量。 訪問跳躍表數據結構的 length 屬性, 直接返回集合元素的數量。
ZCOUNT 遍歷壓縮列表, 統計分值在給定范圍內的節點的數量。 遍歷跳躍表, 統計分值在給定范圍內的節點的數量。
ZRANGE 從表頭向表尾遍歷壓縮列表, 返回給定索引范圍內的所有元素。 從表頭向表尾遍歷跳躍表, 返回給定索引范圍內的所有元素。
ZREVRANGE 從表尾向表頭遍歷壓縮列表, 返回給定索引范圍內的所有元素。 從表尾向表頭遍歷跳躍表, 返回給定索引范圍內的所有元素。
ZRANK 從表頭向表尾遍歷壓縮列表, 查找給定的成員, 沿途記錄經過節點的數量, 當找到給定成員之后, 途經節點的數量就是該成員所對應元素的排名。 從表頭向表尾遍歷跳躍表, 查找給定的成員, 沿途記錄經過節點的數量, 當找到給定成員之后, 途經節點的數量就是該成員所對應元素的排名。
ZREVRANK 從表尾向表頭遍歷壓縮列表, 查找給定的成員, 沿途記錄經過節點的數量, 當找到給定成員之后, 途經節點的數量就是該成員所對應元素的排名。 從表尾向表頭遍歷跳躍表, 查找給定的成員, 沿途記錄經過節點的數量, 當找到給定成員之后, 途經節點的數量就是該成員所對應元素的排名。
ZREM 遍歷壓縮列表, 刪除所有包含給定成員的節點, 以及被刪除成員節點旁邊的分值節點。 遍歷跳躍表, 刪除所有包含了給定成員的跳躍表節點。 並在字典中解除被刪除元素的成員和分值的關聯。
ZSCORE 遍歷壓縮列表, 查找包含了給定成員的節點, 然后取出成員節點旁邊的分值節點保存的元素分值。 直接從字典中取出給定成員的分值。

 

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM