[Redis原始碼閱讀]dict字典的實現

hoohack發表於2019-02-22

dict的用途

dict是一種用於儲存鍵值對的抽象資料結構,在redis中使用非常廣泛,比如資料庫、雜湊結構的底層。

當執行下面這個命令:

> set msg "hello"
複製程式碼

以及使用雜湊結構,如:

> hset people name "hoohack"
複製程式碼

都會使用到dict作為底層資料結構的實現。

結構的定義

先看看字典以及相關資料結構體的定義:

字典

/* 字典結構 每個字典有兩個雜湊表,實現漸進式雜湊時需要用在將舊錶rehash到新表 */
typedef struct dict {
    dictType *type; /* 型別特定函式 */
    void *privdata; /* 儲存型別特定函式需要使用的引數 */
    dictht ht[2]; /* 儲存的兩個雜湊表,ht[0]是真正使用的,ht[1]會在rehash時使用 */
    long rehashidx; /* rehashing not in progress if rehashidx == -1 rehash進度,如果不等於-1,說明還在進行rehash */
    unsigned long iterators; /* number of iterators currently running 正在執行中的遍歷器數量 */
} dict;
複製程式碼

雜湊表

/* 雜湊表結構 */
typedef struct dictht {
    dictEntry **table; /* 雜湊表節點陣列 */
    unsigned long size; /* 雜湊表大小 */
    unsigned long sizemask; /* 雜湊表大小掩碼,用於計算雜湊表的索引值,大小總是dictht.size - 1 */
    unsigned long used; /* 雜湊表已經使用的節點數量 */
} dictht;
複製程式碼

雜湊表節點

/* 雜湊表節點 */
typedef struct dictEntry {
    void *key; /* 鍵名 */
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v; /* 值 */
    struct dictEntry *next; /* 指向下一個節點, 將多個雜湊值相同的鍵值對連線起來*/
} dictEntry;
複製程式碼

dictType

/* 儲存一連串操作特定型別鍵值對的函式 */
typedef struct dictType {
    uint64_t (*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 struct

根據資料結構定義,把關聯圖畫出來後,看程式碼的時候就更加清晰。

從圖中也可以看出來,字典的雜湊表裡,使用了連結串列解決鍵衝突的情況,稱為鏈式地址法。

rehash(重新雜湊)

當操作越來越多,比如不斷的向雜湊表新增元素,此時雜湊表需要分配了更多的空間,如果接下來的操作是不斷地刪除雜湊表的元素,那麼雜湊表的大小就會發生變化,更重要的是,現在的雜湊表不再需要那麼大的空間了,在redis的實現中,為了保證雜湊表的負載因子維持在一個合理範圍內,當雜湊表儲存的鍵值對太多或者太少時,redis對雜湊表大小進行相應的擴充套件和收縮,稱為rehash(重新雜湊)。

執行rehash的流程圖

redis dict rehash

負載因子解釋

負載因子 = 雜湊表已儲存節點數量 / 雜湊表大小

負載因子越大,意味著雜湊表越滿,越容易導致衝突,效能也就越低。因此,一般來說,當負載因子大於某個常數(可能是 1,或者 0.75 等)時,雜湊表將自動擴容。

漸進式rehash

在上面的rehash流程圖裡面,rehash的操作不是一次性就完成了的,而是分多次,漸進式地完成。

原因是,如果需要rehash的鍵值對較多,會對伺服器造成效能影響,漸進式地rehash避免了對伺服器的影響。

漸進式的rehash使用了dict結構體中的rehashidx屬性輔助完成。當漸進式雜湊開始時,rehashidx會被設定為0,表示從dictEntry[0]開始進行rehash,每完成一次,就將rehashidx加1。直到ht[0]中的所有節點都被rehash到ht[1],rehashidx被設定為-1,此時表示rehash結束。

結合程式碼再深入理解

/* 實現漸進式的重新雜湊,如果還有需要重新雜湊的key,返回1,否則返回0
 *
 * 需要注意的是,rehash持續將bucket從老的雜湊表移到新的雜湊表,但是,因為有的雜湊表是空的,
 * 因此函式不能保證即使一個bucket也會被rehash,因為函式最多一共會訪問N*10個空bucket,不然的話,函式將會耗費過多效能,而且函式會被阻塞一段時間
 */
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;

        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];
        
        /* 實現將bucket從老的雜湊表移到新的雜湊表 */
        while(de) {
            unsigned int h;

            nextde = de->next;
            /* Get the index in the new hash table */
            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++;
    }

    /* 如果已經完成了,釋放舊的雜湊表,返回0 */
    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;
    }

    /* 繼續下一次rehash */
    return 1;
}
複製程式碼

在漸進式rehash期間,所有對字典的操作,包括:新增、查詢、更新等等,程式除了執行指定的操作之外,還會順帶將ht[0]雜湊表索引的所有鍵值對rehash到ht[1]。比如新增:

dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
    int index;
    dictEntry *entry;
    dictht *ht;

    /* 如果正在rehash,順帶執行rehash操作 */
    if (dictIsRehashing(d)) _dictRehashStep(d);

    /* 獲取新元素的下標,如果已經存在,返回-1 */
    if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
        return NULL;

    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0]; // 如果正在進行rehash操作,返回ht[1],否則返回ht[0]
    entry = zmalloc(sizeof(*entry));
    entry->next = ht->table[index];
    ht->table[index] = entry;
    ht->used++;

    /* Set the hash entry fields. */
    dictSetKey(d, entry, key);
    return entry;
}
複製程式碼

總結

使用一個標記值標記某項操作正在執行是程式設計中常用的手段,比如本文提到的rehashidx,多利用此手段可以解決很多問題。

我在github有對Redis原始碼更詳細的註解。感興趣的可以圍觀一下,給個star。Redis4.0原始碼註解。可以通過commit記錄檢視已新增的註解。

原創文章,文筆有限,才疏學淺,文中若有不正之處,萬望告知。

更多精彩內容,請關注個人公眾號。

[Redis原始碼閱讀]dict字典的實現

相關文章