Redis 數據結構的實現

 

 Redis 數據結構的實現

 

 

 

 

先看個對照關係:  redis

Redis數據結構 實現一 實現二
string 整數(若是value可以表示爲整數) 字符串
hash 壓縮列表(只包含少許鍵值對, 而且每一個鍵值對的鍵和值要麼就是小整數值, 要麼就是長度比較短的字符串) 字典
list 壓縮列表(只包含少許列表項, 而且每一個列表項要麼就是小整數值, 要麼就是長度比較短的字符串) 雙端鏈表
set 整數集合(當一個集合只包含整數值元素, 而且這個集合的元素數量很少時) 字典
sorted set 壓縮列表 跳錶

 

再討論每種數據結構的實現原理:數組

 

雙端鏈表

 實現以下:服務器

typedef struct listNode {

    // 前置節點
    struct listNode *prev;

    // 後置節點
    struct listNode *next;

    // 節點的值
    void *value;

} listNode;

typedef struct list {

    // 表頭節點
    listNode *head;

    // 表尾節點
    listNode *tail;

    // 鏈表所包含的節點數量
    unsigned long len;

    // 節點值複製函數
    void *(*dup)(void *ptr);

    // 節點值釋放函數
    void (*free)(void *ptr);

    // 節點值對比函數
    int (*match)(void *ptr, void *key);

} list;

 

 

 

字典(dictionay) 

Redis 的字典使用哈希表做爲底層實現, 數據結構

哈希表的實現以下:函數

typedef struct dictht {

    // 哈希表數組
    dictEntry **table;

    // 哈希表大小
    unsigned long size;

    // 哈希表大小掩碼,用於計算索引值
    // 老是等於 size - 1
    unsigned long sizemask;

    // 該哈希表已有節點的數量
    unsigned long used;

} dictht;

typedef struct dictEntry {

    //
    void *key;

    //
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;

    // 指向下個哈希表節點,造成鏈表
    struct dictEntry *next;

} dictEntry;

 

字典的實現:性能

typedef struct dict {

    // 類型特定函數
    dictType *type;

    // 私有數據
    void *privdata;

    // 哈希表,通常狀況下只使用ht[0],rehash才使用ht[1]
    dictht ht[2];

    // rehash 索引
    // 當 rehash 不在進行時,值爲 -1
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */

} dict;

 

 


擴張和收縮(rehash)

隨着操做的不斷執行, 哈希表保存的鍵值對會逐漸地增多或者減小, 爲了讓哈希表的負載因子(load factor)維持在一個合理的範圍以內, 當哈希表保存的鍵值對數量太多或者太少時, 程序須要對哈希表的大小進行相應的擴展或者收縮。優化

rehash步驟:ui

  1. 爲字典的 ht[1] 哈希表分配空間, 這個哈希表的空間大小取決於要執行的操做, 以及 ht[0] 當前包含的鍵值對數量 (也便是 ht[0].used 屬性的值):
    • 若是執行的是擴展操做, 那麼 ht[1] 的大小爲第一個大於等於 ht[0].used 2 的 2^n (2 的 n 次方冪);
    • 若是執行的是收縮操做, 那麼 ht[1] 的大小爲第一個大於等於 ht[0].used 的 2^n 。
  2. 將保存在 ht[0] 中的全部鍵值對 rehash 到 ht[1] 上面: rehash 指的是從新計算鍵的哈希值和索引值, 而後將鍵值對放置到 ht[1]哈希表的指定位置上。
  3. 當 ht[0] 包含的全部鍵值對都遷移到了 ht[1] 以後 (ht[0] 變爲空表), 釋放 ht[0] , 將 ht[1] 設置爲 ht[0] , 並在 ht[1] 新建立一個空白哈希表, 爲下一次 rehash 作準備。

當如下條件中的任意一個被知足時, 程序會自動開始對哈希表執行擴展操做:編碼

  1. 服務器目前沒有在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 而且哈希表的負載因子大於等於 1 ;
  2. 服務器目前正在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 而且哈希表的負載因子大於等於 5 ;

其中哈希表的負載因子能夠經過公式:spa

# 負載因子 = 哈希表已保存節點數量 / 哈希表大小
load_factor = ht[0].used / ht[0].size

根據 BGSAVE 命令或 BGREWRITEAOF 命令是否正在執行, 服務器執行擴展操做所需的負載因子並不相同, 這是由於在執行 BGSAVE命令或 BGREWRITEAOF 命令的過程當中, Redis 須要建立當前服務器進程的子進程, 而大多數操做系統都採用寫時複製(copy-on-write)技術來優化子進程的使用效率, 因此在子進程存在期間, 服務器會提升執行擴展操做所需的負載因子, 從而儘量地避免在子進程存在期間進行哈希表擴展操做, 這能夠避免沒必要要的內存寫入操做, 最大限度地節約內存。

另外一方面, 當哈希表的負載因子小於 0.1 時, 程序自動開始對哈希表執行收縮操做。

爲了不 rehash 對服務器性能形成影響, 服務器不是一次性將 ht[0] 裏面的全部鍵值對所有 rehash 到 ht[1] , 而是分屢次、漸進式地將 ht[0] 裏面的鍵值對慢慢地 rehash到 ht[1] 。如下是哈希表漸進式 rehash 的詳細步驟:

  1. 爲 ht[1] 分配空間, 讓字典同時持有 ht[0] 和 ht[1] 兩個哈希表。
  2. 在字典中維持一個索引計數器變量 rehashidx , 並將它的值設置爲 0 , 表示 rehash 工做正式開始。
  3. 在 rehash 進行期間, 每次對字典執行添加、刪除、查找或者更新操做時, 程序除了執行指定的操做之外, 還會順帶將 ht[0] 哈希表在 rehashidx 索引上的全部鍵值對 rehash 到 ht[1] , 當 rehash 工做完成以後, 程序將 rehashidx 屬性的值增一。
  4. 隨着字典操做的不斷執行, 最終在某個時間點上, ht[0] 的全部鍵值對都會被 rehash 至 ht[1] , 這時程序將 rehashidx 屬性的值設爲 -1 , 表示 rehash 操做已完成。

漸進式 rehash 的好處在於它採起分而治之的方式, 將 rehash 鍵值對所需的計算工做均灘到對字典的每一個添加、刪除、查找和更新操做上, 從而避免了集中式 rehash 而帶來的龐大計算量。

由於在進行漸進式 rehash 的過程當中, 字典會同時使用 ht[0] 和 ht[1] 兩個哈希表, 因此在漸進式 rehash 進行期間, 字典的刪除(delete)、查找(find)、更新(update)等操做會在兩個哈希表上進行: 好比說, 要在字典裏面查找一個鍵的話, 程序會先在 ht[0]裏面進行查找, 若是沒找到的話, 就會繼續到 ht[1] 裏面進行查找, 諸如此類。

另外, 在漸進式 rehash 執行期間, 新添加到字典的鍵值對一概會被保存到 ht[1] 裏面, 而 ht[0] 則再也不進行任何添加操做: 這一措施保證了 ht[0] 包含的鍵值對數量會只減不增, 並隨着 rehash 操做的執行而最終變成空表。

 

 

 

整數集合(intset)

 整數集合是用一個有序數組實現的,

typedef struct intset {

    // 編碼方式
    uint32_t encoding;

    // 集合包含的元素數量
    uint32_t length;

    // 保存元素的數組
    int8_t contents[];

} intset;

雖然 intset 結構將 contents 屬性聲明爲 int8_t 類型的數組, 但實際上 contents 數組的真正類型取決於 encoding 屬性的值:

  • 若是 encoding 屬性的值爲 INTSET_ENC_INT16 , 那麼 contents 就是一個 int16_t 類型的數組, 數組裏的每一個項都是一個 int16_t 類型的整數值 (最小值爲 -32,768 ,最大值爲 32,767 )。
  • 若是 encoding 屬性的值爲 INTSET_ENC_INT32 , 那麼 contents 就是一個 int32_t 類型的數組, 數組裏的每一個項都是一個 int32_t 類型的整數值 (最小值爲 -2,147,483,648 ,最大值爲 2,147,483,647 )。
  • 若是 encoding 屬性的值爲 INTSET_ENC_INT64 , 那麼 contents 就是一個 int64_t 類型的數組, 數組裏的每一個項都是一個 int64_t 類型的整數值 (最小值爲 -9,223,372,036,854,775,808 ,最大值爲 9,223,372,036,854,775,807 )。

 

升級(upgrade)

intset 的升級操做:例如當向一個底層爲 int16_t 數組的整數集合添加一個 int64_t 類型的整數值時, intset 已有的全部元素都會被轉換成 int64_t 類型。

升級整數集合並添加新元素共分爲三步進行:

  1. 根據新元素的類型, 擴展整數集合底層數組的空間大小, 併爲新元素分配空間。
  2. 將底層數組現有的全部元素都轉換成與新元素相同的類型, 並將類型轉換後的元素放置到正確的位上, 並且在放置元素的過程當中, 須要繼續維持底層數組的有序性質不變。
  3. 將新元素添加到底層數組裏面。

 升級以後新元素的擺放位置

由於引起升級的新元素的長度老是比整數集合現有全部元素的長度都大, 因此這個新元素的值要麼就大於全部現有元素, 要麼就小於全部現有元素:

  • 在新元素小於全部現有元素的狀況下, 新元素會被放置在底層數組的最開頭(索引 0 );
  • 在新元素大於全部現有元素的狀況下, 新元素會被放置在底層數組的最末尾(索引 length-1 )。

 注意:intset不支持降級!

 

 

跳錶(skiplist)

數據結構定義以下:

typedef struct zskiplistNode {

    // 後退指針
    struct zskiplistNode *backward;

    // 分值
    double score;

    // 成員對象
    robj *obj;

    //
    struct zskiplistLevel {

        // 前進指針
        struct zskiplistNode *forward;

        // 跨度
        unsigned int span;

    } level[];

} zskiplistNode;

typedef struct zskiplist {

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

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

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

} zskiplist;

 

 

上圖展現了一個跳躍表示例, 位於圖片最左邊的是 zskiplist 結構, 該結構包含如下屬性:

  • header :指向跳躍表的表頭節點。
  • tail :指向跳躍表的表尾節點。
  • level :記錄目前跳躍表內,層數最大的那個節點的層數(表頭節點的層數不計算在內)。
  • length :記錄跳躍表的長度,也便是,跳躍表目前包含節點的數量(表頭節點不計算在內)。

位於 zskiplist 結構右方的是四個 zskiplistNode 結構, 該結構包含如下屬性:

  • 層(level):節點中用 L1 、 L2 、 L3 等字樣標記節點的各個層, L1 表明第一層, L2 表明第二層,以此類推。每一個層都帶有兩個屬性:前進指針和跨度。前進指針用於訪問位於表尾方向的其餘節點,而跨度則記錄了前進指針所指向節點和當前節點的距離。在上面的圖片中,連線上帶有數字的箭頭就表明前進指針,而那個數字就是跨度。當程序從表頭向表尾進行遍歷時,訪問會沿着層的前進指針進行。
  • 後退(backward)指針:節點中用 BW 字樣標記節點的後退指針,它指向位於當前節點的前一個節點。後退指針在程序從表尾向表頭遍歷時使用。
  • 分值(score):各個節點中的 1.0 、 2.0 和 3.0 是節點所保存的分值。在跳躍表中,節點按各自所保存的分值從小到大排列。
  • 成員對象(obj):各個節點中的 o1 、 o2 和 o3 是節點所保存的成員對象。

 

 

 

 

壓縮列表(ziplist)

ziplist是由若干個entry 組成,每一個entry能夠保存一個字節數組或者一個整數值。

 

 

屬性 類型 長度 用途
zlbytes uint32_t 4  記錄整個壓縮列表佔用的內存字節數:在對壓縮列表進行內存重分配, 或者計算 zlend的位置時使用。
zltail uint32_t 4  記錄壓縮列表表尾節點距離壓縮列表的起始地址有多少字節: 經過這個偏移量,程序無須遍歷整個壓縮列表就能夠肯定表尾節點的地址。
zllen uint16_t 2  記錄了壓縮列表包含的節點數量: 當這個屬性的值小於 UINT16_MAX (65535)時, 這個屬性的值就是壓縮列表包含節點的數量; 當這個值等於 UINT16_MAX 時, 節點的真實數量須要遍歷整個壓縮列表才能計算得出。
entryX 列表節點 不定 壓縮列表包含的各個節點,節點的長度由節點保存的內容決定。
zlend uint8_t 1  特殊值 0xFF (十進制 255 ),用於標記壓縮列表的末端。

 

 

 entry

entry能夠保存一個字節數組或者一個整數值, 其中, 字節數組能夠是如下三種長度的其中一種:

  1. 長度小於等於 63 (2^{6}-1)字節的字節數組;
  2. 長度小於等於 16383 (2^{14}-1) 字節的字節數組;
  3. 長度小於等於 4294967295 (2^{32}-1)字節的字節數組;

而整數值則能夠是如下六種長度的其中一種:

  1. 4 位長,介於 0 至 12 之間的無符號整數;
  2. 1 字節長的有符號整數;
  3. 3 字節長的有符號整數;
  4. int16_t 類型整數;
  5. int32_t 類型整數;
  6. int64_t 類型整數。

每一個壓縮列表entry都由 previous_entry_length 、 encoding 、 content 三個部分組成,

 

屬性 類型 長度 用途
previous_entry_length 整型 1(小於254)或5(大於或等於254) 前一個entry的長度
encoding 整型   記錄content的類型和長度
content 整數或字節數組    entry的值

 

 

 

 

 

連鎖更新(cascade update)

如今, 考慮這樣一種狀況: 在一個壓縮列表中, 有多個連續的、長度介於 250 字節到 253 字節之間的節點 e1 至 eN ,以下圖:

由於 e1 至 eN 的全部節點的長度都小於 254 字節, 因此記錄這些節點的長度只須要 1 字節長的 previous_entry_length 屬性, 換句話說, e1 至 eN 的全部節點的 previous_entry_length 屬性都是 1 字節長的。 

這時, 若是咱們將一個長度大於等於 254 字節的新節點 new 設置爲壓縮列表的表頭節點, 那麼 new 將成爲 e1 的前置節點, 如圖  

由於 e1 的 previous_entry_length 屬性僅長 1 字節, 它沒辦法保存新節點 new 的長度, 因此程序將對壓縮列表執行空間重分配操做, 並將 e1 節點的 previous_entry_length 屬性從原來的 1 字節長擴展爲 5 字節長。

如今, 麻煩的事情來了 —— e1 本來的長度介於 250 字節至 253 字節之間, 在爲 previous_entry_length 屬性新增四個字節的空間以後,e1 的長度就變成了介於 254 字節至 257 字節之間, 而這種長度使用 1 字節長的 previous_entry_length 屬性是沒辦法保存的。

所以, 爲了讓 e2 的 previous_entry_length 屬性能夠記錄下 e1 的長度, 程序須要再次對壓縮列表執行空間重分配操做, 並將 e2 節點的 previous_entry_length 屬性從原來的 1 字節長擴展爲 5 字節長。

正如擴展 e1 引起了對 e2 的擴展同樣, 擴展 e2 也會引起對 e3 的擴展, 而擴展 e3 又會引起對 e4 的擴展……爲了讓每一個節點的 previous_entry_length 屬性都符合壓縮列表對節點的要求, 程序須要不斷地對壓縮列表執行空間重分配操做, 直到 eN 爲止。

Redis 將這種在特殊狀況下產生的連續屢次空間擴展操做稱之爲「連鎖更新」。

除了添加新節點可能會引起連鎖更新以外, 刪除節點也可能會引起連鎖更新。

由於連鎖更新在最壞狀況下須要對壓縮列表執行 N 次空間重分配操做, 而每次空間重分配的最壞複雜度爲 O(N) , 因此連鎖更新的最壞複雜度爲 O(N^2) 。

 

 

 

參考文檔:

http://redisbook.com/

相關文章
相關標籤/搜索