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

sidfate發表於2020-01-26

Redis 字典

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

原始碼結構

    // 字典結構
    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 結構,裡面存放著鍵值對的資料,以及指向下一個節點的指標。

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

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值相“與”,結果值一定不會比陣列的長度值大,因此也就不會發生陣列越界。

  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,它們被分配在了陣列的不同位置,這樣,雜湊的分佈非常均勻。

    那麼,如果陣列長度是奇數呢?減去1後(dict->ht.sizemask)就是偶數了,偶數對應的二進位制最低位一定是 0,例如14二進位制1110。對上面兩個數子分別“與”運算,得到1000和1000。結果都是一樣的值。那麼,雜湊值8和9的元素都被儲存在陣列同一個index位置的連結串列中。在操作的時候,連結串列中的元素越多,效率越低,因為要不停的對連結串列迴圈比較。

為什麼 ht 中陣列的長度一定是2的n次方?因為其實計算索引的過程其實就是取模(求餘數),但是取餘操作 % 的效率沒有位運算 & 來的高,而 hash%length==hash&(length-1)的條件就是 length 是 2的次方,這裡的原因上面也解釋過了。

漸進式rehash

隨著操作的不斷執行, 雜湊表儲存的鍵值對會逐漸地增多或者減少, 為了讓雜湊表的負載因子(load factor)維持在一個合理的範圍之內, 當雜湊表儲存的鍵值對數量太多或者太少時, 程式需要對雜湊表的大小進行相應的擴充套件或者收縮,也就是 rehash。

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

  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 操作的執行而最終變成空表。

相關文章