本章会讲述Redis到底是怎样设计rehash来减少对主线程的阻塞,它的设计实现绝对会让你大开眼界,在面试官面前展示你的知识深度。
声明 : Redis的字典就是用哈希表来实现的,和java的HashMap实现一样,只是加了渐进式rehash的设计。
1.哈希表的概念 : 存储k-v数据时,先把key进行哈希计算得到hash值,hash值%数组长度,就得到这个k-v数据存储在数组的哪个index下。
2.哈希冲突 : 在key进行哈希计算得到hash值时,可能不同的key会得到相同的hash值,这种现象叫hash冲突。解决hash冲突有很多办法,redis用的是链地址法,就是把数组中同一下index下的所有数据,通过链表的形式进行关联。
我们都知道,如果哈希冲突,index下面会用链表进行关联数据。
下面通过查询一个key的复杂度来分析 :
查找一个key的过程 : 先是对key进行hash计算并对数组长度取模得到数据所在的index,在该index下遍历链表来查找key。那么结论来了,查找key的复杂度取决于链表的长度,如果链表的长度为n,那么复杂度就为o(n),n越大查询效率就会越低。当数据个数越多,哈希表的hash冲突的概率就会越高,导致链表长度越长,影响查询效率,所以要进行rehash。理解这个非常重要。
Java的HashMap进行rehash是一次性完成的,而redis的字典进行rehash是一个index一个index进行的。
redis的字典数据结构中有rehashidx属性,属性的含义是,字典的rehash进度。
rehashidx属性的值为-1时,表示没有在进行rehash,大于-1时,表示rehash到哪个index下了。
这个就是redis字典的数据结构,使用两个hash表来实现的。在进行rehash时,rehashidx的值会变成0,当h0数组的0位置所有的数据都移到h1数组中时,会把rehashidx加1,表示0位置已经rehash完,1这个位置下没有进行rehash。直到把h0所有的数据都移到h1里去,rehashidx会变成-1,表示不再进行rehash,并且这时候会把h0和h1互换。这里简单介绍下rehash的执行过程。
渐进式rehash就是每次只rehash几个桶(就是index),把rehash所需要的时间打散,每个命令占用一点点时间来减少对主键程的阻塞。
下面又到了我们的源码实现环节,这里解释下,我的所有redis文章,最后都会从源码实现来了解功能。
按照触发时机这个思路来看下面的源码
1.每次对字典key有操作时
对字典key的操作命令会先调用dictFind函数,函数会判断是否在rehash,如果在则调用dictRehash函数,进行rehash一个桶。
// 字典 查询key数据
dictEntry *dictFind(dict *d, const void *key) {
// 如果字典为空什么都不做
if (d->ht[0].used + d->ht[1].used == 0) return NULL;
// rehashidx如果不为-1,表示正在进行rehash。如果为-1表示字典没有进行rehash
// 调用dictRehash函数进行rehash,参数1代表只rehash一个桶
if (d->rehashidx != -1) dictRehash(d,1);
... // 查找数据的过程省略,本章重点是rehash
return NULL;
}
// 函数作用是 最多rehash n个有效桶(有效桶的定义是该index下有数据)
// 最大空桶数 = n * 10
int dictRehash(dict *d, int n) {
/* 最大空桶数 */
int empty_visits = n * 10;
if (d->rehashidx == -1) return 0;
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
// 如果index下没有数据则遍历下一个index
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
// 这里如果扫描的空桶数达到了最大空桶数,则本次rehash结束。
if (--empty_visits == 0) return 1;
}
// 取出该index下链表的头节点
de = d->ht[0].table[d->rehashidx];
/* 把ht[0].table[d->rehashidx]这个桶下的所有节点都移到ht[1]里 */
while(de) {
unsigned int h;
nextde = de->next;
// 计算index值
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++;
}
/* 如果rehash完,进行ht[0]和ht[1]替换,并释放内存 */
if (d->ht[0].used == 0) {
zfree(d->ht[0].table);
d->ht[0] = d->ht[1];
_dictReset(&d->ht[1]);
// 如果字典rehash完,会把rehashidx赋值为-1。
// 所以从这里就可以看出rehashidx就是一个标记
d->rehashidx = -1;
return 0;
}
return 1;
}
2.定时任务(定时任务只会对键空间和过期空间的字典进行rehash)
这里有两个名词键空间和过期空间。
键空间指的是数据库保存所有k-v数据,是个字典结构。
过期空间指的是数据库保存所有k-v的过期数据,是个字典结构。
/* 定时任务会触发该方法 */
int incrementallyRehash(int dbid) {
/* 键空间是否在进行rehash*/
if (dictIsRehashing(server.db[dbid].dict)) {
// 1 代表 最多rehash 1ms,
// 这个也是redis为了减少主线程阻塞的设计。
// 设想一个存储3千万个k-v的数据库,进行rehash要是不控制时间,
// 肯定会阻塞主线程特别长时间,导致redis服务器响应命令时间长。
dictRehashMilliseconds(server.db[dbid].dict,1);
return 1;
}
/* 过期空间是否在进行rehash */
if (dictIsRehashing(server.db[dbid].expires)) {
// 同上
dictRehashMilliseconds(server.db[dbid].expires,1);
return 1;
}
return 0;
}
/* 在约定ms毫秒内进行rehash */
int dictRehashMilliseconds(dict *d, int ms) {
long long start = timeInMilliseconds();
int rehashes = 0;
/* 每次rehash100个桶 */
// 这个dictRehash函数就是上面所讲的
while(dictRehash(d, 100)) {
rehashes += 100;
// 超过约定时间,直接返回。
if (timeInMilliseconds()-start > ms) break;
}
return rehashes;
}
本文重点是理解渐进式rehash的过程,并理解存储业务数据的字典只有在访问时才会进行rehash,那么对于一个业务字典可能一直处于rehash状态。
这里留下一个问题,这种设计对于大key会产生什么问题?
后续会更新关于这个问题的解答。
最近一直在更新一些从Redis源码角度看问题的文章。小伙伴们想要了解Redis的什么,可以评论区留言或私信。