【Redis5源碼學習】2019-04-19 字典dict

baiyanphp

所有視頻:【每日學習記錄】使用錄像設備記錄天天的學習redis

字典是啥

dict,即字典,也被稱爲哈希表hashtable。在redis的五大數據結構中,有以下兩種情形會使用dict結構:算法

  • hash:數據量小的時候使用ziplist,量大時使用dict
  • zset:數據量小的時候使用ziplist,數據量大的時候使用skiplist + dict

結合以上兩種狀況,咱們能夠看出,dict也是一種較爲複雜的數據結構,一般用在數據量大的情形中。一般狀況下,一個dict長這樣:

在這個哈希表中,每一個存儲單元被稱爲一個桶(bucket)。咱們向這個dict(hashtable)中插入一個"name" => "baiyan"的key-value對,假設對這個key 「name」作哈希運算結果爲3,那麼咱們尋找這個hashtable中下標爲3的位置並將其插入進去,獲得如圖所示的情形。咱們能夠看到,dict最大的優點就在於其查找的時間複雜度爲O(1),是任何其它數據結構所不能比擬的。咱們在查找的時候,首先對key 」name「進行哈希運算,獲得結果3,咱們直接去dict索引爲3的位置進行查找,便可獲得value 」baiyan「,時間複雜度爲O(1),是至關快的。編程

redis中的字典

基本結構

在redis中,在普通字典的基礎上,爲了方便進行擴容與縮容,增長了一些描述字段。仍是以上面的例子爲基礎,在redis中存儲結構以下圖所示:

dictht的結構以下:segmentfault

typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

在dictht中,真正存儲數據的地方是**table這個dictEntry類型二級指針。咱們能夠把它拆分來看,首先第一個指針能夠表明一個一維數組,即哈希表。然後面的指針表明,在每一個一維數組(哈希表)的存儲單元中,存儲的都是一個dictEntry類型的指針,這個指針就指向咱們存儲key-value對的dictEntry類型結構的所在位置,如上圖所示。
存儲最終key-value對的dictEntry的結構以下:數組

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

一個存儲key-value對的entry,最主要仍是這裏的key和value字段。因爲存儲在dict中的key和value能夠是字符串、也能夠是整數等等,因此在這裏均用一個void * 指針來表示。咱們注意到最後有一個也是同類型dictEntry的next指針,它就是用來解決咱們常常說的哈希衝突問題。數據結構

哈希衝突

當咱們對不一樣的key進行哈希運算以後結果相同時,就碰到了哈希衝突的問題。經常使用的兩種哈希衝突的解決方案有兩種:開放定址法與鏈地址法。redis使用的是後者。經過這個next指針,咱們就能夠將哈希值相同的元素都串聯起來,解決哈希衝突的問題。注意在redis的源碼實現中,在往dict插入元素的時使用的是鏈表的頭插法,即將新元素插到鏈表的頭部,這樣就不用每次遍歷到鏈表的末尾進行插入,下降了插入的時間複雜度。架構

鏈地址法所帶來的問題

假設咱們一直往dict中插入元素,那麼這個哈希表的全部bucket都會被佔滿,並且在鏈地址法解決哈希衝突的過程當中,每一個bucket後面的鏈表會很是長。這樣一來,這個鏈表的時間複雜度就會逐漸退化成O(n)。對於總體的dict而言,其查詢效率就會大大下降。爲了解決數據量過大致使dict性能降低的問題,咱們須要對其進行擴容,來知足後續插入元素的存儲須要。性能

分而治之的rehash

  • 在一般狀況下,咱們會對哈希表作一個2倍的擴容,即由2->4,4->8等等。假設咱們的一個dict中已經存儲了好多數據,咱們還須要向這個dict中插入一大堆數據。在後續插入大量數據的過程當中,因爲咱們須要解決dict性能降低的問題,咱們須要對其進行擴容。因爲擴容的時候,須要對全部key-value對從新進行哈希運算,並從新分配到相應的bucket位置上,咱們稱這個過程爲爲rehash
  • 在rehash過程當中,須要作大量的哈希運算操做,其開銷是至關大、並且花費的時間是至關長的。因爲redis是單進程、單線程的架構,在執行rehash的過程當中,因爲其開銷大、時間長,會致使redis進程阻塞,進而沒法爲線上提供數據存儲服務,對外部會返回redis服務不可用。爲了解決一次性rehash所致使的redis進程阻塞的問題,利用分而治之的編程思想,將一次rehash操做分散到多個步驟當中去減少rehash給redis進程帶來的資源佔用。舉一個例子,可能會在後續的get、set操做中,進行一次rehash操做。爲了實現這種操做,redis其實設計了兩個哈希表,一個就是咱們以前講過的對外部提供存取服務的哈希表,而另外一個就專門用來作rehash操做。這種分而治之的思想,將一次大數據量的rehash操做分散到屢次完成,叫作漸進式rehash

  • 目前是剛剛要進行rehash的狀態。咱們能夠看到,在以前畫的圖的基礎上,咱們加入了一個新的結構dict,其中的ht[2]字段就負責指向兩個哈希表。下面一個哈希表的大小爲以前的大小8*2=16,沒有任何元素。關於dict的結構以下:
typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehash進程標識。若是值爲-1則不在rehash,不然在進行rehash */
    unsigned long iterators; /* number of iterators currently running */
} dict;
  • 注意其中的rehashidx字段,它表明咱們進行rehash的進程。注意咱們每次進行get或set等命令的時候,rehash就會進行一次,即把一個在原來哈希表ht[0]上的元素挪到新哈希表ht[1]中,注意一次只移動一個元素,移動完成以後,rehashidx就會+1,直到原來哈希表上全部的元素都挪到新哈希表上爲止。rehash完成以後,新哈希表ht[1]就會被置爲ht[0],爲線上提供服務。而原來的哈希表ht[0]就會被銷燬。rehash的源碼以下:
int dictRehash(dict *d, int n) {
    int empty_visits = n*10; /* Max number of empty buckets to visit. */
    if (!dictIsRehashing(d)) return 0;

    while(n-- && d->ht[0].used != 0) {
        dictEntry *de, *nextde;

        /* Note that rehashidx can't overflow as we are sure there are more
         * elements because ht[0].used != 0 */
        assert(d->ht[0].size > (unsigned long)d->rehashidx);
        while(d->ht[0].table[d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
        de = d->ht[0].table[d->rehashidx];
        /* 將老的哈希表ht[0]中的元素移動到新哈希表ht[1]中 */
        while(de) {
            uint64_t h;

            nextde = de->next;
            /* 計算新哈希表ht[1]的索引下標*/
            h = dictHashKey(d, de->key) & d->ht[1].sizemask; //哈希算法
            de->next = d->ht[1].table[h];
            d->ht[1].table[h] = de;
            d->ht[0].used--;
            d->ht[1].used++;
            de = nextde;
        }
        d->ht[0].table[d->rehashidx] = NULL;
        d->rehashidx++;
    }

    /* 檢查是否rehash完成,若完成則置rehashidx爲-1 */
    if (d->ht[0].used == 0) {
        zfree(d->ht[0].table);
        d->ht[0] = d->ht[1];
        _dictReset(&d->ht[1]);
        d->rehashidx = -1;
        return 0;
    }

    /* More to rehash... */
    return 1;
}

rehash過程當中可能帶來的問題

rehash對查找的影響

若是在rehash的過程當中(例如容量由4擴容到8),若是須要查找一個元素。首先咱們會計算哈希值(假設爲3)去找老的哈希表ht[0],若是咱們發現位置3上已經沒有了元素,說明這個元素已經被rehash過了,到新的哈希表上對應的位置3或7上尋找便可。學習

rehash對遍歷的影響

問題

試想這麼一種狀況:在rehash以前,咱們使用SCAN命令對dict進行第一次遍歷;而rehash結束以後咱們進行第二次SCAN遍歷,會發生什麼狀況?
在討論這個問題以前,咱們先熟悉一下SCAN命令。咱們知道在咱們執行keys這種返回全部key值的命令,因爲全部key加在一塊是至關多的,若是一次性所有把它遍歷完成,可以讓單進程的redis阻塞至關長的時間,在這段時間裏都沒法對外提供服務。爲了解決這個問題,SCAN命令橫空出世。它並非一次性將全部的key都返回,而是每次返回一部分key並記錄一下當前遍歷的進度,這裏用一個遊標去記錄。下次再次運行SCAN命令的時候,redis會從遊標的位置開始繼續往下遍歷。SCAN命令實際上也是一種分而治之的思想,這樣一次遍歷一小部分,直到遍歷完成。SCAN命令官方解釋以下:

SCAN 命令是一個基於遊標的 迭代器: SCAN 命令每次被調用以後, 都會向用戶返回一個新的遊標,用戶在下次迭代時須要使用這個新遊標做爲 SCAN 命令的遊標參數, 以此來延續以前的迭代過程。

SCAN命令的使用方法以下:

redis 127.0.0.1:6379> scan 0
1) "17"
2)  1) "key:12"
    2) "key:8"
    3) "key:4"
    4) "key:14"
    5) "key:16"
    6) "key:17"
    7) "key:15"
    8) "key:10"
    9) "key:3"
    10) "key:7"
    11) "key:1"

redis 127.0.0.1:6379> scan 17
1) "0"
2) 1) "key:5"
   2) "key:18"
   3) "key:0"
   4) "key:2"
   5) "key:19"
   6) "key:13"
   7) "key:6"
   8) "key:9"
   9) "key:11"
    • 在上面這個例子中,第一次迭代使用0做爲遊標,表示開始一次新的迭代。第二次迭代使用的是第一次迭代時返回的遊標,也便是命令回覆第一個元素的值17 。
    • 從上面的示例能夠看到, SCAN 命令的回覆是一個包含兩個元素的數組,第一個數組元素是用於進行下一次迭代的新遊標,而第二個數組元素則是一個數組,這個數組中包含了全部被迭代的元素。
    • 在第二次調用 SCAN 命令時,命令返回了遊標0,這表示迭代已經結束,整個數據集(collection)已經被完整遍歷過了。
    • 以0做爲遊標開始一次新的迭代,一直調用 SCAN 命令,直到命令返回遊標0,咱們稱這個過程爲一次完整遍歷。

    回到正題,咱們來解決以前的問題。 咱們簡化一下dict的結構,只留下兩個基本的哈希表結構,咱們如今有4個元素:十二、1三、1四、15,假設哈希算法爲取餘。

    • 假設如今咱們在沒有rehash以前,對其使用SCAN命令,基於咱們以前講過的知識點,因爲SCAN是基於遊標的增量遍歷,咱們假設這個SCAN命令只遍歷到遊標爲1的位置就中止了:

    • 咱們獲得第一次遍歷的結果爲:12
    • 開始進行rehash。
    • rehash結束,咱們再次使用SCAN命令對其進行遍歷。因爲上次返回的遊標爲1,咱們從1的位置繼續遍歷,只不過此次要在新的哈希表中進行遍歷了:

    • 第二次SCAN命令遍歷的結果爲:十二、1三、1四、15

    那麼咱們將兩次SCAN的結果合起來,爲十二、十二、1三、1四、15。咱們發現,元素12被多遍歷了一次,與咱們的預期不符。因此咱們得出結論:在rehash過程當中執行SCAN命令會致使遍歷結果出現冗餘

    解決方案

    爲了解決擴容和縮容進行rehash的過程當中重複遍歷的問題,redis對哈希表的下標作出了以下變化(v就是哈希表的下標):

    v = rev(v);
    v++;
    v = rev(v);

    首先將遊標倒置,加一後,再倒置,也就是咱們所說的「高位++」的操做。這裏的這幾步操做是來經過前一個下標,計算出哈希表下一個bucket的下標。舉一個例子:最開始00這個bucket不用動,以前通過正常的低位++以後,00的後面應該爲01。然而如今是高位++,原來01的位置的下標就會變成10.......以此類推。最終,哈希表的下標就會由原來順序的00、0一、十、11變成了00、十、0一、11,如圖所示:

    這樣就可以保證咱們屢次執行SCAN命令就不會重複遍歷了嗎?接下來就是見證奇蹟的時刻:

    • 首先仍是沒進行rehash以前,對其進行SCAN。一樣的,咱們假設這個SCAN命令只遍歷到遊標爲1的位置就中止了:

    • 咱們獲得第一次遍歷的結果:12
    • 開始進行rehash

      • rehash結束,咱們再次使用SCAN命令對其進行遍歷。注意這裏,上次返回的遊標爲2,咱們從2的位置繼續遍歷,也是要在新的哈希表中進行遍歷了:

    • 咱們能夠看到,通過一個小的下標的修改,就可以解決rehash所帶來的SCAN重複遍歷的問題。對dict進行遍歷的源碼以下:
    unsigned long dictScan(dict *d,
                          unsigned long v,
                          dictScanFunction *fn,
                          dictScanBucketFunction* bucketfn,
                          void *privdata)
    {
       dictht *t0, *t1;
       const dictEntry *de, *next;
       unsigned long m0, m1;
    
       if (dictSize(d) == 0) return 0;
    
       // 若是SCAN的時候沒有進行rehash
       if (!dictIsRehashing(d)) {
           t0 = &(d->ht[0]);
           m0 = t0->sizemask;
    
           /* Emit entries at cursor */
           if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);
           de = t0->table[v & m0];
           while (de) { //遍歷同一個bucket上後面掛接的鏈表
               next = de->next;
               fn(privdata, de);
               de = next;
           }
    
           /* Set unmasked bits so incrementing the reversed cursor
            * operates on the masked bits */
           v |= ~m0;
    
           /* Increment the reverse cursor */
           v = rev(v); //反轉v
           v++; //反轉以後即爲高位++
           v = rev(v); //再反轉回來,獲得下一個遊標值
    
        // 若是SCAN的時候正在進行rehash
       } else { 
           t0 = &d->ht[0];
           t1 = &d->ht[1];
    
           /* Make sure t0 is the smaller and t1 is the bigger table */
           if (t0->size > t1->size) {
               t0 = &d->ht[1];
               t1 = &d->ht[0];
           }
    
           m0 = t0->sizemask;
           m1 = t1->sizemask;
    
           /* Emit entries at cursor */
           if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);
           de = t0->table[v & m0];
           while (de) { //遍歷同一個bucket上後面掛接的鏈表
               next = de->next;
               fn(privdata, de);
               de = next;
           }
    
           /* Iterate over indices in larger table that are the expansion
            * of the index pointed to by the cursor in the smaller table */
           do {
               /* Emit entries at cursor */
               if (bucketfn) bucketfn(privdata, &t1->table[v & m1]);
               de = t1->table[v & m1];
               while (de) { //遍歷同一個bucket上後面掛接的鏈表
                   next = de->next;
                   fn(privdata, de);
                   de = next;
               }
    
               /* Increment the reverse cursor not covered by the smaller mask.*/
               v |= ~m1;
               v = rev(v); //反轉v
               v++; //反轉以後即爲高位++
               v = rev(v); //再反轉回來,獲得下一個遊標值
    
               /* Continue while bits covered by mask difference is non-zero */
           } while (v & (m0 ^ m1));
       }
    
       return v;
    }

    有關rehash過程對SCAN的影響,限於篇幅僅僅展現這種狀況。更多的情形請參考:Redis scan命令原理

    相關文章
    相關標籤/搜索