Redis 的基礎資料結構(一) 可變字串、連結串列、字典

犀利豆發表於2019-02-24

原文地址:xilidou.com/2018/03/12/…

這周開始學習 Redis,看看Redis是怎麼實現的。所以會寫一系列關於 Redis的文章。這篇文章關於 Redis 的基礎資料。閱讀這篇文章你可以瞭解:

  • 動態字串(SDS)
  • 連結串列
  • 字典

三個資料結構 Redis 是怎麼實現的。

SDS

SDS (Simple Dynamic String)是 Redis 最基礎的資料結構。直譯過來就是”簡單的動態字串“。Redis 自己實現了一個動態的字串,而不是直接使用了 C 語言中的字串。

sds 的資料結構:

struct sdshdr {
    
    // buf 中已佔用空間的長度
    int len;

    // buf 中剩餘可用空間的長度
    int free;

    // 資料空間
    char buf[];
};
複製程式碼

所以一個 SDS 的就如下圖:

sds

所以我們看到,sds 包含3個引數。buf 的長度 len,buf 的剩餘長度,以及buf。

為什麼這麼設計呢?

  • 可以直接獲取字串長度。 C 語言中,獲取字串的長度需要用指標遍歷字串,時間複雜度為 O(n),而 SDS 的長度,直接從len 獲取複雜度為 O(1)。

  • 杜絕緩衝區溢位。 由於C 語言不記錄字串長度,如果增加一個字元傳的長度,如果沒有注意就可能溢位,覆蓋了緊挨著這個字元的資料。對於SDS 而言增加字串長度需要驗證 free的長度,如果free 不夠就會擴容整個 buf,防止溢位。

  • 減少修改字串長度時造成的記憶體再次分配。 redis 作為高效能的記憶體資料庫,需要較高的相應速度。字串也很大概率的頻繁修改。 SDS 通過未使用空間這個引數,將字串的長度和底層buf的長度之間的額關係解除了。buf的長度也不是字串的長度。基於這個分設計 SDS 實現了空間的預分配和惰性釋放。

    1. 預分配 如果對 SDS 修改後,如果 len 小於 1MB 那 len = 2 * len + 1byte。 這個 1 是用於儲存空位元組。 如果 SDS 修改後 len 大於 1MB 那麼 len = 1MB + len + 1byte。
    2. 惰性釋放 如果縮短 SDS 的字串長度,redis並不是馬上減少 SDS 所佔記憶體。只是增加 free 的長度。同時向外提供 API 。真正需要釋放的時候,才去重新縮小 SDS 所佔的記憶體
  • 二進位制安全。 C 語言中的字串是以 ”\0“ 作為字串的結束標記。而 SDS 是使用 len 的長度來標記字串的結束。所以SDS 可以儲存字串之外的任意二進位制流。因為有可能有的二進位制流在流中就包含了”\0“造成字串提前結束。也就是說 SDS 不依賴 "\0" 作為結束的依據。

  • 相容C語言 SDS 按照慣例使用 ”\0“ 作為結尾的管理。部分普通C 語言的字串 API 也可以使用。

連結串列

C語言中並沒有連結串列這個資料結構所以 Redis 自己實現了一個。Redis 中的連結串列是:


typedef struct listNode {

    // 前置節點
    struct listNode *prev;

    // 後置節點
    struct listNode *next;

    // 節點的值
    void *value;

} listNode;

複製程式碼

非常典型的雙向連結串列的資料結構。

同時為雙向連結串列提供瞭如下操作的函式:


/*
 * 雙端連結串列迭代器
 */
typedef struct listIter {

    // 當前迭代到的節點
    listNode *next;

    // 迭代的方向
    int direction;

} listIter;

/*
 * 雙端連結串列結構
 */
typedef struct list {

    // 表頭節點
    listNode *head;

    // 表尾節點
    listNode *tail;

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

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

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

    // 連結串列所包含的節點數量
    unsigned long len;

} list;

複製程式碼

連結串列的結構比較簡單,資料結構如下:

list

總結一下性質:

  • 雙向連結串列,某個節點尋找上一個或者下一個節點時間複雜度 O(1)。
  • list 記錄了 head 和 tail,尋找 head 和 tail 的時間複雜度為 O(1)。
  • 獲取連結串列的長度 len 時間複雜度 O(1)。

字典

字典資料結構極其類似 java 中的 Hashmap。

Redis的字典由三個基礎的資料結構組成。最底層的單位是雜湊表節點。結構如下:


typedef struct dictEntry {
    
    // 鍵
    void *key;

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

    // 指向下個雜湊表節點,形成連結串列
    struct dictEntry *next;

} dictEntry;

複製程式碼

實際上雜湊表節點就是一個單項列表的節點。儲存了一下下一個節點的指標。 key 就是節點的鍵,v是這個節點的值。這個 v 既可以是一個指標,也可以是一個 uint64_t或者 int64_t 整數。*next 指向下一個節點。

通過一個雜湊表的陣列把各個節點連結起來:

typedef struct dictht {
    
    // 雜湊表陣列
    dictEntry **table;

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

    // 該雜湊表已有節點的數量
    unsigned long used;

} dictht;

複製程式碼

dictht

通過圖示我們觀察:

dictht.png

實際上,如果對java 的基本資料結構瞭解的同學就會發現,這個資料結構和 java 中的 HashMap 是很類似的,就是陣列加連結串列的結構。

字典的資料結構:

typedef struct dict {

    // 型別特定函式
    dictType *type;

    // 私有資料
    void *privdata;

    // 雜湊表
    dictht ht[2];

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

    // 目前正在執行的安全迭代器的數量
    int iterators; /* number of iterators currently running */

} dict;

複製程式碼

其中的dictType 是一組方法,程式碼如下:

/*
 * 字典型別特定函式
 */
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;

複製程式碼

字典的資料結構如下圖:

dict

這裡我們可以看到一個dict 擁有兩個 dictht。一般來說只使用 ht[0],當擴容的時候發生了rehash的時候,ht[1]才會被使用。

當我們觀察或者研究一個hash結構的時候偶我們首先要考慮的這個 dict 如何插入一個資料?

我們梳理一下插入資料的邏輯。

  • 計算Key 的 hash 值。找到 hash 對映到table 陣列的位置。

  • 如果資料已經有一個 key存在了。那就意味著發生了 hash 碰撞。新加入的節點,就會作為連結串列的一個節點接到之前節點的 next 指標上。

  • 如果 key 發生了多次碰撞,造成連結串列的長度越來越長。會使得字典的查詢速度下降。為了維持正常的負載。Redis 會對 字典進行 rehash 操作。來增加 table 陣列的長度。所以我們要著重瞭解一下 Redis 的 rehash。步驟如下:

    1. 根據 ht[0] 的資料和操作的型別(擴大或縮小),分配 ht[1] 的大小。
    2. 將 ht[0] 的資料 rehash 到 ht[1] 上。
    3. rehash 完成以後,將ht[1] 設定為 ht[0],生成一個新的ht[1]備用。
  • 漸進式的 rehash 。 其實如果字典的 key 數量很大,達到千萬級以上,rehash 就會是一個相對較長的時間。所以為了字典能夠在 rehash 的時候能夠繼續提供服務。Redis 提供了一個漸進式的 rehash 實現,rehash的步驟如下:

    1. 分配 ht[1] 的空間,讓字典同時持有 ht[1] 和 ht[0]。
    2. 在字典中維護一個 rehashidx,設定為 0 ,表示字典正在 rehash。
    3. 在rehash期間,每次對字典的操作除了進行指定的操作以外,都會根據 ht[0] 在 rehashidx 上對應的鍵值對 rehash 到 ht[1]上。
    4. 隨著操作進行, ht[0] 的資料就會全部 rehash 到 ht[1] 。設定ht[0] 的 rehashidx 為 -1,漸進的 rehash 結束。

這樣保證資料能夠平滑的進行 rehash。防止 rehash 時間過久阻塞執行緒。

  • 在進行 rehash 的過程中,如果進行了 delete 和 update 等操作,會在兩個雜湊表上進行。如果是 find 的話優先在ht[0] 上進行,如果沒有找到,再去 ht[1] 中查詢。如果是 insert 的話那就只會在 ht[1]中插入資料。這樣就會保證了 ht[1] 的資料只增不減,ht[0]的資料只減不增。

歡迎關注我的微信公眾號:

二維碼

相關文章