我们在前面的学习中,应该知道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的方法。