(Redis設計與實現-1) 數據結構

一.簡單動態字符串算法

Redis沒有直接使用C語言傳統的字符串表示(以空字符結尾的字符數組), 而是本身構建了一種名爲簡單動態字符串(simple dynamic string,SDS)的抽象類型, 並將 SDS 用做 Redis 的默認字符串表示。
struct sdshdr {
    int len;   //記錄buf數組中已使用字節的數量
    int free;  //記錄buf數組中未使用字節的數量
    char buf[];  //字節數組,用於保存字符串
};

圖片描述

a.free屬性的值爲5,表示這個SDS有5個未使用空間。

b.len屬性的值爲5,表示這個 SDS 保存了一個五字節長的字符串。

c.buf屬性是一個char類型的數組, 數組的前五個字節分別保存了 'R' 、 'e' 、 'd' 、 'i' 、 's' 五個字符,而
最後一個字節則保存了空字符 '\0' 。使得SDS能夠直接重用一部分C字符串函數庫裏面的函數。

(1).SDS的空間分配策略數組

空間預分配:空間預分配用於優化SDS的字符串增加操做:當SDS的API對一個SDS進行修改,而且須要對SDS進行空間擴展的時候,程序不只會爲SDS分配修改所必需要的空間,還會爲 SDS 分配額外的未使用空間。
a.若是對SDS進行修改以後,SDS的長度(也便是 len 屬性的值)將小於1MB,那麼程序分配和len屬性一樣大小的未使
用空間,這時 SDS len 屬性的值將和 free 屬性的值相同。

b.若是對SDS進行修改以後,SDS的長度將大於等於1MB, 那麼程序會分配1 MB的未使用空間。
惰性空間釋放:惰性空間釋放用於優化SDS的字符串縮短操做:當SDS的API須要縮短SDS保存的字符串時,程序並不當即使用內存重分配來回收縮短後多出來的字節,而是使用free屬性將這些字節的數量記錄起來,並等待未來使用。

(2).二進制安全安全

由於在C中,除了字符串的末尾以外,字符串裏面不能包含空字符,不然最早被程序讀入的空字符將被誤認爲是字符串結尾,這些限制使得C字符串只能保存文本數據, 而不能保存二進制數據。而全部SDS API 都會以處理二進制的方式來處理SDS存放在buf數組裏的數據,程序不會對其中的數據作任何限制、過濾。這也是咱們將SDS的buf屬性稱爲字節數組的緣由(Redis 不是用這個數組來保存字符, 而是用它來保存一系列二進制數據)。


二.鏈表服務器

Redis中的每一個鏈表使用一個list結構來表示,這個結構帶有表頭節點指針、表尾節點指針、以及鏈表長度等信息。每一個鏈表節點由一個listNode結構來表示,每一個節點都有一個指向前置節點和後置節點的指針,因此Redis的鏈表實現是雙端鏈表。
//鏈表
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;


//鏈表節點
typedef struct listNode {
    struct listNode *prev;// 前置節點
    struct listNode *next; // 後置節點
    void *value;// 節點的值
} listNode;

圖片描述


三.字典數據結構

字典,又稱符號表(symbol table), 是一種用於保存鍵值對(key-value pair)的抽象數據結構。字典中的每一個鍵都是獨一無二的,程序能夠在字典中根據鍵查找與之關聯的值,或者經過鍵來更新值,又或者根據鍵來刪除整個鍵值對,等等。
//字典
typedef struct dict {
    dictType *type;// 指定特定類型函數
    void *privdata;// 保存了須要傳給那些特定類型函數的可選參數
    dictht ht[2]; // 哈希表,通常狀況下,字典只使用ht[0]哈希表,ht[1]哈希表只會在對ht[0]哈希表進行rehash時使用。
    int rehashidx;//它記錄了rehash目前的進度,當rehash不在進行時,值爲 -1
} dict;


//哈希表
typedef struct dictht {
    dictEntry **table; // 哈希表數組
    unsigned long size; // 哈希表大小
    unsigned long sizemask;// 哈希表大小掩碼,用於計算索引值,老是等於 size - 1
    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 dictType {
    unsigned int (*hashFunction)(const void *key);// 計算哈希值的函數
    void *(*keyDup)(void *privdata, const void *key);// 複製鍵的函數
    void *(*valDup)(void *privdata, const void *obj); // 複製值的函數
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);// 對比鍵的函數
    void (*keyDestructor)(void *privdata, void *key);// 銷燬鍵的函數
    void (*valDestructor)(void *privdata, void *obj);// 銷燬值的函數
} dictType;

圖片描述

(1).哈希算法函數

#使用字典設置的哈希函數,計算鍵 key 的哈希值
hash = dict->type->hashFunction(key);

#使用哈希表的sizemask屬性和哈希值,計算出索引值
index = hash & dict->ht[x].sizemask;

#根據索引值,將包含新鍵值對的哈希表節點放到哈希表數組的指定索引上面

圖片描述

#計算鍵 k0 的哈希值
hash = dict->type->hashFunction(k0);

#假設計算得出的哈希值爲8 ,計算出索引值
index = hash & dict->ht[0].sizemask = 8 & 3 = 0;

(2).解決鍵衝突性能

Redis的哈希表使用鏈地址法(separate chaining)來解決鍵衝突:每一個哈希表節點都有一個next指針,多個哈希表節點能夠用next指針構成一個單向鏈表,被分配到同一個索引上的多個節點能夠用這個單向鏈表鏈接起來,由於 dictEntry節點組成的鏈表沒有指向鏈表表尾的指針,因此爲了速度考慮,程序老是將新節點添加到鏈表的表頭位置(複雜度爲 O(1)),排在其餘已有節點的前面。

圖片描述

圖片描述

(3).rehash優化

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

a.爲字典的ht[1]哈希表分配空間,這個哈希表的空間大小取決於要執行的操做,以及ht[0]當前包含的鍵值對數量(也即
是ht[0].used 屬性的值):
    若是執行的是擴展操做,那麼 ht[1] 的大小爲第一個大於等於 ht[0].used * 2 的 2^n;
    若是執行的是收縮操做,那麼 ht[1] 的大小爲第一個大於等於 ht[0].used 的 2^n 。

b.將保存在ht[0]中的全部鍵值對rehash到ht[1]上面:rehash 指的是從新計算鍵的哈希值和索引值,而後將鍵值對放
置到ht[1]哈希表的指定位置上。

c.當ht[0]包含的全部鍵值對都遷移到了ht[1]以後(ht[0] 變爲空表),釋放 ht[0] ,將ht[1]設置爲ht[0] ,並在 
ht[1]新建立一個空白哈希表,爲下一次rehash作準備。
哈希表的擴展發生時機:
a.服務器目前沒有在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 而且哈希表的負載因子大於等於 1 ;
b.服務器目前正在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 而且哈希表的負載因子大於等於 5 ;

哈希表的收縮發生時機:
a.當哈希表的負載因子小於 0.1 時, 程序自動開始對哈希表執行收縮操做。


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

(4).漸進式 rehashui

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

a.爲 ht[1] 分配空間,讓字典同時持有ht[0]和ht[1]兩個哈希表。

b.在字典中維持一個索引計數器變量rehashidx,並將它的值設置爲 0 ,表示rehash工做正式開始。

c.在rehash進行期間,程序除了執行指定的操做之外,還會順帶將ht[0]哈希表在rehashidx索引上的全部鍵值對rehash
到ht[1],當rehash工做完成以後,程序將rehashidx屬性的值增一。

d.隨着字典操做的不斷執行,最終在某個時間點上,ht[0]的全部鍵值對都會被rehash至ht[1],這時程序將rehashidx 
屬性的值設爲 -1 ,表示 rehash 操做已完成。
漸進式rehash執行期間的哈希表操做:

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

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


四.跳躍表編碼

跳躍表(skiplist)是一種有序數據結構,它經過在每一個節點中維持多個指向其餘節點的指針,從而達到快速訪問節點的目的。
//跳躍表
typedef struct zskiplist {
    struct zskiplistNode *header, *tail; // 表頭節點和表尾節點
    unsigned long length; // 表中節點的數量(表頭節點不計算在內)
    int level;// 表中層數最大的節點的層數(表頭節點的層數不計算在內)
} zskiplist;


//跳躍表節點
typedef struct zskiplistNode {
    struct zskiplistNode *backward; // 後退指針
    double score;  // 節點的分值,跳躍表中的全部節點都按分值從小到大來排序
    robj *obj; // 所保存的成員對象,每一個節點的成員對象必須是惟一的
    struct zskiplistLevel {
        struct zskiplistNode *forward;// 前進指針
       unsigned int span; //跨度,記錄了前進指針所指向節點和當前節點的距離
    } level[];// 層
} zskiplistNode;

圖片描述

注意表頭節點和其餘節點的構造是同樣的:表頭節點也有後退指針、分值和成員對象,不過表頭節點的這些屬性都不會被用到,因此圖中省略了這些部分, 只顯示了表頭節點的各個層。
每次建立一個新跳躍表節點的時候, 程序都根據冪次定律 (power law,越大的數出現的機率越小) 隨機生成一個介於 1 和 32 之間的值做爲 level 數組的大小, 這個大小就是層的「高度」。 通常來講,層的數量越多,訪問其餘節點的速度就越快


五.整數集合

整數集合(intset)是Redis 用於保存整數值的集合抽象數據結構,它能夠保存類型爲int16_t 、int32_t 或者 int64_t的整數值,而且保證集合中不會出現重複元素。
typedef struct intset {
    uint32_t encoding;  // 編碼方式
    uint32_t length;// 集合包含的元素數量
    int8_t contents[]; // 保存元素的數組,各個項從小到大有序地排列, 而且不包含任何重複項。
} intset;
編碼方式:

a.若是 encoding 屬性的值爲 INTSET_ENC_INT16 , 那麼 contents 就是一個 int16_t 類型的數組, 數組裏的每一個
項都是一個 int16_t 類型的整數值

b.若是 encoding 屬性的值爲 INTSET_ENC_INT32 , 那麼 contents 就是一個 int32_t 類型的數組, 數組裏的每一個
項都是一個 int32_t 類型的整數值

c.若是 encoding 屬性的值爲 INTSET_ENC_INT64 , 那麼 contents 就是一個 int64_t 類型的數組, 數組裏的每一個
項都是一個 int64_t 類型的整數值

(1).升級

每當咱們要將一個新元素添加到整數集合裏面,而且新元素的類型比整數集合現有全部元素的類型都要長時,整數集合須要先進行升級(upgrade),而後才能將新元素添加到整數集合裏面。
步驟:

a.根據新元素的類型,擴展整數集合底層數組的空間大小,併爲新元素分配空間。

b.將底層數組現有的全部元素都轉換成與新元素相同的類型,並將類型轉換後的元素放置到正確的位上,並且在放置元
素的過程當中,須要繼續維持底層數組的有序性質不變。

c.將新元素添加到底層數組裏面。

(2).降級

整數集合不支持降級操做, 一旦對數組進行了升級, 編碼就會一直保持升級後的狀態。


六.壓縮列表

壓縮列表是 Redis 爲了節約內存而開發的, 由一系列特殊編碼的連續內存塊組成的順序型(sequential)數據結構。一個壓縮列表能夠包含任意多個節點(entry), 每一個節點能夠保存一個字節數組或者一個整數值。

圖片描述

屬性 類型 長度 用途
zlbytes uint32_t 4 字節 記錄整個壓縮列表佔用的內存字節數
zltail uint32_t 4 字節 記錄壓縮列表表尾節點距離壓縮列表的起始地址有多少字節
zllen uint16_t 2 字節 記錄了壓縮列表包含的節點數量
entryX 列表節點 不定 壓縮列表包含的各個節點,節點的長度由節點保存的內容決定。
zlend uint8_t 1 字節 特殊值 0xFF (十進制 255 ),用於標記壓縮列表的末端。

(1).壓縮列表節點

圖片描述

屬性 長度 用途
previous_entry_length 1字節或5字節 記錄了壓縮列表中前一個節點的長度。壓縮列表的從表尾向表頭遍歷操做會使用
encoding 1字節或2字節或5字節 記錄了節點的content屬性所保存數據的類型以及長度
content encoding 屬性決定 負責保存節點的值, 節點值能夠是一個字節數組或者整數
previous_entry_length:

a.若是前一節點的長度小於254字節,那麼previous_entry_length屬性的長度爲1字節:前一節點的長度就保存在這一
個字節裏面。

b.若是前一節點的長度大於等於254字節,那麼previous_entry_length屬性的長度爲5字節:其中屬性的第一字節會被
設置爲 0xFE(十進制值 254), 而以後的四個字節則用於保存前一節點的長度。
encoding:

a.1字節、2字節或者5字節長,值的最高位爲 00 、 01 或者 10 的是字節數組編碼:這種編碼表示節點的content屬性
保存着字節數組,數組的長度由編碼除去最高兩位以後的其餘位記錄;

b.1字節,值的最高位以 11 開頭的是整數編碼:這種編碼表示節點的content屬性保存着整數值,整數值的類型和長度
由編碼除去最高兩位以後的其餘位記錄;

(2).連鎖更新

圖片描述

a.當e1至eN的全部節點的長度都小於254 字節,因此記錄這些節點的長度只須要1字節長的 previous_entry_length屬
性。

b.若是咱們將一個長度大於等於254字節的新節點new設置爲壓縮列表的表頭節點,那麼new將成爲e1的前置節點,由於
 e1的previous_entry_length屬性僅長1字節,它沒辦法保存新節點new的長度,因此程序將對壓縮列表執行空間重分配
操做,並將e1 節點的 previous_entry_length 屬性從原來的 1 字節長擴展爲 5 字節長,e1長度的改變致使後續節點
連鎖更新
連鎖更新不可避免, 但它真正形成性能問題的概率是很低的,由於壓縮列表只在包含少許列表項場景下使用
相關文章
相關標籤/搜索