Redis原理再學習04:資料結構-雜湊表hash表(dict字典)

九卷發表於2022-02-28

雜湊函式簡介

雜湊函式(hash function),又叫雜湊函式,雜湊演算法。雜湊函式把資料“壓縮”成摘要,有的也叫”指紋“,它使資料量變小且資料格式大小也固定。

雜湊函式將資料打亂混合,重新建立一個雜湊值。

我們經常用到的對使用者登入密碼加密,比如 md5 演算法,其實就是一個雜湊函式。

value = hash_function(input_data),value 這個計算出來的值是大小固定的。

md5("hashmd5") = 46BD4AA9F79D359530D3D873BAC6F3DC,32 位的 md5 值。

當然也有 16 位的 md5 值。

經過雜湊函式計算的雜湊值,會不會出現雜湊值相同情況?

當然會,這個就是雜湊值衝突

所以一個好的雜湊函式就很重要,要儘量避免出現雜湊值衝突。

常用的雜湊演算法:md5,sha-1,sha-256,sha-512 等等。

雜湊表簡介

雜湊表可以有很多英文名稱,比如 hashtable,hashmap,symbol table,map 等等,英文名稱雖然不同,但是資料結構基本差不多。

在 map 中,就是一種對映關係。一般儲存 key:value 的鍵值對對映關係。

在雜湊表中,key 經過雜湊函式計算後儲存到雜湊表中,然後與 value 值關聯對應。

雜湊表的結構組成:陣列array + 連結串列list。是一個組合結構。

比如:key:value 值,陣列用來儲存 key 經過雜湊函式計算後的值與陣列長度取餘後的值,連結串列儲存 key:value 值。

如下圖:

image-20220130234015075

上圖為什麼是 2 個 key:val 在一起?

其實這就是 hash 衝突了,用鏈地址表來解決雜湊衝突的問題。

Redis中的雜湊表和字典dict

1. 雜湊表各結構定義

雜湊表dictht

redis3.0 中的雜湊表叫 dictht,dictht 的定義:

// https://github.com/redis/redis/blob/3.0/src/dict.h#L69

/* This is our hash table structure. Every dictionary has two of this as we
 * implement incremental rehashing, for the old to the new table. */
typedef struct dictht { // 雜湊表
    dictEntry **table; // 雜湊表的陣列,陣列中每個元素都是指標,指向 dictEntry 結構
    unsigned long size; // 雜湊表的大小,table 陣列的大小
    unsigned long sizemask; // 雜湊表掩碼,用於計算索引值,等於 size-1
    unsigned long used; // 雜湊表已有的節點(鍵值對)數量
} dictht;

image-20220131000117154

雜湊表節點dictEntry

雜湊表節點,有的地方取名為雜湊桶 bucket,節點 Node 等等,不過表達意思是一樣的。

上面 redis3.0 雜湊表 dictht 裡的節點 dictEntry 是怎麼定義? 程式碼如下:

// https://github.com/redis/redis/blob/3.0/src/dict.h#L47
typedef struct dictEntry {
    void *key;  // 鍵 key
    union { // 值 val
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next; // 指向下一個雜湊表節點,連結串列法解決hash衝突
} dictEntry;

key 屬性儲存鍵值對中的鍵,v 屬性儲存鍵值對中的值,其中這個 v 值可能是一個指標,或者是一個 uint64_t 整數,或者是 int64_t 整數,或是 double 型別浮點數。

dictEnty 表節點和 dictht 雜湊表結構關係如下圖:

image-20220131002838303

next:指向下一個雜湊節點,用連結串列法來解決雜湊衝突。

hash衝突:

上面的 dictEntry 結構裡的屬性 next 就是解決這個雜湊鍵衝突問題的。

有衝突的值,就用連結串列來記錄下一個值。

雜湊演算法

Redis 中計算雜湊值的雜湊函式有好幾個。

  1. dictIntHashFunction 計算整型型別雜湊值的雜湊函式

    unsigned int dictIntHashFunction(unsigned int key)
    
  2. dictGenHashFunction MurmurHash2 雜湊演算法, by Austin Appleby,用於計算字串的雜湊值的雜湊函式

    unsigned int dictGenHashFunction(const void *key, int len)
    
  3. dictGenCaseHashFunction djb 雜湊演算法,大小寫敏感的雜湊函式

    /* And a case insensitive hash function (based on djb hash) */
    unsigned int dictGenCaseHashFunction(const unsigned char *buf, int len)
    

2. 字典dict

字典dict

上面我們已經瞭解,在 Redis 中用 dictht 來表示雜湊表,但是,在使用雜湊表時,Redis 又定義了一個字典 dict 的資料結構。

為什麼要再定義一個 dict 結構?

  • 為了擴充套件雜湊表(rehash)的時候,能夠方面的操作雜湊表。為此裡面定義了 2 個雜湊表 ht[2]。

字典 dict.h/dict 結構定義:

typedef struct dict {
    dictType *type; // 指標,指向dictType 結構,dictType 中包含很多自定義函式,見下面
    void *privdata; // 私有資料,儲存dictType結構中的函式引數
    dictht ht[2]; // hash表,ht[2] 表示有2張表
    long rehashidx; /* rehashing not in progress if rehashidx == -1 *///rehash 標識,rehashidx=-1,沒進行rehash
    int iterators; /* number of iterators currently running */// 正在執行的迭代器數量
} dict;

*type:儲存了很多函式,這些函式是操作特定型別鍵值對的函式,Redis 會為用途不同的字典設定不同型別特定函式。

ht[2]:包含 2 個 dictht雜湊表,為什麼有2張表?rehash 時會用到 ht[1]。一般情況下只使用 ht[0]。

rehashidx:這個屬性與 rehash 有關,記錄 rehash 目前的進度,如果目前沒有進行 rehash,那麼 rehashidx=-1。

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;

字典 dict 圖示:

3. rehash

a. 什麼是 rehash ?

  • 擴大或縮小雜湊表容量。

b. 為什麼有 rehash ?

  • 當雜湊表的資料量持續增長,而雜湊表容量大小固定時,就可能會有 2 個或以上數量的鍵被分配到雜湊表陣列的同一個索引上,於是就發生了衝突(collision)。
  • 當然衝突可以用連結串列法(separate chaining)解決,但是為了雜湊表的效能,要儘量避免衝突,就要對雜湊表進行擴容或縮容。

雜湊表中有一個負載因子(load factor)的概念:

負載因子 = 雜湊表已儲存的鍵值對數量(使用的數量) / 雜湊表的長度

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

這個負載因子的概念是用來衡量雜湊表容量大小情況的。雜湊表中的鍵值對數量少,負載因子也小。

當負載因子超過某個闕值時,為了維持雜湊的容量在一定合理範圍,就會對雜湊表容量進行 resize 操作:

  1. 擴大雜湊表容量
  2. 縮小雜湊表容量

c. 什麼時候進行擴容和縮容操作?

  • 擴容條件

    滿足下面任一條件都會觸發雜湊表擴容

    1. 伺服器目前沒有執行 bgsave 命令,或 bgrewriteaof 命令,並且雜湊表的負載因子 >=1

    2. 伺服器目前在執行 bgsave 命令,或 bgrewriteaof 命令並且雜湊表的負載因子 >5

  • 縮容條件

    1. 雜湊表的負載因子 < 0.1

d. 怎麼操作擴容和縮容?

也就是說擴容和縮容的操作步驟是什麼?

  1. 為字典 ht[1] 分配記憶體空間,空間大小取決於要執行的操作,以及當前 ht[0] 的鍵值對數量

    • 如果是擴容操作,那麼 ht[1] 的空間大小等於第一個 ht[0].used * 2 的 2^n(2的n次冪)

    • 如果是縮容操作,那麼 ht[1] 的空間大小等於第一個 ht[0].used 的 2^n(2的n次冪)

  2. 將 ht[0] 上所有鍵值重新計算雜湊值和索引值後存放到 ht[1] 對應位置上

  3. 當 ht[0] 上所有的鍵值移動到 ht[1] 後,釋放 ht[0],將 ht[1] 變成 ht[0],並在 ht[1] 上新建一個空雜湊表

擴容程式碼簡析:

_dictExpandIfNeeded

// https://github.com/redis/redis/blob/3.0/src/dict.c#L923

/* Expand the hash table if needed */
static int _dictExpandIfNeeded(dict *d)
{
    /* Incremental rehashing already in progress. Return. */
    if (dictIsRehashing(d)) return DICT_OK; // 如果正在進行rehash,則返回

    /* If the hash table is empty expand it to the initial size. */
    // 如果 ht[0] 為空,則建立並初始化ht[0],然後返回
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);

    /* If we reached the 1:1 ratio, and we are allowed to resize the hash
     * table (global setting) or we should avoid it but the ratio between
     * elements/buckets is over the "safe" threshold, we resize doubling
     * the number of buckets. */
    /*當 (ht[0].used/ht[0].size)>=1 並且,
       滿足dict_can_resize=1或ht[0].used/ht[0].size>5時,對字典進行擴容*/ 
    if (d->ht[0].used >= d->ht[0].size &&
        (dict_can_resize ||
         d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
    {
        return dictExpand(d, d->ht[0].used*2);
    }
    return DICT_OK;
}

// https://github.com/redis/redis/blob/3.0/src/dict.c#L58
static int dict_can_resize = 1;
static unsigned int dict_force_resize_ratio = 5;

dictExpand:

// https://github.com/redis/redis/blob/3.0/src/dict.c#L204
/* Expand or create the hash table */
int dictExpand(dict *d, unsigned long size)
{
    dictht n; /* the new hash table 新建一個雜湊表*/
    unsigned long realsize = _dictNextPower(size); // 計算擴容或縮容新版雜湊表大小

    /* the size is invalid if it is smaller than the number of
     * elements already inside the hash table */
    // 如果雜湊表正在rehash或新建雜湊表大小小於現已使用的,則返回錯誤
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;

    /* Rehashing to the same table size is not useful. */
    if (realsize == d->ht[0].size) return DICT_ERR;

    /* Allocate the new hash table and initialize all pointers to NULL */
    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;

    /* Is this the first initialization? If so it's not really a rehashing
     * we just set the first hash table so that it can accept keys. */
    if (d->ht[0].table == NULL) {
        d->ht[0] = n;
        return DICT_OK;
    }

    /* Prepare a second hash table for incremental rehashing */
    d->ht[1] = n;
    d->rehashidx = 0;
    return DICT_OK;
}

縮容操作:

dictResize

// https://github.com/redis/redis/blob/3.0/src/dict.c#L192
int dictResize(dict *d)
{
    int minimal;

    // dict_can_resize 在 https://github.com/redis/redis/blob/3.0/src/dict.c#L58 這裡是設定為 1,如果為0就返回,不進行後面操心
    // 或者 dictIsRehashig() 真正進行rehash操心,也返回不rehash操作
    if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
    minimal = d->ht[0].used; // 獲得已經使用ht的數量
    if (minimal < DICT_HT_INITIAL_SIZE) // 這個最小值不能小於 DICT_HT_INITIAL_SIZE = 4
        minimal = DICT_HT_INITIAL_SIZE;
    return dictExpand(d, minimal); // 用dictExpand函式調整字典大小
}

// https://github.com/redis/redis/blob/3.0/src/dict.h#L100
/* This is the initial size of every hash table */
#define DICT_HT_INITIAL_SIZE     4

參考

相關文章