Redis 數據結構——字典

字典

一個鍵(key)可以和一個值(value)進行關聯(或者說將鍵映射爲值), 這些關聯的鍵和值就被稱爲鍵值對。

字典在 Redis 中的應用相當廣泛, 比如 Redis 的數據庫就是使用字典來作爲底層實現的, 對數據庫的增、刪、查、改操作也是構建在對字典的操作之上的。

除了用來表示數據庫之外, 字典還是哈希鍵的底層實現之一: 當一個哈希鍵包含的鍵值對比較多, 又或者鍵值對中的元素都是比較長的字符串時, Redis 就會使用字典作爲哈希鍵的底層實現。

Redis 的字典使用哈希表作爲底層實現, 一個哈希表裏面可以有多個哈希表節點, 而每個哈希表節點就保存了字典中的一個鍵值對。

哈希表

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

typedef struct dictht {
​
    // 哈希表數組
    dictEntry **table;
​
    // 哈希表大小
    unsigned long size;
​
    // 哈希表大小掩碼,用於計算索引值
    // 總是等於 size - 1
    unsigned long sizemask;
​
    // 該哈希表已有節點的數量
    unsigned long used;
​
} dictht;

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

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

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

哈希表節點

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

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)的問題。

 

字典

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

typedef struct dict {
​
    // 類型特定函數
    dictType *type;
​
    // 私有數據
    void *privdata;
​
    // 哈希表
    dictht ht[2];
​
    // rehash 索引
    // 當 rehash 不在進行時,值爲 -1
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */
​
} dict;

type 屬性和 privdata 屬性是針對不同類型的鍵值對, 爲創建多態字典而設置的:

  • 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 *privdata, const void *key1, const void *key2);
​
    // 銷燬鍵的函數
    void (*keyDestructor)(void *privdata, void *key);
​
    // 銷燬值的函數
    void (*valDestructor)(void *privdata, void *obj);
​
} dictType;

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

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

 

哈希算法

當要將一個新的鍵值對添加到字典裏面時, 程序需要先根據鍵值對的鍵計算出哈希值和索引值, 然後再根據索引值, 將包含新鍵值對的哈希表節點放到哈希表數組的指定索引上面。

Redis 計算哈希值和索引值的方法如下:

# 使用字典設置的哈希函數,計算鍵 key 的哈希值
hash = dict->type->hashFunction(key);
​
# 使用哈希表的 sizemask 屬性和哈希值,計算出索引值
# 根據情況不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;

當字典被用作數據庫的底層實現, 或者哈希鍵的底層實現時, Redis 使用 MurmurHash2 算法來計算鍵的哈希值。

解決鍵衝突

Redis 的哈希表使用鏈地址法(separate chaining)來解決鍵衝突: 每個哈希表節點都有一個 next 指針, 多個哈希表節點可以用 next 指針構成一個單向鏈表, 被分配到同一個索引上的多個節點可以用這個單向鏈表連接起來, 這就解決了鍵衝突的問題。

因爲 dictEntry 節點組成的鏈表沒有指向鏈表表尾的指針, 所以爲了速度考慮, 程序總是將新節點添加到鏈表的表頭位置O(1), 排在其他已有節點的前面。

rehash

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

擴展和收縮哈希表的工作可以通過執行 rehash (重新散列)操作來完成, Redis 對字典的哈希表執行 rehash 的步驟如下:

  1. 爲字典的 ht[1] 哈希表分配空間, 這個哈希表的空間大小取決於要執行的操作, 以及 ht[0] 當前包含的鍵值對數量 (也即是ht[0].used 屬性的值):

    • 如果執行的是擴展操作, 那麼 ht[1] 的大小爲第一個大於等於 ht[0].used * 2 的 2N(2n 次方冪);

    • 如果執行的是收縮操作, 那麼 ht[1] 的大小爲第一個大於等於 ht[0].used 的 2N 。

  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

計算得出。

根據 BGSAVE 命令或 BGREWRITEAOF 命令是否正在執行, 服務器執行擴展操作所需的負載因子並不相同, 這是因爲在執行 BGSAVE 命令或BGREWRITEAOF 命令的過程中, Redis 需要創建當前服務器進程的子進程, 而大多數操作系統都採用寫時複製(copy-on-write)技術來優化子進程的使用效率, 所以在子進程存在期間, 服務器會提高執行擴展操作所需的負載因子, 從而儘可能地避免在子進程存在期間進行哈希表擴展操作, 這可以避免不必要的內存寫入操作, 最大限度地節約內存。

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

漸進式rehash

擴展或收縮哈希表需要將 ht[0] 裏面的所有鍵值對 rehash 到 ht[1] 裏面, 但是, 這個 rehash 動作並不是一次性、集中式地完成的, 而是分多次、漸進式地完成的。

這樣做的原因在於, 如果 ht[0] 裏只保存着四個鍵值對, 那麼服務器可以在瞬間就將這些鍵值對全部 rehash 到 ht[1] ; 但是, 如果哈希表裏保存的鍵值對數量不是四個, 而是四百萬、四千萬甚至四億個鍵值對, 那麼要一次性將這些鍵值對全部 rehash 到 ht[1] 的話, 龐大的計算量可能會導致服務器在一段時間內停止服務。

因此, 爲了避免 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 執行期間的哈希表操作

因爲在進行漸進式 rehash 的過程中, 字典會同時使用 ht[0]ht[1] 兩個哈希表, 所以在漸進式 rehash 進行期間, 字典的刪除(delete)、查找(find)、更新(update)等操作會在兩個哈希表上進行: 比如說, 要在字典裏面查找一個鍵的話, 程序會先在 ht[0] 裏面進行查找, 如果沒找到的話, 就會繼續到 ht[1] 裏面進行查找, 諸如此類。

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

字典API

函數 作用 時間複雜度
dictCreate 創建一個新的字典。
dictAdd 將給定的鍵值對添加到字典裏面。
dictReplace 將給定的鍵值對添加到字典裏面, 如果鍵已經存在於字典,那麼用新值取代原有的值。
dictFetchValue 返回給定鍵的值。
dictGetRandomKey 從字典中隨機返回一個鍵值對。
dictDelete 從字典中刪除給定鍵所對應的鍵值對。
dictRelease 釋放給定字典,以及字典中包含的所有鍵值對。 N 爲字典包含的鍵值對數量。

總結

  • 字典被廣泛用於實現 Redis 的各種功能, 其中包括數據庫和哈希鍵。

  • Redis 中的字典使用哈希表作爲底層實現, 每個字典帶有兩個哈希表, 一個用於平時使用, 另一個僅在進行 rehash 時使用。

  • 當字典被用作數據庫的底層實現, 或者哈希鍵的底層實現時, Redis 使用 MurmurHash2 算法來計算鍵的哈希值。

  • 哈希表使用鏈地址法來解決鍵衝突, 被分配到同一個索引上的多個鍵值對會連接成一個單向鏈表。

  • 在對哈希表進行擴展或者收縮操作時, 程序需要將現有哈希表包含的所有鍵值對 rehash 到新哈希表裏面, 並且這個 rehash 過程並不是一次性地完成的, 而是漸進式地完成的。

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