Redis詳解(六)漸進式rehash機制

在Redis中,鍵值對(Key-Value Pair)存儲方式是由字典(Dict)保存的,而字典底層是通過哈希表來實現的。通過哈希表中的節點保存字典中的鍵值對。我們知道當HashMap中由於Hash衝突(負載因子)超過某個閾值時,出於鏈表性能的考慮,會進行Resize的操作。Redis也一樣。

在redis的具體實現中,使用了一種叫做漸進式哈希(rehashing)的機制來提高字典的縮放效率,避免 rehash 對服務器性能造成影響,漸進式 rehash 的好處在於它採取分而治之的方式, 將 rehash 鍵值對所需的計算工作均攤到對字典的每個添加、刪除、查找和更新操作上, 從而避免了集中式 rehash 而帶來的龐大計算量。

1. 字典結構

1.1 哈希表節點

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

從哈希表節點結構中,可以看出,在redis中解決hash衝突的方式爲採用鏈地址法。key和v分別用於保存鍵值對的鍵和值。

1.2 哈希表

/* 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;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;
  • table:哈希表數組,數組的每個項是dictEntry鏈表的頭結點指針
  • size:哈希表大小;在redis的實現中,size也是觸發擴容的閾值
  • sizemask:哈希表大小掩碼,用於計算索引值;總是等於 size-1 ;
  • used:哈希表中保存的節點的數量

##字典

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;
  • type 屬性是一個指向 dictType 結構的指針, 每個 dictType 結構保存了一簇用於操作特定類型鍵值對的函數, Redis 會爲用途不同的字典設置不同的類型特定函數。
  • 而 privdata 屬性則保存了需要傳給那些類型特定函數的可選參數。
  • dictht ht[2]:在字典內部,維護了兩張哈希表。 一般情況下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只會在對 ht[0] 哈希表進行 rehash 時使用。
  • rehashidx:和 rehash 有關的屬性,它記錄了 rehash 目前的進度, 如果目前沒有在進行 rehash , 那麼它的值爲 -1 。

type 屬性和 privdata 屬性是針對不同類型的鍵值對, 爲創建多態字典而設置的。

2. rehash檢查

隨着操作的不斷執行, 哈希表保存的鍵值對會逐漸地增多或者減少, 爲了讓哈希表的負載因子(load factor)維持在一個合理的範圍之內, 當哈希表保存的鍵值對數量太多或者太少時, 程序需要對哈希表的大小進行相應的擴展或者收縮。

2.1 擴容

redis中,每次插入鍵值對時,都會檢查是否需要擴容。如果滿足擴容條件,則進行擴容。

在向redis中添加鍵時都會依次調用dictAddRaw –> _dictKeyIndex –> _dictExpandIfNeeded函數,在_dictExpandIfNeeded函數中會判斷是否需要擴容。

/* Expand the hash table if needed */
static int _dictExpandIfNeeded(dict *d)
{
    /* Incremental rehashing already in progress. Return. */
    // 如果正在進行漸進式擴容,則返回OK
    if (dictIsRehashing(d)) return DICT_OK;
 
    /* If the hash table is empty expand it to the initial size. */
    // 如果哈希表ht[0]的大小爲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]中保存的key個數與哈希表大小的比例已經達到1:1,即保存的節點數已經大於哈希表大小
     * 且redis服務當前允許執行rehash,或者保存的節點數與哈希表大小的比例超過了安全閾值(默認值爲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;
}

從上面代碼和註釋可以看到,如果沒有進行初始化或者滿足擴容條件則對字典進行擴容。

先來看看字典初始化,在redis中字典中的hash表也是採用延遲初始化策略,在創建字典的時候並沒有爲哈希表分配內存,只有當第一次插入數據時,才真正分配內存。看看字典創建函數dictCreate。

/* Create a new hash table */
dict *dictCreate(dictType *type,
        void *privDataPtr)
{
    dict *d = zmalloc(sizeof(*d));
 
    _dictInit(d,type,privDataPtr);
    return d;
}
 
/* Initialize the hash table */
int _dictInit(dict *d, dictType *type,
        void *privDataPtr)
{
    _dictReset(&d->ht[0]);
    _dictReset(&d->ht[1]);
    d->type = type;
    d->privdata = privDataPtr;
    d->rehashidx = -1;
    d->iterators = 0;
    return DICT_OK;
}
 
static void _dictReset(dictht *ht)
{
    ht->table = NULL;
    ht->size = 0;
    ht->sizemask = 0;
    ht->used = 0;
}

從上面的創建過程可以看出,ht[0].table爲NULL,且ht[0].size爲0,直到第一次插入數據時,才調用dictExpand函數初始化。

我們再看看dict_can_resize字段,該字段在dictEnableResize和dictDisableResize函數中分別賦值1和0,在updateDictResizePolicy函數中會調用者兩個函數。

void updateDictResizePolicy(void) {
    if (server.rdb_child_pid == -1 && server.aof_child_pid == -1)
        dictEnableResize();
    else
        dictDisableResize();
}
 
void dictEnableResize(void) {
    dict_can_resize = 1;
}
 
void dictDisableResize(void) {
    dict_can_resize = 0;
}

而在redis中每次開始執行aof文件重寫或者開始生成新的RDB文件或者執行aof重寫/生成RDB的子進程結束時,都會調用updateDictResizePolicy函數,所以從該函數中,也可以看出來,如果當前沒有子進程在執行aof文件重寫或者生成RDB文件,則運行進行字典擴容;否則禁止字典擴容。

綜上,字典擴容需要同時滿足如下兩個條件:

  1. 哈希表中保存的key數量超過了哈希表的大小(可以看出size既是哈希表大小,同時也是擴容閾值)
  2. 當前沒有子進程在執行aof文件重寫或者生成RDB文件;或者保存的節點數與哈希表大小的比例超過了安全閾值(默認值爲5)

也可以如下理解:

當以下條件中的任意一個被滿足時, 程序會自動開始對哈希表執行擴展操作:

  • 服務器目前沒有在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 並且哈希表的負載因子大於等於 1 ;

  • 服務器目前正在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 並且哈希表的負載因子大於等於 5 ;

3. 縮容

當哈希表的負載因子小於 0.1 時, 程序自動開始對哈希表執行收縮操作。

在週期函數serverCron中,調用databasesCron函數,該函數中會調用tryResizeHashTables函數檢查用於保存鍵值對的redis數據庫字典是否需要縮容。如果需要則調用dictResize進行縮容,dictResize函數中也是調用dictExpand函數。

看看databasesCron中相關部分

if (server.rdb_child_pid == -1 && server.aof_child_pid == -1) {
    /* We use global counters so if we stop the computation at a given
     * DB we'll be able to start from the successive in the next
     * cron loop iteration. */
    static unsigned int resize_db = 0;
    static unsigned int rehash_db = 0;
    int dbs_per_call = CRON_DBS_PER_CALL;
    int j;
 
    /* Don't test more DBs than we have. */
    if (dbs_per_call > server.dbnum) dbs_per_call = server.dbnum;
 
    /* Resize */
    for (j = 0; j < dbs_per_call; j++) {
        tryResizeHashTables(resize_db % server.dbnum);
        resize_db++;
    }

可以看到要檢查是否需要縮容的前提也是當前沒有子進程執行aof重寫或者生成RDB文件。

/* If the percentage of used slots in the HT reaches HASHTABLE_MIN_FILL
 * we resize the hash table to save memory */
void tryResizeHashTables(int dbid) {
    if (htNeedsResize(server.db[dbid].dict))
        dictResize(server.db[dbid].dict);
    if (htNeedsResize(server.db[dbid].expires))
        dictResize(server.db[dbid].expires);
}
 
/* Hash table parameters */
#define HASHTABLE_MIN_FILL        10      /* Minimal hash table fill 10% */
int htNeedsResize(dict *dict) {
    long long size, used;
 
    size = dictSlots(dict);
    used = dictSize(dict);
    return (size > DICT_HT_INITIAL_SIZE &&
            (used*100/size < HASHTABLE_MIN_FILL));
}
 
/* Resize the table to the minimal size that contains all the elements,
 * but with the invariant of a USED/BUCKETS ratio near to <= 1 */
int dictResize(dict *d)
{
    int minimal;
 
    if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
    minimal = d->ht[0].used;
    if (minimal < DICT_HT_INITIAL_SIZE)
        minimal = DICT_HT_INITIAL_SIZE;
    return dictExpand(d, minimal);
}

從htNeedsResize函數中可以看到,當哈希表保存的key數量與哈希表的大小的比例小於10%時需要縮容。最小容量爲4。

從dictResize函數中可以看到縮容時,縮容後的哈希表大小爲當前哈希表中key的數量,當然經過dictExpand函數中_dictNextPower函數計算後,縮容後的大小爲第一個大於等於當前key數量的2的n次方。最小容量爲4。同樣從dictResize函數中可以看到,如果當前正在執行 BGSAVE 命令或者 BGREWRITEAOF 命令,則不進行縮容(有篇文章中提到縮容時沒有考慮bgsave,該說法是錯誤的)。

4. 漸進式rehash

4.1 漸進式rehash初始化

從上面可以看到,不管是擴容還是縮容,最終都是調用dictExpand函數來完成。看看dictExpand函數實現。

/* Expand or create the hash table */
int dictExpand(dict *d, unsigned long size)
{
    /* the size is invalid if it is smaller than the number of
     * elements already inside the hash table */
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;
 
    //計算新的哈希表大小,使得新的哈希表大小爲一個2的n次方;大於等於size的第一個2的n次方
    dictht n; /* the new hash table */
    unsigned long realsize = _dictNextPower(size);
 
    /* 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;
}

可以看到該函數計算一個新的哈希表大小,滿足2的n次方,爲什麼要滿足2的n次方?因爲哈希表掩碼sizemask爲size-1,當size滿足2的n次方時,計算每個key的索引值時只需要用key的hash值與掩碼sizemask進行位與操作,替代求餘操作,計算更快。

然後分配了一個新的哈希表,爲該哈希表分配了新的大小的內存。最後將該哈希表賦值給字典的ht[1],然後將rehashidx賦值爲0,打開漸進式rehash標誌。同時該值也標誌漸進式rehash當前已經進行到了哪個hash槽。

從該函數中,我們並沒有看到真正執行哈希表rehash的相關操作,只是分配了一個新的哈希表就結束了。我們知道哈希表rehash需要遍歷原有的整個哈希表,對原有的所有key進行重新hash,存放到新的哈希槽。

在redis的實現中,沒有集中的將原有的key重新rehash到新的槽中,而是分解到各個命令的執行中,以及週期函數中。

4.2 操作輔助rehash

在redis中每一個增刪改查命令中都會判斷數據庫字典中的哈希表是否正在進行漸進式rehash,如果是則幫助執行一次。

dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
    long index;
    dictEntry *entry;
    dictht *ht;
 
    if (dictIsRehashing(d)) _dictRehashStep(d);
    ......
}

類似的在dictFind、dictGenericDelete、dictGetRandomKey、dictGetSomeKeys等函數中都有以下語句判斷是否正在進行漸進式rehash。

if (dictIsRehashing(d)) _dictRehashStep(d);
//dictIsRehashing(d)定義如下,rehashidx不等於-1即表示正在進行漸進式rehash
#define dictIsRehashing(d) ((d)->rehashidx != -1)

_dictRehashStep函數的定義如下

/*
 * 此函數僅執行一步hash表的重散列,並且僅當沒有安全迭代器綁定到哈希表時。
 * 當我們在重新散列中有迭代器時,我們不能混淆打亂兩個散列表的數據,否則某些元素可能被遺漏或重複遍歷。
 *
 * 該函數被在字典中查找或更新等普通操作調用,以致字典中的數據能自動的從哈系表1遷移到哈系表2
 */
static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1);
}

4.3 定時輔助rehash

雖然redis實現了在讀寫操作時,輔助服務器進行漸進式rehash操作,但是如果服務器比較空閒,redis數據庫將很長時間內都一直使用兩個哈希表。所以在redis週期函數中,如果發現有字典正在進行漸進式rehash操作,則會花費1毫秒的時間,幫助一起進行漸進式rehash操作。

在databasesCron函數中,實現如下:

/* Rehash */
    if (server.activerehashing) {
        for (j = 0; j < dbs_per_call; j++) {
            int work_done = incrementallyRehash(rehash_db);
            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;
            }
        }
    }

前提是配置了activerehashing,允許服務器在週期函數中輔助進行漸進式rehash,該參數默認值是1。

/* Our hash table implementation performs rehashing incrementally while
 * we write/read from the hash table. Still if the server is idle, the hash
 * table will use two tables for a long time. So we try to use 1 millisecond
 * of CPU time at every call of this function to perform some rehahsing.
 *
 * The function returns 1 if some rehashing was performed, otherwise 0
 * is returned. */
int incrementallyRehash(int dbid) {
    /* Keys dictionary */
    if (dictIsRehashing(server.db[dbid].dict)) {
        dictRehashMilliseconds(server.db[dbid].dict,1);
        return 1; /* already used our millisecond for this loop... */
    }
    /* Expires */
    if (dictIsRehashing(server.db[dbid].expires)) {
        dictRehashMilliseconds(server.db[dbid].expires,1);
        return 1; /* already used our millisecond for this loop... */
    }
    return 0;
}
 
/* Rehash for an amount of time between ms milliseconds and ms+1 milliseconds */
int dictRehashMilliseconds(dict *d, int ms) {
    long long start = timeInMilliseconds();
    int rehashes = 0;
 
    while(dictRehash(d,100)) {
        rehashes += 100;
        if (timeInMilliseconds()-start > ms) break;
    }
    return rehashes;
}

4.4 漸進式rehash實現

從上面可以看到,不管是在操作中輔助rehash執行,還是在週期函數中輔助執行,最終都是調用dictRehash函數。

/* Performs N steps of incremental rehashing. Returns 1 if there are still
 * keys to move from the old to the new hash table, otherwise 0 is returned.
 *
 * Note that a rehashing step consists in moving a bucket (that may have more
 * than one key as we use chaining) from the old to the new hash table, however
 * since part of the hash table may be composed of empty spaces, it is not
 * guaranteed that this function will rehash even a single bucket, since it
 * will visit at max N*10 empty buckets in total, otherwise the amount of
 * work it does would be unbound and the function may block for a long time. */
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];
        /* Move all the keys in this bucket from the old to the new hash HT */
        while(de) {
            uint64_t 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++;
    }
 
    /* Check if we already rehashed the whole table... */
    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;
}

4.5 漸進式rehash小結

在redis中,擴展或收縮哈希表需要將 ht[0] 裏面的所有鍵值對 rehash 到 ht[1] 裏面, 但是, 這個 rehash 動作並不是一次性、集中式地完成的, 而是分多次、漸進式地完成的。爲了避免 rehash 對服務器性能造成影響, 服務器不是一次性將 ht[0] 裏面的所有鍵值對全部 rehash 到 ht[1] , 而是分多次、漸進式地將 ht[0] 裏面的鍵值對慢慢地 rehash 到 ht[1] 。

以下是哈希表漸進式 rehash 的詳細步驟:

(1)爲 ht[1] 分配空間, 讓字典同時持有 ht[0] 和 ht[1] 兩個哈希表。

(2)在字典中維持一個索引計數器變量 rehashidx , 並將它的值設置爲 0 , 表示 rehash 工作正式開始。

(3)在 rehash 進行期間, 每次對字典執行添加、刪除、查找或者更新操作時, 程序除了執行指定的操作以外, 還會順帶將 ht[0] 哈希表在 rehashidx 索引上的所有鍵值對 rehash 到 ht[1] , 當 rehash 工作完成之後, 程序將 rehashidx 屬性的值增一。

(4)隨着字典操作的不斷執行, 最終在某個時間點上, ht[0] 的所有鍵值對都會被 rehash 至 ht[1] , 這時程序將 rehashidx 屬性的值設爲 -1 , 表示 rehash 操作已完成。

漸進式 rehash 的好處在於它採取分而治之的方式, 將 rehash 鍵值對所需的計算工作均灘到對字典的每個添加、刪除、查找和更新操作上, 從而避免了集中式 rehash 而帶來的龐大計算量。

5. 漸進式 rehash 執行期間的哈希表操作

因爲在進行漸進式 rehash 的過程中, 字典會同時使用 ht[0] 和 ht[1] 兩個哈希表, 所以在漸進式 rehash 進行期間, 字典的刪除(delete)、查找(find)、更新(update)等操作會在兩個哈希表上進行: 比如說, 要在字典裏面查找一個鍵的話, 程序會先在 ht[0] 裏面進行查找, 如果沒找到的話, 就會繼續到 ht[1] 裏面進行查找, 諸如此類。

另外, 在漸進式 rehash 執行期間, 新添加到字典的鍵值對一律會被保存到 ht[1] 裏面, 而 ht[0] 則不再進行任何添加操作: 這一措施保證了 ht[0] 包含的鍵值對數量會只減不增, 並隨着 rehash 操作的執行而最終變成空表。

漸進式rehash帶來的問題
漸進式rehash避免了redis阻塞,可以說非常完美,但是由於在rehash時,需要分配一個新的hash表,在rehash期間,同時有兩個hash表在使用,會使得redis內存使用量瞬間突增,在Redis 滿容狀態下由於Rehash會導致大量Key驅逐。

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