聊聊Redis鍵值存儲結(jié)構(gòu)以及Rehash機(jī)制

一、鍵值對的結(jié)構(gòu)

了解 Redis 朋友的都知道,Redis 是一種鍵值對 ( Key-Value Pair ) 數(shù)據(jù)庫,在內(nèi)存中鍵值對是以字典 ( Dict ) 的方式保存的,而字典的底層其實是通過 哈希表 來實現(xiàn)的。通過哈希表中的節(jié)點保存字典中的鍵值對。而這個哈希表的數(shù)據(jù)結(jié)構(gòu)就是一個數(shù)組。

也就是說當(dāng)我們添加或修改數(shù)據(jù)時,只需要計算出鍵的哈希值,然后,跟數(shù)組的大小取模,就可以很快的定位到它所對應(yīng)的哈希桶的位置。所以,哈希表的最大好處就是我們可用O(1)的時間復(fù)雜度來快速查找鍵值對。(如下圖所示)。

[圖片上傳失敗...(image-2f13f0-1617843426474)]

但是,這里存在一個問題,就是當(dāng)存入的鍵之間計算的哈希值相同時,就會出現(xiàn)鍵沖突的問題。那么,Redis是如何解決的呢?

二、鍵的哈希值沖突了怎么辦

當(dāng)往 Redis寫入大量的數(shù)據(jù)時,不可避免的會出現(xiàn)鍵沖突的問題。這里的哈希沖突,也就是指,兩個 key 的哈希值和哈希桶計算對應(yīng)關(guān)系時,正好落在了同一個哈希桶中。畢竟,哈希桶的個數(shù)通常要少。

Redis 解決沖突的方式,跟 Java中的 HashMap 相同,也是以鏈表的方式解決的。也就是當(dāng)多個鍵的哈希值相同,落到同一個哈希桶上時,它們之間以鏈表的形式保存。如下圖所示:

在這里插入圖片描述

但是,這里存在一個問題,哈希沖突鏈上的元素只能通過指針逐一查找再操作。如果哈希表里寫入的數(shù)據(jù)越來越多,哈希沖突可能也會越來越多,這就會導(dǎo)致某些哈希沖突鏈過長,進(jìn)而導(dǎo)致這個鏈上的元素查找耗時長,效率降低。對于追求“快”的 Redis 來說,這是不太能接受的。

所以,Redis 是通過擴(kuò)容,也就是擴(kuò)大哈希表的長度來解決此問題。

三、Redis中的Rehash

Redis 中是通過對哈希表進(jìn)行 Rehash 操作,也就是增加現(xiàn)有哈希桶的數(shù)量,讓主鍵增多的 數(shù)據(jù)能在更多的哈希桶之間分散保存,減少了每個桶中的元素數(shù)量,從而減少單個桶中的沖突。那它具體是怎么做的呢?

1、字典結(jié)構(gòu)

我們先通過Redis 源碼看看 它的字典 ( dict )結(jié)構(gòu)是如何實現(xiàn)。

/* 源碼文件 src\dict.h */

/* 哈希表
 * 每個字典都使用兩個哈希表,以實現(xiàn)漸進(jìn)式 rehash 。
 */
typedef struct dictht {
    dictEntry **table;       // 哈希表數(shù)組
    unsigned long size;      // 哈希表大小
    unsigned long sizemask;  // 哈希表大小掩碼,用于計算索引值,總是等于 size - 1
    unsigned long used;      // 該哈希表 已有節(jié)點數(shù)量
} dictht;

// Redis 中的字典結(jié)構(gòu)
typedef struct dict {
    dictType *type; // 類型特定函數(shù)
    void *privdata; // 私有數(shù)據(jù)
    dictht ht[2];  // 哈希表
    long rehashidx; /* Rehash 索引,當(dāng)不在進(jìn)行Rehash時 值為 -1 */
    unsigned long iterators; /* 目前正在運行的安全迭代器的數(shù)量 */
} dict;

由 Redis 源碼我們不難發(fā)現(xiàn),在 dict 結(jié)構(gòu)中定義了兩個 dictht ,也就是存在兩個哈希表。Redis 這么做的目的就是為了使 rehash 操作更高效。Redis 正常情況下都是使用 哈希表1( 即 dict->ht[0] ),哈希表2( 即 dict->ht[1] )并不會分配空間,只有當(dāng)數(shù)據(jù)不斷增多,需要進(jìn)行 rehash 時采用用到 哈希表2。

2、何時 rehash

在 Redis 中是跟 Java 中的 HashMap 一樣,也是當(dāng)哈希表中保存的元素達(dá)到某個閾值的時候,就會觸發(fā) rehash操作。同樣我們也通過 Redis 源碼來看看它是什么時候觸發(fā)的。

/*源碼文件 src\dict.c */

// 添加元素
int dictAdd(dict *d, void *key, void *val){
     //  掉方法 dictAddRaw
    dictEntry *entry = dictAddRaw(d,key,NULL);
    // 省略部分代碼
    return DICT_OK;
}

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

    if (dictIsRehashing(d)) _dictRehashStep(d); // 判斷是否整在進(jìn)行 rehash

    /* 
     * 獲取新元素 在哈希表中的下標(biāo),如果已經(jīng)存在返回 -1
     */
    if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
        return NULL;
    // 省略部分代碼
    return entry;
}

static long _dictKeyIndex(dict *d, const void *key, uint64_t hash, dictEntry **existing){
    unsigned long idx, table;
    dictEntry *he;
    if (existing) *existing = NULL;

    /* Expand the hash table if needed */
    /* 判斷 是否需要進(jìn)行擴(kuò)容 */
    if (_dictExpandIfNeeded(d) == DICT_ERR)
        return -1;
    
     // 省略部分代碼
    return idx;
}

/*  判斷是否需要進(jìn)行擴(kuò)容操作 */
static int _dictExpandIfNeeded(dict *d){
    /*  如果正在進(jìn)行 rehash 則直接返回 OK. */
    if (dictIsRehashing(d)) return DICT_OK;

    /*  如果當(dāng)前 哈希表1 為空,即第一次添加元素,則直接創(chuàng)建 */
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);

    /*
     * 如果當(dāng)前已有元素大于等于哈希表的大小,并且允許 rehash 或者已經(jīng)
     * 超過了安全閾值,則進(jìn)行擴(kuò)容操作
     * 
     */
    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;
}

說明:

1、d->ht[0].used >= d->ht[0].size 判斷哈希表1 中的已有元素是否大于等于哈希表的長度。

2、dict_can_resize 是否允許進(jìn)行 rehash 的標(biāo)準(zhǔn) ,0-不允許,1-允許。當(dāng) redis 正在進(jìn)行 生成 RDB 快照或者 重新AOF 文件時,此值設(shè)置為0

3、d->ht[0].used/d->ht[0].size > dict_force_resize_ratio 判斷哈希表 中的已有元素與哈希表長度的比率是超過安全比率 dict_force_resize_ratio。 dict_force_resize_ratio 值為 5。

總結(jié),當(dāng) Redis 中哈希表中的已有元素個數(shù)大于等于哈希表的長度,并且 Redis 不在處于 正在生產(chǎn) RDB快照或者重寫AOF文件,或者 哈希表已有元素個與哈希表的長度的比率大于 5 時,就會觸發(fā) rehash 操作。

<span style="color: #FF0000">強(qiáng)調(diào):Redis中的閾值時固定,不可改的。</span>

3、rehash 步驟

擴(kuò)展和收縮哈希表的工作可以通過執(zhí)行rehash(重新散列)操作來完成,Redis 中執(zhí)行 rehash 的步驟如下:

  1. 給哈希表2 ( ht[1] )分配的空間。這個哈希表的空間大小取決于要執(zhí)行的操作,以及ht[0]當(dāng)前包含的鍵值對數(shù)量(也即是ht[0].used屬性的值):

    • 如果執(zhí)行的是擴(kuò)展操作,那么 ht[1] 的大小為第一個大于等于 ht[0].used*2 的 2 n(2的n次方冪);
    • 如果執(zhí)行的是收縮操作,那么 ht[1] 的大小為第一個大于等于 ht[0].used 的2 n
  2. 將保存在 ht[0] 中的所有鍵值對 rehash 到 ht[1] 上面:rehash指的是重新計算鍵的哈希值和索引值,然后將鍵值對放置到ht[1]哈希表的指定位置上。

  3. 當(dāng) ht[0] 包含的所有鍵值對都遷移到了 ht[1] 之后(ht[0]變?yōu)榭毡恚?,釋放ht[0],將ht[1]設(shè)置為ht[0],并在ht[1]新創(chuàng)建一個空白哈希表,為下一次rehash做準(zhǔn)備。

上面的步驟看似很簡單,但是,我們知道 Redis 是單線程的,那么如果一次性把哈希表1中的數(shù)據(jù)全部遷移完,勢必會造成 Redis 線程阻塞,無法服務(wù)其他請求,此時,Redis 就無法快速訪問數(shù)據(jù)了。

為了避免這個問題,Redis 采用了 漸進(jìn)式rehash

4、漸進(jìn)式 rehash

何為漸進(jìn)式rehash ?簡單來說就是在第二步拷貝數(shù)據(jù)時,Redis 不是集中式的一次性完成的,而是分多次、漸進(jìn)式地完成的。 Redis 是把數(shù)據(jù)的遷移任務(wù)分散到了每次的請求中。

  • 操作步驟
  1. 在字典中維護(hù)了一個 rehashidx 變量,當(dāng)開始 rehash 時,他的值設(shè)置為 0,也就是表示從哈希表1,索引0 開始。
  2. 在每次的 增、刪、改、查操作時,除了執(zhí)行指定操作之外,還會判斷是否正在 rehash,即 rehashidx != -1,如果是,就會順帶把 rehashidx 索引位置的 元素遷移到 哈希表2中。
  3. 隨著操作不斷的執(zhí)行,最終會在某個時間點上,哈希表1 中的所有鍵值都會被 rehash 到哈希表2中。這時會把rehashidx的值設(shè)置為 -1,表示 rehash 操作已完成。

如下圖所示:


在這里插入圖片描述
  • 源碼分析

    由上面介紹的 rehash 觸發(fā)條件的源碼中我們可知當(dāng)滿足條件后回調(diào)用 dictExpand 方法,那么我們看下此方法的詳細(xì)實現(xiàn)

    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;
    
        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 */
        /* 分配一個新的 哈希表, 并且初始化所有的指針指向 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) { // 第一次創(chuàng)建
            d->ht[0] = n;
            return DICT_OK;
        }
    
        /* Prepare a second hash table for incremental rehashing */
        d->ht[1] = n;
        d->rehashidx = 0; // 把 rehash 標(biāo)志設(shè)置為 0,
        return DICT_OK;
    }
    

    此方法主要實現(xiàn)了以下功能:

    1. 計算需要擴(kuò)容的新的哈希表的大小
    2. 給哈希表分配空間,并且所有的指針都指向 null
    3. 把rehash索引 rehashidx 設(shè)置為 0,也就是說 rehash 是從哈希表 1 的 0 位桶開始拷貝。

    接下來我們在看看,Redis中 增刪改查的代碼中是如何進(jìn)行 rehash 的。

    /* 增加方法 */
    dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing){
        long index;
        dictEntry *entry;
        dictht *ht;
        // 判斷是否整在進(jìn)行 rehash
        if (dictIsRehashing(d)) _dictRehashStep(d); 
        
        // 省略部分代碼
    }
     /* 查詢方法 */
    dictEntry *dictFind(dict *d, const void *key){
        dictEntry *he;
        uint64_t h, idx, table;
    
        if (d->ht[0].used + d->ht[1].used == 0) return NULL; /* dict is empty */
         // 判斷是否整在進(jìn)行 rehash
        if (dictIsRehashing(d)) _dictRehashStep(d);
    
        // 省略部分代碼   
    }
    
    /* 刪除方法 */
    static dictEntry *dictGenericDelete(dict *d, const void *key, int nofree) {
        uint64_t h, idx;
        dictEntry *he, *prevHe;
        int table;
    
        if (d->ht[0].used == 0 && d->ht[1].used == 0) return NULL;
       
        // 判斷是否整在進(jìn)行 rehash
        if (dictIsRehashing(d)) _dictRehashStep(d);
        
        // 省略部分代碼
    }
    

    有增、刪、查的方法我們可以發(fā)現(xiàn)所有的方法中都要 if (dictIsRehashing(d)) _dictRehashStep(d); 這段代碼,此行代碼就是 判斷當(dāng)前是否正在進(jìn)行 rehash 也就是 rehashidx != -1 則進(jìn)行一步 rehash 操作。

5、定時輔助 rehash

由于,Redis 是漸進(jìn)的方式進(jìn)行 rehash 的,所以,在 Redis 中的鍵值對很多的情況下,那么,哈希表1 和 哈希表2 將共存很長時間,Redis 在漸進(jìn)式 rehash 的同時,還會在 Redis 空閑時也會定時輔助進(jìn)行 rehash 。

下面我們通過 Redis 源碼,看看是如何實現(xiàn)的。

  • 調(diào)度方法 serverCron
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    
    // 省略部分代碼
    
      /* We need to do a few operations on clients asynchronously. */
    clientsCron();

    /* 處理 Redis 數(shù)據(jù)庫后端操作,再次方法中執(zhí)行定時 rehash 操作 */
    databasesCron();

    // 省略部分代碼
}
  • databaseCron 方法

    void databasesCron(void) {
        /* 處理過期鍵 */
        if (server.active_expire_enabled) {
            if (server.masterhost == NULL) {
                activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);
            } else {
                expireSlaveKeys();
            }
        }
    
        /* Defrag keys gradually. */
        if (server.active_defrag_enabled)
            activeDefragCycle();
    
        /* 如果當(dāng)前系統(tǒng)沒有在 生成 RDB 快照或 重寫 AOF 文件,則進(jìn) */
        if (server.rdb_child_pid == -1 && server.aof_child_pid == -1) {
            
            // 省略部分代碼
    
            /* 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;
                    }
                }
            }
        }
    }
    

    說明:

    可以在 redis.conf 配置文件中配置 activerehashing 的值是否啟動,定時輔助 rehash,默認(rèn)為 yes 啟動。

四、擴(kuò)展知識

1、rehash 期間如何查找值

因為在進(jìn)行漸進(jìn)式 rehash 的過程中,字典會同時使用 ht[0] 和 ht[1] 兩個哈希表,所以在漸進(jìn)式 rehash 進(jìn)行期間,字典的刪除(delete)、查找(find)、更新(update)等操作會在兩個哈希表上進(jìn)行。首先會現(xiàn)在 ht[0] 上查找,如果不存在,則到 ht[1] 上查找。

2、rehash期間有新增怎么存

在漸進(jìn)式 rehash 執(zhí)行期間,新添加到字典的鍵值對一律會被保存到 ht[1] 里面,而 ht[0] 則不再進(jìn)行任何添加操作,這一措施保證了 ht[0] 包含的鍵值對數(shù)量會只減不增,并隨著 rehash 操作的執(zhí)行而最終變成空表。

3、生成 RDB 文件或 重寫 AOF 期間為什么不能 rehash 操作

這是因為在 Redis 中是通過創(chuàng)建子進(jìn)程的方式,生成 RDB 快照或 重寫 AOF 文件的,而大多數(shù)操作系統(tǒng)都采用寫時復(fù)制(copy-on-write)技術(shù)來優(yōu)化子進(jìn)程的使用效率。因為 rehash 操作需要父進(jìn)程申請新的哈希表,所有在 生成RDB或 AOF重寫 期間,會造成父進(jìn)程大量的 COW操作,影響父進(jìn)程的性能。

說明:

如果負(fù)載因子大于一定值(固定為 5) 時,即時正在進(jìn)行 生成RDB 或 重寫 AOF 也會進(jìn)行 rehash 操作。

4、為什么rehash期間,允許RDB和AOF rewrite?

因為 rehash 已經(jīng)開始,說明新的哈希表內(nèi)存已經(jīng)申請完成了,之后的 rehash 過程只是遷移哈希桶下的數(shù)據(jù)指針,不涉及到內(nèi)存申請了,所以 RDB 和 AOF rewrite 沒影響。

五、小結(jié)

在 Redis 中是以 哈希表的方式來保存鍵值對數(shù)據(jù)的,但是隨著鍵值對的增多,會出現(xiàn) 哈希沖突的情況,這種情況,Redis 是以鏈表的方式解決哈希沖突的。當(dāng)鏈表變得很長時,會影響 Redis 的查找性能,為了減小 鏈表的長度,Redis 采用了 rehash 操作,也就是把擴(kuò)大當(dāng)前哈希表的長度,Redis 在 rehash 是不是一次性rehash ,而是采用了漸進(jìn)式方式,這樣可以解決長時間阻塞,在 漸進(jìn)式 rehash 的同時,Redis 在空閑時間也會進(jìn)行 1Ms 的定時 rehash。

https://mp.weixin.qq.com/s/N-4clpJNo3MevurIQo8Ehw

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容