跟我速覽Redis底層六大數據結構!

大綱:

  1. 簡單動態字符串SDS
  2. 鏈表
  3. 字典
  4. 跳躍表
  5. 整數集合
  6. 壓縮列表

閱讀本文你將收貨什麼:

  1. 瞭解Redis底層的六種數據結構。
  2. 瞭解每種數據結構的實現方式以及設計上的優點。

Redis爲什麼這麼快?

作爲高速KV數據庫,Redis的速度已經經過各大小公司的實戰考驗了,至於爲什麼這麼快,各個理由從google上一搜大同小異,今天我們來聊一聊其底層實現的六大數據結構。

Redis的高效與其基本的數據結構也是密不可分的,爲了滿足效率和安全這些需求,Redis根據自身需要量身定製了數據結構。注:Redis基於這些數據結構創建了字符串對象,列表對象,哈希對象,集合對象和有序集合對象的對象系統,以此實現鍵值對數據庫。

一.簡單動態字符串(simple dynamic string,SDS)

SDS:每個sdshdr結構表示一個SDS值

struct sdshdr {
    // 記錄buf數組中已使用字節的數量
    // 等於SDS所保存字符串的長度
    int len;

    // 記錄buf數組中未使用字節的數量
    int free;

    // 字節數組,用於保存字符串
    char buf[ ];
};

圖1·SDS示例

  • free屬性值爲0,表示這個SDS沒有分配任何未使用空間。
  • len屬性值爲5,表示這個SDS保存了一個五字節長的字符串。
  • buf屬性是一個char類型的數組,以’\0’結尾,不計算在len屬性中。
優點:
  1. 以’\0’結尾可以直接使用C字符串函數庫裏的函數。
  2. 常數複雜度獲取字符串長度O(1),只需要訪問len屬性即可。
  3. 杜絕緩衝區溢出,當SDS API需要對SDS進行修改時,會先檢查SDS的空間是否滿足需要,不滿足則自動擴容,避免溢出。
  4. 減少修改字符串時帶來的內存重分配次數。因爲內存重分配涉及複雜的算法,並且可能需要執行系統調用,所以它通常是一個比較耗時的操作。
    • 空間預分配
      如果對SDS修改後,其長度小於1M將分配和len屬性同樣大小的未使用空間,這時候len屬性與free屬性值相同。
      如果對SDS修改後其長度大於1M,那麼程序會分配1M的未使用空間。
    • 惰性空間釋放
      惰性空間釋放用於優化SDS的字符串縮短操作,當SDS的API需要縮短其保存的字符串時,程序不立即使用內存重新分配來回收多出來的字節,而是使用free屬性將其記錄,留待以後使用。
  5. 二進制安全。所有SDS API都會以處理二進制的方式來處理SDS存放在buf數組裏的數據,數據在寫入時是什麼樣的,它被讀取時就是什麼樣。

二.鏈表

listNode:每個鏈表節點用一個listNode結構來表示

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

}listNode;

list:雖然多個listNode結構可以組成鏈表,但由list來持有鏈表操作方便許多

typedef struct list {
        // 表頭結點
        listNode *head;

        // 表尾節點
        listNode *tail;

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

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

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

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

}list;

圖2·由list結構和listNode結構組成的鏈表
圖2·由list結構和listNode結構組成的鏈表

特性總結:
  1. 雙端:鏈表節點帶有prev和next指針,獲取某個節點的前置節點和後置節點的複雜度爲O(1)
  2. 無環:表頭節點的pre和表尾節點的next都指向NULL,對鏈表的訪問以NULL爲終點。
  3. 帶表頭和表尾指針:通過list結構的head指針和tail指針,獲取鏈表頭尾節點的複雜度爲O(1).
  4. 帶鏈表長度技術器,使用list結構的len屬性來對list持有的鏈表節點計數,獲取鏈表中節點數量的複雜度爲O(1)。
  5. 多態:鏈表節點使用void* 指針來保存節點的值,並且可以通過list結構的dup、free、match三個屬性爲節點值設置類型特定的函數,用來保存各種不同類型的值。
    ###三.字典

三.字典

哈希表:Redis字典所使用的哈希表由dictht結構定義

typedef struct dictht {
        // 哈希表數組
        dictRntry **table;

        // 哈希表大小
        unsigned long size;

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

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

圖3·一個空的哈希表
圖3·一個空的哈希表

  • table屬性是一個數組,數組中的每個元素都是一個指向dictEntry結構的指針,每個dictEntry結構保存着一個鍵值對。

  • size屬性記錄了哈希表的大小,也即table數組的大小,而used屬性記錄了哈希表目前已有節點(鍵值對)的數量。

  • sizemask屬性的值總是等於size-1,這個屬性和哈希值一起決定一個鍵應該被放到table數組的哪個索引上面。

哈希表節點:哈希表節點使用dictEntry結構表示,每個結構保存着一個鍵值對

typedef struct dictEntry {
        // 鍵
        void *key;

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

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

}dictEntry;

圖4·連在一起的鍵K1和鍵K0
圖4·連在一起的鍵K1和鍵K0

  • key屬性保存着鍵值對中的鍵,而v屬性保存着鍵值對中的值,值可以是一個指針,或uint64_t整數,或int64_t整數。
  • next屬性是指向另一個哈希表節點的指針,這個指針可以將多個哈希值相同的鍵值對連接在一起,一次解決鍵衝突的問題。

字典:Redis中的字典由dict結構表示

typedef struct dict {

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

        //    私有數據
        void *privdata;

        //    哈希表
        dictht ht[2];

        //    rehash索引
        //    當rehash不在進行時,值爲-1
        int treashidx;
}dict;
  • type屬性是一個指向dictType結構的指針,每個dictType結構保存了一簇用於操作特定類型鍵值對的函數,Redis會爲用途不同的字典設置不同類型的函數。
  • privadata屬性保存了需要傳給那些類型特定函數的可選參數。
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 *key, const void *key2);

        //    銷燬鍵的函數
        void (*keyDestructor) (void *privdata, void *key);

        // 銷燬值的函數
        void (*valDestructor) (void *privdata, void *obj);
}dictType;

圖五·普通狀態下的字典
圖五·普通狀態下的字典

  • 哈希算法:
    當要將一個新的鍵值對添加到字典裏時,程序需要先根據鍵值對的鍵計算出哈希值和索引值,然後再根據索引值,將包含新鍵值對的哈希表節點放到哈希表數組指定索引上面。
  • 解決鍵衝突:
    Redis的哈希表使用鏈地址法來解決鍵衝突,每個哈希表節點都有一個next指針,多個哈希表節點用next指針構成一個單向鏈表。
  • rehash:
    1.爲字典的ht[1]哈希表分配空間,這個哈希表的空間大小取決於要執行的操作,以及ht[0]當前包含的鍵值對數量。
    • 擴展:那麼ht[1]的大小爲第一個大於等於ht[0].used*2的2^n;
    • 收縮:那麼ht[1]的的大小爲第一個大於等於ht[0].used的2^n.
    1. 將保存在ht[0]中的所有鍵值對rehash到ht[1]上面。
    2. 當ht[0]包含的所有鍵值對都遷移到ht[1]後,釋放ht[0],將ht[1]設置爲ht[0],並在ht[1]新創建一個空白哈希表,爲下一次rehash做準備。
  • 漸進式rehsah:
    1.爲ht[1]分配空間,讓字典同時持有ht[0]和ht[1]兩個哈希表。
    2.在字典中維持一個索引計數器變量rehashidx,並設置0,表示rehash工作開始。
    3.在rehash期間,每次對字典進行增刪改查外,順帶將ht[0]哈希表在rehashidx索引上所有鍵值對rehash到ht[1],當rehash完成後,將rehashidx增一。
    4. 當rehash完成時,將rehashidx設置爲-1,表示rehash操作完成。

!!!漸進式rehash的好處在於它採取分而治之的方式,將rehash鍵值對所需的計算工作均攤到每個增刪改查操作上,避免集中式rehash帶來的龐大計算量

漸進式rehash期間hash表操作:刪、查、改操作先ht[0]後ht[1],新增直接在ht[1]上。

四.跳躍表

跳躍表節點:由zskiplistNode結構定義

typedef struct zskiplistNode {

        // 後退指針
        struct zskiplistNode *backward;

        // 分值
        double score;

        // 成員對象
        robj *obj;

        // 層
        struct zskiplistLevel {

                // 前進指針
                struct zskiplistNode *forward;

                // 跨度
                unsigned int span;
        }level[];
}zskiplistNode;

圖六·帶不同層高的節點
圖六·帶不同層高的節點

  1. 層:
    跳躍表節點的level數組可以包含多個元素,每個元素都包含一個指向其他節點的指針,程序可以通過這些層來加快訪問其他節點的速度,一般來說,層越多,訪問其他節點的速度就越快。
    2.前進指針:
    每個層都有一個指向表尾方向的前進指針(level[i].forward),用於從表頭向表尾方向訪問節點。
  2. 跨度:
    層的跨度(level[i].span)用於記錄兩個節點之間的距離。跨度與操作無關,只是用於記算排位:在查找某個節點的過程中,將沿途訪問過的所有層的跨度累計起來,得到結果就是目標節點在跳躍表中的排位。
  3. 後退指針:
    後退指針(backward)用於從表尾向表頭方向訪問節點,每次只能後退至前一個節點。
  4. 分值和成員:
    • 節點的分值(score)是一個double類型的浮點數,跳躍表中所有節點都按分值從小到大排序。
    • 節點的成員對象(obj)是一個指針,指向一個字符串對象,字符串對象則保存着一個SDS值。
    • 在同一個跳躍表中,各個節點保存的成員對象必須是唯一的,但是多個節點保存的分值可以相同。

跳躍表

typedef struct zskiplist {
        // 表頭結點和表尾節點
        struct skiplistNode *header, *tail;    

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

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

圖七·帶有zskiplist結構的跳躍表
圖七·帶有zskiplist結構的跳躍表

  • header和tail指針分別指向跳躍表的表頭和表尾節點,通過這兩個指針,程序定位表頭節點和表尾節點的複雜度爲O(1)。
  • 通過length屬性記錄節點的數量,獲取跳躍表長度的複雜度爲o(1)。
  • level屬性用於在O(1)複雜度內獲取跳躍表中層高最大的那個節點的層數量。

五.整數集合

typedef struct intset {
        //     編碼方式
        uint32_t encoding;  

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

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

圖八·一個包含五個int16_t類型整數值的整數集合
圖八·一個包含五個int16_t類型整數值的整數集合

  • contents數組是整數集合的底層實現:整數集合的每個元素都是contents數組的一個數組項(item),各個項在數組中按值從小到大排列,不重複。
  • length屬性記錄了整數集合包含的元素數量。
  • contents數組真正類型取決於encoding屬性的值。

升級

當添加新元素到整數集合中,新元素比整數集合現有元素都要長,則進行升級。

  1. 根據新元素的類型,擴展整數集合底層數組的空間大小,併爲新元素分配空間。
  2. 將底層數組現有所有元素都轉換成新元素的類型,並將類型轉換後的元素放置到正確的位置上。
  3. 將新元素添加到底層數組裏面。

升級的好處:

  1. 提升靈活性:C語言是靜態類型語言,避免類型錯誤,通常不會將兩種不同類型的值放在同一個數據結構中。
  2. 節約內存:讓一個數組可以同時保存int16_t、int32_t、int64_t三種類型最簡單的做法就是直接使用int64_t作爲整數集合的底層實現。整數集合技能保存不同類型的整數,又可以確保升級操作只會在必要的時候進行,這可以儘量節約內存。

降級

整數集合不支持降級。

六.壓縮列表

壓縮列表的構成

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

  • 列表zlbytes屬性值爲0x50(十進制80),表示壓縮列表總長爲80字節。
  • 列表zltail屬性值爲0x3c(十進制60),這表示如果我們有一個指向壓縮列表起始指針的P,只要P加上偏移量60就能計算出表尾節點entry3的地址。
  • 列表zllen屬性值爲0x3(十進制3),表示壓縮列表包含三個節點。

壓縮列表節點的構成

圖十·前一節點長度爲5字節
圖十·前一節點長度爲5字節

  • previous_entry_length
    節點的previous_entry_length屬性以字節爲單位,記錄了壓縮列表中前一個字節的長度,其長度可爲1或5:

    • 如果前一節點長度小於254字節,那麼previous_entry_length長度爲一個字節,前一個節點的長度就保存在這個字節裏。
    • 如果前一個節點的長度大於254字節,那麼previous_entry_length屬性的長度爲5字節,第一個字節被設爲0xFE,之後四個字節用於保存前一節點的長度。
      因爲節點的previous_entry_length屬性記錄了前一個節點的長度,所以程序可以通過指針運算,根據當前節點的其實地址計算出前一個節點的起始地址。
  • encoding
    節點encoding屬性記錄了節點的content屬性所保存數據的類型以及長度。

字節數組編碼:

編碼 編碼長度 content屬性保存的值
00bbbbbb 1字節 長度小於等於63字節的字節數組
01bbbbbb xxxxxxxx 2字節 長度小於等於16 383字節的字節數組
10_ _ _ _ _ _ aaaaaaa bbbbbbbb cccccccc 5字節 長度小於等於4 294 967 295字節的字節數組

整數編碼:

編碼 編碼長度 content屬性保存的值
11000000 1字節 int16_t 類型的數組
11010000 1字節 int32_t 類型的數組
11100000 1字節 int64_t 類型的數組
11110000 1字節 24位有符號整數
11111110 1字節 8位有符號整數
1111xxxx 1字節 無意義
  • content
    節點的content屬性負責保存節點的值,節點值可以是一個字節數組或者整數,值的類型和長度有節點的encoding屬性決定。

連鎖更新

圖十一·添加新節點到壓縮列表
圖十一·添加新節點到壓縮列表

  • 因爲每個節點的previous_entry_length屬性都記錄了前一個節點的長度
  • 假設在一個壓縮列表中所有節點長度都小於254字節,當插入一長度大於254字節的新節點並設置爲表頭節點,那麼他下一個節點e1的previous_entry_length只有1個字節,沒法保存大於254字節的長度,需要擴展。
  • e1更新後e2也需要擴展,擴展e2也會引發對e3的擴展,e4······直到每個節點previous_entry_length都符合壓縮列表對節點的要求。

因爲連鎖更新在最壞的情況下需要對壓縮列表執行N次空間重分配操作,每次空間分配的最壞複雜度爲O(N),所以連鎖更新的最壞複雜度爲O(N^2)。
儘管連鎖更新的複雜度較高,但真正造成性能問題的機率很低

總結:

以上就是Redis的六種底層數據的各種實現分析,總結於《Redis設計與實現》,用於自己速覽,也希望能幫助到對於redis感興趣的各位!

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