1. 程式人生 > >Redis設計與實現筆記 4.字典

Redis設計與實現筆記 4.字典

字典,又稱為符號表(symbol table),關聯陣列(associative)或對映(map),是一種用於儲存鍵值對的抽象資料結構。

在欄位中,一個鍵(key)可以和一個值(value)進行關聯(或者說將鍵對映為值),這些關聯的鍵和值就稱為鍵值對。

字典中的每個鍵都是獨一無二的,程式可以在字典中根據鍵查詢,更新和刪除與之關聯的值。

字典在Redis中的應用相當廣泛,比如1.Redis的資料庫就是使用字典作為底層實現的,對資料庫的增刪改查操作也是構建在對字典的操作之上的。2.字典還是雜湊鍵的底層實現之一,當一個雜湊鍵包含的鍵值對比較多,又或者鍵值對中的元素都是比較長的字串時,Redis就會使用字典作為雜湊鍵的底層實現。

4.1 字典的實現

Redis的字典使用雜湊表作為底層實現,一個雜湊表裡可以有多個雜湊表節點,而每個雜湊表節點就儲存了字典中的一個鍵值對。

4.1.1 雜湊表

//dict.h/dictht

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

    //雜湊表大小
    unsigned long size;

    //雜湊表大小掩碼,用於計算索引值
    //總是等於size-1
    unsigned long sizemask;
    
    //雜湊表已有節點數量
    unsigned long used;
} distht;

table屬性是一個數組,陣列中的每個元素都是一個指向dict.h/dictEntry結構體的指標,每個dictEntry結構儲存著一個鍵值對。

size屬性記錄了雜湊表的大小,也即是table陣列的大小,而used屬性則記錄了雜湊表目前已有節點(鍵值對)的數量。

sizemask屬性的值總是等於size-1這個屬性和雜湊值一起決定一個鍵應該放到table陣列的哪個索引上面

4.1.2 雜湊表節點

typedef struct dictEntry {

    //鍵
    void *key;

    //值
    union {
        void *val;
        uint64_t u64;
        int64+t s64;
    } v;

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

key屬性儲存著鍵值對中的鍵,而v屬性則儲存著鍵值對中的值,其中鍵值對的值可以是一個指標,或者是一個uint64_t整數,又或者是一個int64_t整數。

next屬性是指向另一個雜湊表節點的指標,這個指標可以將多個雜湊值相同的鍵值對連線起來,以此來解決鍵衝突(collision)的問題。

4.1.3 字典

Redis中的字典由dict.h/dict結構表示

typedef struct dict {
    
    //型別特定函式
    dictType *type;

    //私有資料
    void *privdata;

    //雜湊表
    dictht ht[2];

    //rehash索引
    //當rehash不在進行時,值為-1
    //int trehashidx;
} dict;

type屬性是一個指向dictType結構體的指標,每個dictType結構體儲存了一簇用於操作特定型別鍵值對的函式,Redis會用用途不同的字典設定不同的型別特定函式。

而privdata屬性咋儲存了需要傳給那些型別特定函式的可選引數。

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 *privadata, const void *key1, const void *key2);

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

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

ht屬性是一個包含兩個項的陣列,陣列中的每個項都是一個dictht雜湊表,一般情況下,字典只使用ht[0]雜湊表,ht[1]雜湊表只會在ht[0]雜湊表進行rehash時使用。

除了ht[1]之外,另一個與rehash有關的屬性就是rehashidx,它記錄了rehash目前的進度,如果目前沒有進行rehash,那麼它的值為-1。

4.2 雜湊演算法

當要將一個新的鍵值對新增到字典裡面時,程式需要先根據鍵值對的鍵計算出雜湊值索引值。然後再根據索引值,將包含新鍵值對的雜湊表節點放到雜湊表陣列的指定索引上面。

Redis計算雜湊值和索引值的方法如下:

//使用字典設定的雜湊函式,計算鍵key的雜湊值

hash = dict->type->hashFunction(key);

//使用雜湊表的sizemask屬性和雜湊值,計算出索引值
//根據情況不同,ht[x]可以是ht[0]或ht[1]

index = hash & dict->ht[x].sizemask;

當字典被用作資料庫的底層實現,或者雜湊鍵的底層實現時,Redis使用MurmurHash2演算法來計算鍵的雜湊值。

4.3 解決鍵衝突

當有兩個或者以上數量的鍵被分配到了雜湊表陣列的同一個索引上面時,我們稱這些鍵發生了衝突。

Redis的雜湊表使用鏈地址法(separate chaining)來解決鍵衝突,每個雜湊表節點都有一個next指標,多個雜湊表節點可以用next指標構成一個單項鍊表,被分配到同一個索引上的節點可以用這個單向連結串列連線起來,這就解決了鍵衝突的問題。(因為dictEntry節點組成的連結串列沒有指向連結串列表尾的指標,所以為了速度考慮,程式總是將新節點新增到連結串列的表頭位置(複雜度為O(1)))。

4.4 rehash

為了讓雜湊表的負載因子(load factor)維持在一個合理的範圍內,當雜湊表儲存的鍵值對數量太多或者太少時,程式需要對雜湊表的大小進行相應的擴充套件或收縮。

Redis對字典的雜湊表執行rehash的步驟如下:

  1. 為字典的ht[1]雜湊表分配空間,這個雜湊表的空間大小取決於要執行的操作,以及ht[0]當前包含的鍵值對數量(也即是ht[0].used屬性的值):         A.如果執行的是擴充套件操作,那麼ht[1]的大小為第一個大於ht[0].used*22^{n}(2的n次方冪);         B.如果執行的是收縮操作,那麼ht[1]的大小為第一個不大於ht[0].used2^{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。 其中,負載因子公式:
    #負載因子 = 雜湊表已儲存節點數量 / 雜湊表大小
    load_factor = ht[0].used / ht[0].size

另一方面, 當雜湊表的負載因子小於 0.1 時, 程式自動開始對雜湊表執行收縮操作。

4.5 漸進式rehash

rehash 動作並不是一次性、集中式地完成的, 而是分多次、漸進式地完成的。原因是Redis是單執行緒模型。

因此, 為了避免 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 操作的執行而最終變成空表。