Redis底層數據結構之hash


Redis底層數據結構之hash

hash是日常開發過程中使用Redis的一個數據結構,其底層實現方式有兩種,如下所示。一種是zipList,這種是當hash結構的V值較小的時候使用的編碼方式。這個已經在上一篇文章中介紹過了。這篇文章主要講解一下另外一種實現方式,字典dict,當hash結構的V值較大時采用的編碼方式。

dict

這里又要開始鞭屍C語言了,字典dict作為一種常用的數據結構,C語言內部並不具備,因而Redis的開發人員自己設計和開發了Redis中的dict結構,其定義如下:

typedf struct dict{
    dictType *type;//類型特定函數,包括一些自定義函數,這些函數使得key和
                   //value能夠存儲
    void *private;//私有數據
    dictht ht[2];//兩張hash表 
    int rehashidx;//rehash索引,字典沒有進行rehash時,此值為-1
    unsigned long iterators; //正在迭代的迭代器數量
}dict;
  • typeprivate這兩個屬性是為了實現字典多態而設置的,當字典中存放着不同類型的值,對應的一些復制,比較函數也不一樣,這兩個屬性配合起來可以實現多態的方法調用;
  • ht[2],兩個hash
  • rehashidx,這是一個輔助變量,用於記錄rehash過程的進度,以及是否正在進行rehash等信息,當此值為-1時,表示該dict此時沒有rehash過程
  • iterators,記錄此時dict有幾個迭代器正在進行遍歷過程

dictht

由上面可以看出,dict本質上是對哈希表dictht的一個簡單封裝,dictht的定義如下所示:

typedf struct dictht{
    dictEntry **table;//存儲數據的數組 二維
    unsigned long size;//數組的大小
    unsigned long sizemask;//哈希表的大小的掩碼,用於計算索引值,總是等於 
                           //size-1
    unsigned long used;//// 哈希表中中元素個數
}dictht;

table是一個dictEntry類型的數組,用於真正存儲數據;size表示table這個數組的大小;sizemask用於計算索引位置,且總是等於size-1used表示dictht中已有的節點數量,其示意圖如下所示:

dictEntry

上面分析dictht時說到,真正存儲數據的結構是dictEntry數組,其結構定義如下:

typedf struct dictEntry{
    void *key;//鍵
    union{
        void val;
        unit64_t u64;
        int64_t s64;
        double d;
    }v;//值
    struct dictEntry *next;//指向下一個節點的指針
}dictEntry;

其示意圖如下所示:

image-20200831175713878

最后整個dict的結構示意圖如上所示:

上圖是一個沒有處於rehash狀態下的字典dict,整個dict中有兩個哈希表dictht,其中一個哈希表存儲數據,另一個哈希表為空。

擴容與縮容

當哈希表中元素數量逐漸增加時,此時產生hash沖突的概率逐漸增大,且由於dict也是采用拉鏈法解決hash沖突的,隨着hash沖突概率上升,鏈表會越來越長,這就會導致查找效率下降。相反,當元素不斷減少時,元素占用dict的空間就越少,出於對內存的極致利用,此時就需要進行縮容操作。

既然說到擴容和縮容,熟悉Java集合的小伙伴是不是想到了什么。不錯,那就是負載因子負載因子一般用於描述集合當前被填充的程度。在Redis的字典dict中,負責因子=哈希表中已保存節點數量/哈希表的大小,即:

load factor = ht[0].used / ht[0].size

Redis中,三條關於擴容和縮容的規則:

  • 沒有執行BGSAVE和BGREWRITEAOF指令的情況下,哈希表的負載因子大於等於1時進行擴容;
  • 正在執行BGSAVE和BGREWRITEAOF指令的情況下,哈希表的負載因大於等於5時進行擴容;
  • 負載因子小於0.1時,Redis自動開始對哈希表進行收縮操作;

其中,擴容和縮容的數量大小也有一定的規則:

  • 擴容:擴容后的dictEntry數組數量為第一個大於等於ht[0].used * 22^n
  • 縮容:縮容后的dictEntry數組數量為第一個大於等於ht[0].used2^n

rehash

Java中的HashMap類似,當Redis中的dict進行擴容或者縮容,會發生reHash過程。JavaHashMaprehash過程如下:新建一個哈希表,一次性將當前所有節點進行rehash然后復制到新哈希表相應的位置上,之后釋放掉原有的hash表,而持有新的表,這個過程是一個時間復雜度為O(n)的操作。而對於單線程的Redis而言很難承受這么高時間復雜度的操作,因而其rehash的過程有所不同,使用的是一種稱之為漸進式rehash的方式,一點一點地進行搬遷。其過程如下:

  • 假設當前數據在dictht[0]中,那么首先為dictht[1]分配足夠的空間,如果是擴容,則dictht[1]大小就按照擴容規則設置;如果是縮減,則dictht[1]大小就按照縮減規則進行設置;
  • 在字典dict中維護一個變量,rehashidx=0,表示rehash正式開始;
  • rehash進行期間,每次對字典執行添加、刪除、查找或者更新操作時,程序除了執行指定的操作以外,還會順帶將dictht[0]哈希表在rehashidx索引上的所有鍵值對rehashdictht[1],當一次rehash工作完成之后,程序將rehashidx屬性的值+1。同時在serverCron中調用rehash相關函數,在1ms的時間內,進行rehash處理,每次僅處理少量的轉移任務(100個元素);
  • 隨着字典操作的不斷執行,最終在某個時間點上,dictht[0]的所有鍵值對都會被rehashdictht[1],這時程序將rehashidx屬性的值設為-1,表示rehash操作已完成;

上述就是Redisdict漸進式rehash過程,但在這個過程會存在兩個明顯問題。第一,第三步說了,每次對字典執行增刪改查時才會觸發rehash過程,萬一某一時間段並沒有任何請求命令呢?此時應該怎么辦?第二,在維護兩個dictht的時候,此時哈希表如何正常對外提供服務?

Redis的設計人員在設計時就已經考慮到了這兩個問題。對於第一個問題,Redis在有一個定時器,會定時去判斷rehash是否完成,如果沒有完成,則繼續進行rehash。定時函數如下所示:

// 服務器定時任務
void databaseCron() {
         ...
         if (server.activerehashing) {
            for (j = 0; j < dbs_per_call; j++) {
                 int work_done = incrementallyRehash(rehash_db);//rehash方法
                 if (work_done) {
                       /* If the function did some work, stop here, we'll do
                        * more at the next cron loop. */
                       break;
                  } else {
                 /* If this db didn't need rehash, we'll try the next one. */
                      rehash_db++;
                      rehash_db %= server.dbnum;
                  }
             }
        }
}

對於第二個問題,對於添加操作,會將新的數據直接添加到dictht[1]上面,這樣就可以保證dictht[0]上的數量只減少不增加。而對於刪除、更改、查詢操作,會直接在dictht[0]上進行,尤其是這三個操作,都會涉及到查詢,當在dictht[0]上查詢不到時,會接着去dictht[1]上查找,如果再找不到,則表明不存在該K-V值。

漸進式rehash的優缺點

優點:采用了分而治之的思想,將 rehash 操作分散到每一個對該哈希表的操作上以及定時函數上,避免了集中式 rehash 帶來的性能壓力;

缺點:在 rehash 的時間內,需要保存兩個 hash 表,對內存的占用稍大,而且如果在 redis 服務器本來內存滿了的時候,突然進行 rehash 會造成大量的 key 被拋棄

思考題

為什么擴容的時候要考慮BIGSAVE的影響,而縮容時不需要?

  • BIGSAVE時,dict要是進行擴容,則此時就需要為dictht[1]分配內存,若是dictht[0]的數據量很大時,就會占用更多系統內存,造成內存頁過多分離,所以為了避免系統耗費更多的開銷去回收內存,此時最好不要進行擴容;
  • 縮容時,結合縮容的條件,此時負載因子<0.1,說明此時dict中數據很少,就算為dictht[1]分配內存,也消耗不了多少資源;

總結

參考

Redis深度歷險:核心原理和應用實踐
Redis系列(六)底層數據結構之字典
redis哈希表的rehash分析
圖解redis五種數據結構底層實現


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM