【最完整系列】Redis-結構篇-字典

Redis 字典

在 redis 中咱們常常用到的 hash 結構,以及整個 redis 的 db 中 key-value 結構,都是以 dict 的形式存在,也就是字典。redis

源碼結構

// 字典結構
    typedef struct dict {
    	// 類型特定函數
        dictType *type; 
    	// 保存類型特定函數須要使用的參數
        void *privdata; 
    	// 保存的兩個哈希表,ht[0]是真正使用的,ht[1]會在rehash時使用
        dictht ht[2]; 
    	// rehash進度,若是不等於-1,說明還在進行rehash
        long rehashidx;
    	// 正在運行中的遍歷器數量
        unsigned long iterators; 
    } dict;
    
    // hashtable結構
    typedef struct dictht {
    	// 哈希表節點數組
        dictEntry **table; 
    	// 哈希表大小
        unsigned long size; 
    	// 哈希表大小掩碼,用於計算哈希表的索引值,大小老是dictht.size - 1
        unsigned long sizemask; 
    	// 哈希表已經使用的節點數量
        unsigned long used; 
    } dictht;
    
    // hashtable的鍵值對節點結構
    typedef struct dictEntry {
    	// 鍵名
        void *key; 
    	// 值
        union {
            void *val;
            uint64_t u64;
            int64_t s64;
            double d;
        } v; 
    	// 指向下一個節點, 將多個哈希值相同的鍵值對鏈接起來
        struct dictEntry *next; 
    } dictEntry;
複製代碼

由上面的結構咱們能夠看到 dict 結構內部包含兩個 hashtable(如下簡稱ht),一般狀況下只有一個 ht 是有值的。ht 是一個 dictht 的的結構,dictht 的結構和 Java 的 HashMap 幾乎是同樣的,都是經過分桶的方式解決 hash 衝突。第一維是數組,第二維是鏈表。數組中存儲的是第二維鏈表的第一個元素的指針。這個指針在 ht 中就是指向一個 dictEntry 結構,裏面存放着鍵值對的數據,以及指向下一個節點的指針。數組

Hash計算

Redis 計算哈希值和索引值的方法以下:服務器

// 使用字典設置的哈希函數,計算鍵 key 的哈希值
    hash = dict->type->hashFunction(key);
    // 使用哈希表的 sizemask 屬性和哈希值,計算出索引值
    // 根據狀況不一樣, ht 能夠是 ht[0] 或者 ht[1]
    index = hash & dict->ht.sizemask;
複製代碼

hash函數咱們這裏就不說明了,計算出的 hash 值後將該值和 ht 的長度掩碼(長度 - 1 )作與運算得出數組的索引值,這裏我要解釋下這麼作的緣由:函數

  1. 保證不會發生數組越界 首先咱們要知道,ht 中數組的長度按規定必定是2的冪(2的n次方)。所以,數組的長度的二進制形式是:10000…000,1後面有一堆0。那麼,dict->ht.sizemask(dict->ht.size - 1) 的二進制形式就是01111…111,0後面有一堆1。最高位是0,和hash值相「與」,結果值必定不會比數組的長度值大,所以也就不會發生數組越界。ui

  2. 保證元素儘量的均勻分佈 由上邊的分析可知,dict->ht.size 必定是一個偶數,dict->ht.sizemask 必定是一個奇數。假設如今數組的長度(dict->ht.size)爲16,減去1後(dict->ht.sizemask)就是15,15對應的二進制是:1111。如今假設有兩個元素須要插入,一個哈希值是8,二進制是1000,一個哈希值是9,二進制是1001。和1111「與」運算後,結果分別是1000和1001,它們被分配在了數組的不一樣位置,這樣,哈希的分佈很是均勻。spa

    那麼,若是數組長度是奇數呢?減去1後(dict->ht.sizemask)就是偶數了,偶數對應的二進制最低位必定是 0,例如14二進制1110。對上面兩個數子分別「與」運算,獲得1000和1000。結果都是同樣的值。那麼,哈希值8和9的元素都被存儲在數組同一個index位置的鏈表中。在操做的時候,鏈表中的元素越多,效率越低,由於要不停的對鏈表循環比較。3d

爲何 ht 中數組的長度必定是2的n次方?由於其實計算索引的過程其實就是取模(求餘數),可是取餘操做 % 的效率沒有位運算 & 來的高,而 hash%length==hash&(length-1)的條件就是 length 是 2的次方,這裏的緣由上面也解釋過了。指針

漸進式rehash

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

Redis 對字典的哈希表執行 rehash 的步驟以下:cdn

  1. 爲字典的 ht[1] 哈希表分配空間, 這個哈希表的空間大小取決於要執行的操做, 以及 ht[0] 當前包含的鍵值對數量 (也便是 ht[0].used 屬性的值):
    • 若是執行的是擴展操做, 那麼 ht[1] 的大小爲第一個大於等於 ht[0].used * 2 的 2^n (2 的 n 次方冪);
    • 若是執行的是收縮操做, 那麼 ht[1] 的大小爲第一個大於等於 ht[0].used 的 2^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 作準備。

這就是爲何redis 的 dict 中要保存2個 ht 的緣由,方便2個 ht 的遷移替換。

爲何不直接複製 ht[0] 中的全部節點到 ht[1] 上而是 rehash 一遍?

咱們在看一遍計算索引的公式:index = hash & dict->ht.sizemask;

注意到了嗎,索引值的計算與字典數組的長度有關,而咱們rehash時數組的長度是已經變化了,因此須要從新計算。

那麼rehash的條件是什麼呢,ht 達到什麼樣的數量redis會去執行rehash呢?

當如下條件中的任意一個被知足時, 程序會自動開始對哈希表執行擴展操做:

  1. 服務器目前沒有在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 而且哈希表的負載因子大於等於 1 ;
  2. 服務器目前正在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 而且哈希表的負載因子大於等於 5 ;

其中哈希表的負載因子能夠經過公式:

// 負載因子 = 哈希表已保存節點數量 / 哈希表大小
    load_factor = ht[0].used / ht[0].size
複製代碼

負載因子其實就是一個哈希表的使用比例,用來衡量哈希表的容量狀態。

bgsave 或 bgrewriteaof 命令會形成內存頁的過多分離 (Copy On Write),Redis 儘可能不去擴容 ,可是若是 hash 表已經很是滿了,元素的個數已經達到了第一維數組長度的 5 倍,這個時候就會強制擴容。

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

爲何稱爲漸進式?

擴展或收縮哈希表須要將 ht[0] 裏面的全部鍵值對 rehash 到 ht[1] 裏面, 可想而知大字典的 rehash過程是很耗時的,因此 redis 使用了一種漸進式的 rehash,也就是慢慢地將 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的過程當中,咱們的字典裏 ht[0] 和 ht[1] 會同時存在數據,那麼這時候操做字典會不會混亂呢,redis爲此提出瞭如下的邏輯判斷:

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

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

相關文章
相關標籤/搜索