1.2、如何实现一个性能优异的Hash表

我们在前面的学习中,应该知道Hash表是一种非常关键的数据结构,在计算机系统中发货这重要的作用。比如说是在Memcached中,Hash表被用来索引数据;在数据库系统中,Hash表被用来辅助SQL查询。而对于Redis键值数据库来说,Hash表既是键值对中的一种值类型,同时,Redis也使用一个全局Hash表来保存所有的键值对,从而即满足应用存取Hash结构数据需求,又能提供快速查询功能。

之所以Hash表应用如此广泛的一个重要原因就是,从理论上来说,它能以O(1)的复杂度快速查询数据。Hash表通过Hash函数的计算,就能定位数据在表中的位置,紧接这可以对数据进行操作,这就使得数据操作非常快速。

Hash表这个结构我们也并不难理解,但是在实际应用Hash表时,当数据量的不断增加,它的性能经常会受到哈希冲突和rehash开销的影响。而这两个问题其实都是来自于Hash表要保存的数据量,超过了当前Hash表能容纳的数据量。

Redis在解决这两个问题时,提出了采用链式哈希,在不扩容哈希表的前提下,将具有相同哈希值的数据链接起来,使得这些数据在表中依然可以查询到;而对于rehash,Redis采用了渐进式rehash的设计,进而缓解了rehash操作带来的额外开销对系统性能的影响。

Redis是如何实现链式哈希?

在开始学习链式哈希时,我们需要先明白什么是哈希冲突,以及为何会查询哈希冲突。

哈希冲突是什么呢

就比如说一个数组要将其存入到hash表中,我们需要对它先进行hash计算和哈希值的取模,在将其放入指定的位置,如果当一个数据量很大的时候,我们在对其进行hash计算和哈希值的取模后,可能会被映射到同一个位置上,如果同一个位置只能保存一个键值对的话,就会导致Hash表保存的数据非常有限,这就是哈希冲突。

如果出现哈希冲突的话,我们怎么样来解决哈希冲突呢。可以考虑如下的两种解决方案:

1、就是我们上面说过的链式哈希。当然该链式哈希也不能太长,否则会降低Hash表的性能。

2、就是当链式哈希的链长达到一定长度时,我们就可以使用rehash,不过执行rehahs本身开销比较大,所有就需要采用我们上面说过的渐进式rehash设计。

链式哈希如何进行设计于实现的

所谓的链式哈希,就是用一个链表将映射到Hash表中同一个位置的键给连接起来。Redis中是如何实现链式哈希的,以及为何链式哈希能够帮助哈希冲突。

Redis源码中实现Hash的位置如下:

 

Hash表的定义:

typedef struct dict {
    dictEntry **table;
    dictType *type;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
    void *privdata; 
} dict;

如上图所示:dictEntry **table 定义一个二维数组,dictType *type 定义一个hash的类型,unsigned long size 定义的是Hash表的大小等等

为了实现链式哈希,Redis在每个disEntry的结构设计中,除了包含指向键和值的指针,还包含了指向下一个哈希项的指针,如下所示:

 typedef struct dictEntry {
    void *key;
    void *val;
    struct dictEntry *next;
} dictEntry;

Redis如何实现rehash的?

rehash操作,其实就是值扩大Hash表空间,而Redis实现rehash的基本思路就是这样子的:

首先,Redis准备了两个哈希表,同于rehash是交替保存数据。

 typedef struct dictIterator {
    dict *ht;
    int index;
    dictEntry *entry, *nextEntry;
} dictIterator;

在如上的源码中,我们可以看到Redis定义了一个数组ht,以及一个整型的数,按照我的理解应该是用来判断hash表是否进行rehash标识的,-1表示没有进行rehash。以及定义了两个dictEntry类型的数组。

其次,在正常服务请求阶段,所有的键值对写入哈希表ht中。

接着,当进行rehash时,键值对被迁移到哈希表nextEntry中。

最后,当迁移完成后,entry的空间会被释放,并把nextEntry的地址赋值给entry,nextEntry的表大小设置为0.这样一来,又回到了正常服务请求的阶段,entry接受和服务请求,nextEntry作为下一次rehash时的迁移表。

什么时候会触发rehash?

首先我们要知道,Redis用来判断是否触发rehash的函数是_dictExpandIfNeeded。所以接下来我们就先看看,_dictExpandIfNeeded函数中进行的扩容触发条件;然后,我们再看了解下_dictExpandIfNeeded又是在哪些函数中被调用的。、

实际上,_dictExpandIfNeeded函数中定义了二个扩容条件。

          条件一:ht的大小为0.

         条件二:ht承载的元素个数已经超过了ht的大小,同时Hash表可以进行扩容。

 static int _dictExpandIfNeeded(dict *ht) {
    /* If the hash table is empty expand it to the initial size,
     * if the table is "full" double its size. */
    /*如果Hash表为空,将Hash表扩为初始大小*/
    if (ht->size == 0)
        return dictExpand(ht, DICT_HT_INITIAL_SIZE);
    if (ht->used == ht->size)
    /*扩容为原来的2倍*/
        return dictExpand(ht, ht->size*2);
    return DICT_OK;
}

对于条件一来说:此时Hash表是空的,所以Redis就需要将Hash表空间设置为初始大小,这是初始化的工作,并不属于rehash操作。

而条件二就对应了rehash的场景。因为在这个条件中,比较了Hash表当前承载的元素个数和Hash表当前设定的大小,这两个值的比值一般称为负载因子,也就是说,Redis判断是否进行rehash的条件,就是看负载因子是否大于等于1和是否大于5。

实际上,当负载因子大于5时,就表明Hash表已经过载比较严重了,需要立刻进行库扩容,而当负载因子大于等于1时,Redis还会进行判断dict_can_resize这个变量值,查看当前是否可以进行扩容。

dict_can_resize这个变量值是什么意思呢?其实,这个变量值是在dictEnableResize和oid dictDisableResize这两个函数中设置的,它们的作用分别是启动和禁用哈希表执行rehash功能,如下所示:

 void dictEnableResize(void) {
    dict_can_resize = 1;
}

void dictDisableResize(void) {
    dict_can_resize = 0;
}

然后,这两个函数又被封装在了updateDictResizePokicy函数中。

然而updateDictResizePokicy函数是用来启动或禁用rehash扩容功能的,这个函数调用dictEnableResize函数启用扩容功能的条件是:当前没有RDB子进程,并且也没有AOF子进程。这就对应了Redis没有执行RDB快照和没有进行AOF重写的场景。你也可以参考下面的代码:

void updateDictResizePolicy(void) {
    if (!hasActiveChildProcess())
        dictEnableResize();
    else
        dictDisableResize();
}

我们到这里就了解了_dictExpandIfNeeded对rehash的判断触发条件,那么现在,我们就再来看看Redis会在哪些函数中继续调用_dictExpandIfNeeded进行判断。

首先,我们可以在dict.c文件中进行查看_dictExpandIfNeeded的被调用关系,我们不难发现,_dictExpandIfNeeded是被_dictKeyIndex函数调用的,而_dictKeyIndex是被dictAddRaw函数调用的,而dictAddRaw会被以下三个函数进行调用:

    dictAdd:用来往Hash表中添加一个键值对。

    dictReplace:用来往Hash表中添加一个键值对,或者当键值对存在时,修改键值对。

    dictAddOrFind:直接调用dictAddRaw。

所以,当我们在往Redis中写入新的键值对或者是修改键值对的时候,Redis都会对其进行判断是否需要进行rehash。这里你可以参考下面给出的示意图,其他就是展示了_dictExpandIfNeeded被调用的关系。

 

从上图可以看出,Redis在触发rehash操作的关键,就是_dictExpandIfNeeded函数和updateDictResizePolicy函数,_dictExpandIfNeeded函数会根据Hash表的负载因子以及是否进行rehash的标识,判断是否进行rehash,而updateDictResizePolicy函数会根据RDB和AOF的执行情况,启动或禁用rehash。

rehash扩容扩多大?

在Redis中,rehash对Hash表空间的扩容是通过调用dictExpand函数来进行实现的。dictExpand函数中的参数有两个,一个是需要扩容的hash表,一个是要扩到的容量,下面就是dictExpand的函数原型:

 int dictExpand(dict *d, unsigned long size) 

就比如说对于一个Hash表来说,我们就可以根据前面提到的_dictExpandIfNeeded函数,来对其进行判断是否需要对其进行扩容。而一旦判断需要扩容,Redis在执行rehash操作时,对Hash表扩容的思路也很简单,就是如果当前表的已用空间为size,那么就将表扩容到size*2的大小。

在代码中的表示如下,就是说当_dictExpandIfNeeded函数在判断了需要进行rehash后,就调用dictExpand进行扩容。这里你可以看到

dictExpand(ht, ht->size*2);

而在dictExpand函数中,具体执行是由_dictNextPower函数完成的,以下代码显示了Hash表的扩容操作,就是从Hash表的初始大小(DICT_HT_INITIAL_SIZE),不停的进行乘2,直到达到目标的大小。

 static unsigned long _dictNextPower(unsigned long size) {
    //哈希表的初始大小
    unsigned long i = DICT_HT_INITIAL_SIZE;

//如果要扩容的大小已经超过最大值,则返回最大值
    if (size >= LONG_MAX) return LONG_MAX;
    while(1) {
        if (i >= size)
            return i;
            //每一步扩容都在现有大小的基础上乘以2
        i *= 2;
    }
}

渐进式rehash如何实现?

首先我们需要先明白为什么要实现渐进式rehash?

其实这是因为,Hash表在执行rehash时,由于Hash表空间扩大,原本映射到某一位置的键可能会被映射到一个新的位置上,因此,很多键就需要从原来的位置拷贝到新的位置。而在键拷贝时,由于Redis主线程无法执行其他请求,所以键拷贝会阻塞主线程,这样就产生rehash开销。

而为了降低rehash开销,Redis就提出了渐进式rehash方法。

简单来说,渐进式rehash的意思就是Redis并不会一次性把当前Hash表中的所有键,都拷贝到新位置,而是会分批拷贝,每次的键拷贝值拷贝Hash表中的一个bucket中的hash项。这样一来,每次键拷贝的时长有限,对主线程的影响也就有限了。

那么,渐进式rehash在代码层面是如何实现的呢?这里就需要介绍到两个关键函数:dictRehash和_dictRehashStep。

首先我们先来看看dictRehash函数,这个函数实际执行键拷贝,它的输入参数有两个,分别是全局哈希表(即前面提到的dict结构体)和需要进行键拷贝的桶数量。

dictRehash函数的整体逻辑包括两部分

    首先,该函数会执行一个循环,根据要进行键拷贝bucket数量n,依次完成这些bucket内部所有键的迁移。当然,如果ht[0]哈希表中的数据已经迁移完成了,键拷贝的循环也会停止执行。

    其次,在完成了n给bucket拷贝后,dictRehash函数的第二部分逻辑,就是判断ht[0]表中数据是否都已迁移完。如果都迁移完了,那么ht[0]的空间就会被释放。因为redis在处理请求时,代码逻辑中都是使用ht[0],所有当rehash执行完成后,虽然数据都在ht[1]中了,但redis仍然会把ht[1]赋值给ht[0],以便其他部分的代码逻辑正常使用。

而在ht[1]赋值给ht[0]后,它的大小就会被重置为0,等待下一次rehash。于此同时,全局哈希表中的rehashidx变量会被标为-1,表示rehash结束了(这里的rehashidx变量用来表示rehash的进度)

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_used[0] != 0) {
        dictEntry *de, *nextde;

        /* Note that rehashidx can't overflow as we are sure there are more
         * elements because ht[0].used != 0 */
        assert(DICTHT_SIZE(d->ht_size_exp[0]) > (unsigned long)d->rehashidx);
        while(d->ht_table[0][d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
        de = d->ht_table[0][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) & DICTHT_SIZE_MASK(d->ht_size_exp[1]);
            de->next = d->ht_table[1][h];
            d->ht_table[1][h] = de;
            d->ht_used[0]--;
            d->ht_used[1]++;
            de = nextde;
        }
        d->ht_table[0][d->rehashidx] = NULL;
        d->rehashidx++;
    }

    /* Check if we already rehashed the whole table... */
    if (d->ht_used[0] == 0) {
        zfree(d->ht_table[0]);
        /* Copy the new ht onto the old one */
        d->ht_table[0] = d->ht_table[1];
        d->ht_used[0] = d->ht_used[1];
        d->ht_size_exp[0] = d->ht_size_exp[1];
        _dictReset(d, 1);
        d->rehashidx = -1;
        return 0;
    }

    /* More to rehash... */
    return 1;
}

如上代码,可以大概知道dictRehash的主要执行过程。

我们再往下看看渐进式rehash是如何按照bucket粒度进行拷贝数据的,这其实就和全局哈希表dict结构中的rehashidx变量相关了。

rehashidx变量表示的是当前rehash在对哪个bucket做数据迁移。比如,当rehashidx等于0时,表示对ht[0]中的第一个bucket进行数据迁移;当rehashidx等于1时,表示对ht[0]中的第二个bucket进行数据迁移,以此类推。

而dictRehash函数的主循环,首先会判断rehashidx指向的bucket是否为空,如果为空,那就将rehashidx的值加1,检查下一个bucket。

那么,会不会出现连续好几个bucket都为空呢?其实是有可能的,在这种情况下,渐进式rehash不会一种递增rehashidx进行检查。这是因为一旦执行了rehash,Redis主线程就无法处理其他请求了。

所有,渐进式rehash在执行是设置了一个变量empty_visits,用来表示已经检查过的空bucket,当检查了一定数据的空bucket后,这一轮的rehash就停止执行,转而继续处理外来请求,避免了对Redis性能的影响。如下面的代码

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

        /* Note that rehashidx can't overflow as we are sure there are more
         * elements because ht[0].used != 0 */
        assert(DICTHT_SIZE(d->ht_size_exp[0]) > (unsigned long)d->rehashidx);
        while(d->ht_table[0][d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
    }

而如果rehashidx指向bucket有数据可以迁移,那么redis就会把这个bucket中的哈希项依次取出来,并根据ht[1]的表空间大小,重新计算哈希项在ht[1]中的bucket的位置,然后把这个哈希项赋值到ht[1]对应的bucket中。

这样,每做完一个哈希项的迁移,ht[0]和ht[1]用来表示承载哈希项多少的变量used,就会分别减一和加一。当然,如果当前rehashidx指向的bucket中数据都迁移完了,rehashidx就会递增加1,指向下一个bucket。如下的代码

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

        /* Note that rehashidx can't overflow as we are sure there are more
         * elements because ht[0].used != 0 */
        assert(DICTHT_SIZE(d->ht_size_exp[0]) > (unsigned long)d->rehashidx);
        while(d->ht_table[0][d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
        de = d->ht_table[0][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) & DICTHT_SIZE_MASK(d->ht_size_exp[1]);
            de->next = d->ht_table[1][h];
            d->ht_table[1][h] = de;
            d->ht_used[0]--;
            d->ht_used[1]++;
            de = nextde;
        }
        d->ht_table[0][d->rehashidx] = NULL;
        d->rehashidx++;
    }

好了,我们到这里也大概了解到了dictRehash函数的全部逻辑了。

现在我们知道,dictRehash函数本身是按照bucket粒度执行哈希项迁移的,它的内部执行bucket迁移个数,主要由传入的循环次数变量n来决定的,但凡Redis要进行rehash操作,最终都会调用dictRehash函数。

接下来,我们来看看另外一个于渐进式rehash相关的函数_dictRehashStep,这个函数实现了每次只对一个bucket执行rehash。

我们在Redis源码中,不能发现一共有5个函数调用了_dictRehashStep函数,进而调用了dictRehash函数,来执行rehash,它们分别是:dictAddRaw、dictGenericDelete、dictFind、dictGetRandomKey、dictGetSomeKeys这五个函数。

其中dictAddRaw和dictGenericDelete函数,分别对应了对Redis进行增加和删除键值对,而后三个函数则对应了在Redis中进行查询的操作。如下图是关于这些函数直接的对应关系。

但是你要注意,不管是增删改查哪种操作,这5个函数调用的都是_dictRehashStep函数,给dictRehash传入的循环次数变量n的值都是1,如下代码所示:

static void _dictRehashStep(dict *d) {
    if (d->pauserehash == 0) dictRehash(d,1);
}

这样一来,每次迁移完一个bucket,Hash表就会执行正常的增删改查请求操作,这就是在代码层面实现渐进式rehash的方法。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值