Redis 設計與實現-內部數據結構

閒暇之餘,通讀了《Redis 設計與實現》,個人比較喜歡第一版,小記幾筆,以便查閱,如果單純爲了使用,請移步:《命令查詢手冊》,共勉~

簡單動態字符串

Redis中使用的並不是傳統的C字符串,還是使用其特有的數據結構Sds(Simple Dynamic String,簡單動態字符串)作爲char*的替代品,因爲傳統字符串類型無法高效支持一些Redis常用操作,如:

  • 計算字符串長度,傳統的字符串時間複雜度爲O(N)
  • 對字符串進行N次追加,必定需要低字符串進行N次內存重分配(realloc)

所以,Redis中的Sds做了類似於下面的定義:

typedef char * sds;

struct sdshdr {

    // buf 已佔用長度
    int len;

    // buf 剩餘可用長度
    int free;

    // 實際保存字符串數據的地方
    char buf[];
};

通過額外的字段記錄,Sds的字符串長度的複雜度則變爲了O(1),而buf則採用的是內存預分配的策略,比如當前分配了1KB的空間,當追加後的大小小於1KB,則不會引起內存的重新分配,若是大於1KB,則Redis會爲他們額外分配1KB的空間,僞代碼實現如下:

def sdsMakeRoomFor(sdshdr, required_len):

    # 預分配空間足夠,無須再進行空間分配
    if (sdshdr.free >= required_len):
        return sdshdr

    # 計算新字符串的總長度
    newlen = sdshdr.len + required_len

    # 如果新字符串的總長度小於 SDS_MAX_PREALLOC
    # 那麼爲字符串分配 2 倍於所需長度的空間
    # 否則就分配所需長度加上 SDS_MAX_PREALLOC 數量的空間
    if newlen < SDS_MAX_PREALLOC:
        newlen *= 2
    else:
        newlen += SDS_MAX_PREALLOC

    # 分配內存
    newsh = zrelloc(sdshdr, sizeof(struct sdshdr)+newlen+1)

    # 更新 free 屬性
    newsh.free = newlen - sdshdr.len

    # 返回
    return newsh

鏈表

鏈表作爲一種常用的數據結構,在很多高級編程語言中均有內置,但由於Redis所使用的C語言並沒有內置這種結構,所以Redis自己構建了鏈表的實現,鏈表在Redis中的應用非常廣泛,比如列表、發佈訂閱,慢查詢等等。

鏈表節點定義僞代碼:

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;

Redis列表中使用雙端鏈表和壓縮列表作爲底層實現,因爲雙端鏈表佔用的內存比壓縮列表要多,所以當創建新的列表時,Redis會優先考慮壓縮列表作爲底層實現,在有需要的時候,纔會從壓縮列表轉換到雙端鏈表實現。該結構特性可總結如下:

  • 由於listNode帶有prev和next指針,所以獲取某個節點的前後節點的複雜度都是O(1)。
  • list保存了head和tail兩個指針,所以對錶頭和表尾的複雜度都有O(1),所以list可以高效執行LPUSH、RPOP、RPOPLPUSH等命令。
  • list使用len來對節點進行技術,所以程序獲取鏈表中節點數量的複雜度爲O(1)。

字典

字典的結構想必大家並不陌生,也是Redis中應用廣泛的結構之一,使用頻率和Sds及雙端鏈表不相上下,主要的用途有兩個:

  1. 作爲數據庫鍵空間。
  2. 作爲Hash類型鍵的底層實現之一。

與雙端鏈表一樣,雖然字典作爲一種常見的數據結構內置在很多高級編程語言裏,但Redis裏使用的C語言並沒有內置這種結構,因此Redis自己構建了字典的實現,實現的方案有多種:

  • 最簡單就是使用鏈表或數組,但只適用於元素個數不多的情況下。
  • 要兼顧高效和簡單性,可以使用哈希表。
  • 如果追求更爲穩定的性能特徵,並希望高效的實現排序操作,則可使用更爲複雜的平衡樹。
    Redis選擇高效和簡單薦股的哈希表,作爲字典的底層實現。
/*
 * 字典
 *
 * 每個字典使用兩個哈希表,用於實現漸進式 rehash
 */
typedef struct dict {

    // 特定於類型的處理函數
    dictType *type;

    // 類型處理函數的私有數據
    void *privdata;

    // 哈希表(2 個)
    dictht ht[2];

    // 記錄 rehash 進度的標誌,值爲 -1 表示 rehash 未進行
    int rehashidx;

    // 當前正在運作的安全迭代器數量
    int iterators;

} dict;

/*
 * 哈希表
 */
typedef struct dictht {

    // 哈希表節點指針數組(俗稱桶,bucket)
    dictEntry **table;

    // 指針數組的大小
    unsigned long size;

    // 指針數組的長度掩碼,用於計算索引值
    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;

因爲壓縮列表比字典更節省內存,所以在創建Hash鍵時,默認使用壓縮列表作爲底層實現,當有需要是,程序纔會將底層實現從列表轉換到字典。值得關注的是dict類型中使用了兩個指針,分別指向兩個哈希表,其中,0號哈希表(ht[0])是字典主要使用的哈希表,而1號哈希表(ht[1])則只有在程序對0號號系表進行rehash時才使用。Redis目前使用的哈希算法有兩種:

  1. MurmurHash2 32bit算法:這種算法的分步率和速度都非常好:http://code.google.com/p/smhasher/
  2. 基於djb算法實現的一個大小寫無關散列算法:http://www.cse.yorku.ca/~oz/hash.html

儘管使用了哈希算法,但不同的兩個鍵仍然可能擁有相同的哈希值,我們稱之爲碰撞,所以哈希表必須想辦法對碰撞進行處理,字典哈希表所使用碰撞解決方法被稱之爲鏈地址法:就是使用鏈表將多個哈希值相同的節點串聯在一起,從而解決衝突問題,如果哈希表的大小與節點數量保持在1:1時,哈希表性能最好,但是如果節點數量遠大於哈希表的大小的話,那麼哈希表就會退化成多個鏈表,那麼性能就會明顯下降。所以當字典的鍵值對不斷增多的情況下,爲了保持字典的性能,就需要對哈希表(ht[0])進行rehash操作,在不修改任何鍵值對的情況下,對哈希表進行擴容,儘量將比率維持在1:1左右。
通過查看dictht的定義我們可以發現其定義了size(指針數組大小)和used(哈希表現有節點數量)兩個屬性,當他們之間的比率被定義爲\(ratio=used/size\),當滿足下列條件,rehash操作就會被激活:

  1. 自然rehash:ratio>=1且變量dict_can_resize==true;
  2. 強制rehash:ratio>dict_force_resize_ratio(在2.6版本默認爲5)。

Rehash 的執行過程

  1. 設置字典的rehashidx爲0,標識rehash開始,創建一個比ht[0]->table更大的 ht[1]-->table,大小至少爲ht[0]-->used的兩倍;
  2. 將ht[0]->table中的所有鍵值遷移到ht[1]-->table;
  3. 將原有 ht[0]的數據清空,並將ht[1]替換爲新的ht[0];

也許你會有疑問,如果說在rehash的過程中,有新的值寫入怎麼辦?如果直接阻塞,等rehash過程完成,這樣是非常不友好的,所以Redis採用了漸進式(incremental)的rehash方式,主要由_dictRehashStep和dictRehashMilliseconds兩個函數進行:

  • _dictRehashStep用於對數據庫字典以及哈希鍵的字典被動rehash,每次執行_dictRehashStep,哈希表ht[0]-->table第一個不爲空的索引上的所有節點就會全部遷移到ht[1]-->table,每一次執行添加、查找、刪除操作,_dictRehashStep都會被執行一次,因爲字典會保持哈希大小和節點的ratio在一個很小的範圍內,所以每個索引上的節點數量不會很多,在執行操作的同時,對單個索引上的節點進行遷移,幾乎不會對響應時間造成影響;
  • dictRehashMilliseconds則由Redis服務器常規任務程序(service cron job)執行,可以在指定的毫秒數內對數據庫字典進行主動rehash,從而加速數據庫字典的rehash過程;

當然,爲了保證rehash的順利、正確執行,還需要採取一些特別的措施:

  • 在rehash未完成時,字典會同時使用兩個哈希表,所以在這期間的查找、刪除操作,除了在ht[0]上進行,還需要在ht[1]上進行;
  • 在執行添加操作時,新的節點會直接添加到ht[1]而不是ht[0],這樣保證ht[0]的節點數量在整個rehash的過程中都只減不增。

當然,如果因爲大量的刪除節點,導致了哈希表的可用節點數比已用節點數大很多的話,那麼也可以通過rehash來收縮(shrink)字典,操作過程和上述過程類似,不過不同於擴展的是,字典的收縮是需要手動執行的,一般來說當字典的填充率小於10%,我們就可以對這個字典進行收縮操作了。

跳躍表

什麼是跳躍表

首先我們先談談單鏈表,比如一個鏈表L:1->2->3->4->5->6->7->8->9,如果我們想查找某個數據,就只能從頭到尾遍歷,時間複雜度爲O(n),似乎有點難以接受,本着空間換時間的準則,大佬們爲鏈表建立了索引L1:1->3->5->7->9,這樣我們要查找6時,就現在L1中查找,當發現6在5到7之間時,在下降到L中進行查找,當加了一層索引後,我們就會發現,查找一個節點需要遍歷的節點個數減少了,爲了進一步提高效率,我們可以再加一級索引L2:1->5->7,這樣效率就又會進一步提升,當有大量數據時,我們就可以通過這種多級索引的方式,使查找效率大大提升,這種多級索引的結構就是跳躍表。跳躍表的效率和平衡樹媲美,在Redis主要用於實現有序數據類型,主要由以下幾個部分構成:

  • 表頭:負責維護跳躍表的節點指針;
  • 跳躍表節點:保存着元素值,以及多個層;
  • 層:保存着指向其他元素的指針。高層的指針越過的元素數量>=低層的指針,爲了提高查找效率,程序總是從高層先開始訪問,然後隨着元素範圍的縮小,慢慢降低層次;
  • 表尾:全部由NULL組成,標識跳躍表的末尾。

avatar

僅僅從文字上難以形象的說明跳躍表,還是直接上圖來的形象,不過本人又是個憊懶貨,就直接引用了原圖,各位大佬還是移步原文去看吧,比我抄的好多啦。

Redis中的跳躍表

爲了滿足自身需要,Redis對跳躍表進行了修改:

  1. 允許重複的score值:多個不同的member的score值可以相同;
  2. 進行對比操作時,不進要檢查score值,還要檢查member,因爲score重複時需要查member纔行;
  3. 每個加點都有一個高度爲1的後退指針,用於從表尾方向向表頭方向迭代。
//表示跳躍節點
typedef struct zskiplist {

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

    // 節點數量
    unsigned long length;

    // 目前表內節點的最大層數(表頭節點不計算在內)
    int level;

} zskiplist;

//保存跳躍節點的相關信息
typedef struct zskiplistNode {

    // 成員對象:在同一個跳躍表中,各個節點保存的成員對象必須是唯一的,但多個節點保存的分值可以是相同的
    robj *obj;

    // 分值:在跳躍表中,節點按各自所保存的分值從小到大排序
    double score;

    // 後退指針:它指向位於當前節點的前一個節點,用於程序從表尾向表頭遍歷時使用
    struct zskiplistNode *backward;

    // 層
    struct zskiplistLevel {

        // 前進指針
        struct zskiplistNode *forward;

        // 這個層跨越的節點數量
        unsigned int span;

    } level[];

} zskiplistNode;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章