Redis底層詳解(一) 哈希表和字典

一、哈希表概述

       首先簡單介紹幾個概念:哈希表(散列表)、映射、衝突、鏈地址、哈希函數。

       哈希表(Hash table)的初衷是爲了將數據映射到數組中的某個位置,這樣就能夠通過數組下標訪問該數據,提高數據的查找速度,這樣的查找的平均期望時間複雜度是O(1)的。

       例如四個整數 6、7、9、12 需要映射到數組中,我們可以開一個長度爲13(C語言下標從0開始)的數組,然後將對應值放到對應的下標,但是這樣做,就會浪費沒有被映射到的位置的空間。

 

       採用哈希表的話,我們可以只申請一個長度爲4的數組,如下圖所示:

 

       將每個數的值對數組長度4取模,然後放到對應的數組槽位中,這樣就把離散的數據映射到了連續的空間,所以哈希表又稱爲散列表。這樣做,最大限度上提高空間了利用率,並且查找效率還很高。

       那麼問題來了,如果這四個數據是6、7、8、11呢?繼續看圖:

       7 和 11 對4取模的值都是 3,所以佔據了同一個槽位,這種情況我們稱爲衝突 (collision)。一般遇到衝突後,有很多方法解決衝突,包括但不限於 開放地址法、再散列法、鏈地址法 等等。 Redis採用的是鏈地址法,所以這裏只介紹鏈地址法,其它的方法如果想了解請自行百度。

      鏈地址法就是將有衝突的數據用一個鏈表串聯起來,如圖所示:

       這樣一來,就算有衝突,也可以將有衝突的數據存儲在一起了。存儲結構需要稍加變化,哈希表的每個元素將變成一個指針,指向數據鏈表的鏈表頭,每次有新數據來時從鏈表頭插入,可以達到插入的時間複雜度保持O(1)。

        再將問題進行變形,如果4個數據是 "are",  "you",  "OK",  "?" 這樣的字符串,如何進行映射呢?沒錯,我們需要通過一個哈希函數將字符串變成整數,哈希函數的概念會在接下來詳細講述,這裏只需要知道它可以把一個值變成另一個值即可,比如哈希函數f(x),調用 f("are") 就可以得到一個整數,f("you") 也可以得到一個整數。

        一個簡易的大小寫不敏感的字符串哈希函數如下:

unsigned int hashFunction(const unsigned char *buf, int len) {
    unsigned int hash = (unsigned int)5381;                       // hash初始種子,實驗值
    while (len--)
        hash = ((hash << 5) + hash) + (tolower(*buf++));          // hash * 33 + c
    return hash;
}

        我們看到,哈希函數的作用就是把非數字的對象通過一系列的算法轉化成數字(下標),得到的數字可能是哈希表數組無法承載的,所以還需要通過取模才能映射到連續的數組空間中。對於這個取模,我們知道取模的效率相比位運算來說是很低的,那麼有沒有什麼辦法可以把取模用位運算來代替呢?

        答案是有!我們只要把哈希表的長度 L 設置爲2的冪(L = 2^n),那麼 L-1 的二進制表示就是n個1,任何值 x 對 L 取模等同於和 (L-1) 進行位與(C語言中的&)運算。

        介紹完哈希表的基礎概念,我們來看看 Redis 中是如何實現字典的。

二、Redis數據結構定義

     1、哈希表

       哈希表的結構定義在 dict.h/dictht :

typedef struct dictht {
    dictEntry **table;             // 哈希表數組
    unsigned long size;            // 哈希表數組的大小
    unsigned long sizemask;        // 用於映射位置的掩碼,值永遠等於(size-1)
    unsigned long used;            // 哈希表已有節點的數量
} dictht;

       table 是一個數組,數組的每個元素都是一個指向 dict.h/dictEntry 結構的指針;

       size 記錄哈希表的大小,即 table 數組的大小,且一定是2的冪;

       used 記錄哈希表中已有結點的數量;

       sizemask 用於對哈希過的鍵進行映射,索引到 table 的下標中,且值永遠等於 size-1。具體映射方法很簡單,就是對 哈希值 和 sizemask 進行位與操作,由於 size 一定是2的冪,所以 sizemask=size-1,自然它的二進制表示的每一個位(bit)都是1,等同於上文提到的取模;

       如圖所示,爲一個長度爲8的空哈希表。

     2、哈希表節點

       哈希表節點用 dict.h/dictEntry 結構表示,每個 dictEntry 結構存儲着一個鍵值對,且存有一個 next 指針來保持鏈表結構:

typedef struct dictEntry {
    void *key;                  // 鍵
    union {                     // 值
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;     // 指向下一個哈希表節點,形成單向鏈表
} dictEntry;

       key 是鍵值對中的鍵;

       是鍵值對中的值,它是一個聯合類型,方便存儲各種結構;

       next 是鏈表指針,指向下一個哈希表節點,他將多個哈希值相同的鍵值對串聯在一起,用於解決鍵衝突;如圖所示,兩個dictEntry 的 key 分別是 k0 和 k1,通過某種哈希算法計算出來的哈希值和 sizemask 進行位與運算後都等於 3,所以都被放在了 table 數組的 3號槽中,並且用 next 指針串聯起來。

     3、字典

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

typedef struct dict {
    dictType *type;                        // 和類型相關的處理函數
    void *privdata;                        // 上述類型函數對應的可選參數
    dictht ht[2];                          // 兩張哈希表,ht[0]爲原生哈希表,ht[1]爲 rehash 哈希表
    long rehashidx;                        // 當等於-1時表示沒有在 rehash,否則表示 rehash 的下標
    int iterators;                         // 迭代器數量(暫且不談)
} dict;

     type 是一個指向 dict.h/dictType 結構的指針,保存了一系列用於操作特定類型鍵值對的函數;

     privdata 保存了需要傳給上述特定函數的可選參數;

     ht 是兩個哈希表,一般情況下,只使用ht[0],只有當哈希表的鍵值對數量超過負載(元素過多)時,纔會將鍵值對遷移到ht[1],這一步遷移被稱爲 rehash (重哈希),rehash 會在下文進行詳細介紹;

     rehashidx 由於哈希表鍵值對有可能很多很多,所以 rehash 不是瞬間完成的,需要按部就班,那麼 rehashidx 就記錄了當前 rehash 的進度,當 rehash 完畢後,將 rehashidx 置爲-1;

     4、類型處理函數

      類型處理函數全部定義在 dict.h/dictType 中:

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;

       以上的函數和特定類型相關,主要是爲了實現多態,看到這個如果懵逼也沒關係,下面會一一對其進行介紹。

三、哈希函數

      類型處理函數中的第一個函數 hashFunction 就是計算某個鍵的哈希值的函數,對於不同類型的 key,哈希值的計算是不同的,所以在字典進行創建的時候,需要指定哈希函數。

      哈希函數可以簡單的理解爲就是小學課本上那個函數,即y = f(x),這裏的 f(x)就是哈希函數,x是鍵,y就是哈希值。好的哈希函數應該具備以下兩個特質:
       1、可逆性;
       2、雪崩效應:輸入值(x)的1位(bit)的變化,能夠造成輸出值(y)1/2的位(bit)的變化;
       可逆性很容易理解,來看兩個圖。圖(a)中已知哈希值 y 時,鍵 x 可能有兩種情況,所以顯然是不可逆的;而圖(b)中已知哈希值 y 時,鍵 x 一定是唯一確定的,所以它是可逆的。從圖中看出,函數可逆的好處是:減少衝突。由於 x 和 y 一一對應,所以在沒有取模之前,至少是沒有衝突的,這樣就從本原上減少了衝突。

       雪崩效應是爲了讓哈希值更加符合隨機分佈的原則,哈希表中的鍵分佈的越隨機,利用率越高,效率也越高。

       Redis源碼中提供了一些哈希函數的實現:

      1、整數哈希

unsigned int dictIntHashFunction(unsigned int key)
{
    key += ~(key << 15);
    key ^=  (key >> 10);
    key +=  (key << 3);
    key ^=  (key >> 6);
    key += ~(key << 11);
    key ^=  (key >> 16);
    return key;
}

       2、字符串哈希

unsigned int dictGenCaseHashFunction(const unsigned char *buf, int len) {
    unsigned int hash = (unsigned int)dict_hash_function_seed;
    while (len--)
        hash = ((hash << 5) + hash) + (tolower(*buf++)); /* hash * 33 + c */
    return hash;
}

       這些哈希函數是前人經過一系列的實驗,科學計算總結得出來的,我們只需要知道有這麼些函數就行了。當字典被用作數據庫的底層實現, 或者哈希鍵的底層實現時, Redis 使用 MurmurHash2 算法來計算鍵的哈希值。MurmurHash 算法最初由 Austin Appleby 於 2008 年發明, 這種算法的優點在於, 即使輸入的鍵是有規律的, 算法仍能給出一個很好的隨機分佈性, 並且算法的計算速度也非常快。

 

四、哈希算法

     1、索引 

       當要將一個新的鍵值對添加到字典裏面或者通過鍵查找值的時候都需要執行哈希算法,主要是獲得一個需要插入或者查找的dictEntry 所在下標的索引,具體算法如下:

       1、通過宏 dictHashKey 計算得到該鍵對應的哈希值

#define dictHashKey(d, key) (d)->type->hashFunction(key)

       2、將哈希值和哈希表的 sizemask 屬性做位與,得到索引值 index,其中 ht[x] 可以是 ht[0] 或者 ht[1]

index = dictHashKey(d, key) & d->ht[x].sizemask;

      2、衝突解決

        哈希的衝突一定發生在鍵值對插入時,插入的  API 是 dict.c/dictAddRaw:

dictEntry *dictAddRaw(dict *d, void *key)
{
    int index;
    dictEntry *entry;
    dictht *ht;
    if (dictIsRehashing(d)) _dictRehashStep(d);               // 1、執行rehash
    if ((index = _dictKeyIndex(d, key)) == -1)                // 2、索引定位
        return NULL;
    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];          // 3、根據是否 rehash ,選擇哈希表
    entry = zmalloc(sizeof(*entry));                          // 4、分配內存空間,執行插入
    entry->next = ht->table[index];
    ht->table[index] = entry;
    ht->used++;
    dictSetKey(d, entry, key);                                // 5、設置鍵
    return entry;
}

       1、判斷當前的字典是否在進行 rehash,如果是,則執行一步 rehash,否則忽略。判斷 rehash 的依據就是 rehashidx 是否爲1;
       2、通過 _dictKeyIndex 找到一個索引,如果返回-1表明字典中已經存在相同的 key,具體參見接下來要講的 索引定位;
       3、根據是否在 rehash 選擇對應的哈希表;
       4、分配哈希表節點 dictEntry 的內存空間,執行插入,插入操作始終在鏈表頭插入,這樣可以保證每次的插入操作的時間複雜度一定是 O(1) 的,插入完畢,used屬性自增;
       5、dictSetKey 是個宏,調用字典處理函數中的 keyDup 函數進行鍵的複製;

 

      3、索引定位

        插入時還需要進行索引定位,以確定節點要插入到哈希表的哪個位置,實現在靜態函數 dict.c/_dictKeyIndex 中:

static int _dictKeyIndex(dict *d, const void *key)
{
    unsigned int h, idx, table;
    dictEntry *he;

    if (_dictExpandIfNeeded(d) == DICT_ERR)                            // 1、rehash 判斷
        return -1;
    h = dictHashKey(d, key);                                           // 2、哈希函數計算哈希值
    for (table = 0; table <= 1; table++) {
        idx = h & d->ht[table].sizemask;                               // 3、哈希算法計算索引值
        he = d->ht[table].table[idx];
        while(he) {                          
            if (key==he->key || dictCompareKeys(d, key, he->key))      // 4、查找鍵是否已經存在
                return -1;
            he = he->next;
        }
        if (!dictIsRehashing(d)) break;                                // 5、rehash 判斷 
    }
    return idx;
}

       1、判斷當前哈希表是否需要進行擴展,具體參見接下來要講的 rehash;
       2、利用給定的哈希函數計算鍵的哈希值;
       3、通過位與計算索引,即插入到哈希表的哪個槽位中;

       4、查找當前槽位中的鏈表裏是否已經存在該鍵,如果存在直接返回 -1;這裏的 dictCompareKeys 也是一個宏,用到了keyCompare 這個比較鍵的函數;
       5、這個判斷比較關鍵,如果當前沒有在做 rehash,那麼 ht[1] 必然是一個空表,所以不能遍歷 ht[1],需要及時跳出循環;

    五、rehash

      千呼萬喚始出來,提到了這麼多次的 rehash 終於要開講了。其實沒有想象中的那麼複雜,隨着字典操作的不斷執行,哈希表保存的鍵值對會不斷增多(或者減少),爲了讓哈希表的負載因子維持在一個合理的範圍之內,當哈希表保存的鍵值對數量太多或者太少時,需要對哈希表大小進行擴展或者收縮。

     1、負載因子

       這裏提到了一個負載因子,其實就是當前已使用結點數量除上哈希表的大小,即:

load_factor = ht[0].used / ht[0].size

      2、哈希表擴展

       1、當哈希表的負載因子大於5時,爲 ht[1] 分配空間,大小爲第一個大於等於 ht[0].used * 2 的 2 的冪;

       2、將保存在 ht[0] 上的鍵值對 rehash 到 ht[1] 上,rehash 就是重新計算哈希值和索引,並且重新插入到 ht[1] 中,插入一個刪除一個;

       3、當 ht[0] 包含的所有鍵值對全部 rehash 到 ht[1] 上後,釋放 ht[0] 的控件, 將 ht[1] 設置爲 ht[0],並且在 ht[1] 上新創件一個空的哈希表,爲下一次 rehash 做準備;

       Redis 中 實現哈希表擴展調用的是 dict.c/_dictExpandIfNeeded 函數:

static int _dictExpandIfNeeded(dict *d)
{
    if (dictIsRehashing(d)) return DICT_OK;
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);          // 大小爲0需要設置初始哈希表大小爲4
    if (d->ht[0].used >= d->ht[0].size &&
        (dict_can_resize ||
         d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))                 // 負載因子超過5,執行 dictExpand
    {
        return dictExpand(d, d->ht[0].used*2);
    }
    return DICT_OK;
}

      3、哈希表收縮

       哈希表的收縮,同樣是爲 ht[1] 分配空間, 大小等於 max( ht[0].used, DICT_HT_INITIAL_SIZE ),然後和擴展做同樣的處理即可。

     六、漸進式rehash

       擴展或者收縮哈希表的時候,需要將 ht[0] 裏面所有的鍵值對 rehash 到 ht[1] 裏,當鍵值對數量非常多的時候,這個操作如果在一幀內完成,大量的計算很可能導致服務器宕機,所以不能一次性完成,需要漸進式的完成。
       漸進式 rehash 的詳細步驟如下:
       1、爲 ht[1] 分配指定空間,讓字典同時持有 ht[0] 和 ht[1] 兩個哈希表;
       2、將 rehashidx 設置爲0,表示正式開始 rehash,前兩步是在 dict.c/dictExpand 中實現的:

int dictExpand(dict *d, unsigned long size)
{
    dictht n;
    unsigned long realsize = _dictNextPower(size);                      // 找到比size大的最小的2的冪
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;
    if (realsize == d->ht[0].size) return DICT_ERR;

    n.size = realsize;                                                 // 給ht[1]分配 realsize 的空間
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;
    if (d->ht[0].table == NULL) {                                      // 處於初始化階段
        d->ht[0] = n;
        return DICT_OK;
    }
    d->ht[1] = n;
    d->rehashidx = 0;                                                  // rehashidx 設置爲0,開始漸進式 rehash
    return DICT_OK;
}

 

       3、在進行 rehash 期間,每次對字典執行 增、刪、改、查操作時,程序除了執行指定的操作外,還會將 哈希表 ht[0].table中下標爲 rehashidx 位置上的所有的鍵值對 全部遷移到 ht[1].table 上,完成後 rehashidx 自增。這一步就是 rehash 的關鍵一步。爲了防止 ht[0] 是個稀疏表 (遍歷很久遇到的都是NULL),從而導致函數阻塞時間太長,這裏引入了一個 “最大空格訪問數”,也即代碼中的 enmty_visits,初始值爲 n*10。當遇到NULL的數量超過這個初始值直接返回。

       這一步實現在 dict.c/dictRehash 中:

int dictRehash(dict *d, int n) {
    int empty_visits = n*10;
    if (!dictIsRehashing(d)) return 0;

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

        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;                                      // 設置一個空訪問數量 爲 n*10
        }
        de = d->ht[0].table[d->rehashidx];                                          // dictEntry的遷移
        while(de) {
            unsigned int h;
            nextde = de->next;
            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
    }

    if (d->ht[0].used == 0) {                                                      // 遷移完畢,rehashdix 置爲 -1
        zfree(d->ht[0].table);
        d->ht[0] = d->ht[1];
        _dictReset(&d->ht[1]);
        d->rehashidx = -1;
        return 0;
    }
    return 1;
}

       4、最後,當 ht[0].used 變爲0時,代表所有的鍵值對都已經從 ht[0] 遷移到 ht[1] 了,釋放 ht[0].table, 並且將 ht[0] 設置爲 ht[1],rehashidx 標記爲 -1 代表 rehash 結束。

 

     七、字典API

        1、創建字典
        內部分配字典空間,並作爲返回值返回,並調用 _dictInit 進行字典的初始化,時間複雜度O(1)。

dict *dictCreate(dictType *type, void *privDataPtr)

        2、增加鍵值對
        調用 dictAddRaw 增加一個 dictEntry,然後調用 dictSetVal 設置值,時間複雜度O(1)。

int dictAdd(dict *d, void *key, void *val)

        3、查找鍵
        利用哈希算法找到給定鍵的 dictEntry,時間複雜度O(1)。

dictEntry *dictFind(dict *d, const void *key)

        4、查找值
        利用 dictFind 找到給定鍵的 dictEntry,然後獲得值,值的類型不確定,所以返回一個萬能指針,時間複雜度O(1)。

void *dictFetchValue(dict *d, const void *key)

        5、刪除鍵

        通過哈希算法找到對應的鍵,從對應鏈表移除,時間複雜度O(1)。

int dictDelete(dict *ht, const void *key)

 

 

 

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