前言
本文主要介绍 Redis Hash 表 rehash 相关的三个问题:
- 什么时候触发 rehash
- rehash 扩容扩多大
- rehash 如何执行
介绍的源码基于 Redis 5.0.8 版本,会删除一些不影响理解的部分。
什么时候触发 rehash
Redis 用于判断是否触发 rehash 的函数是 _dictExpandIfNeeded。它的源码如下:
static int _dictExpandIfNeeded(dict *d) {
if (dictIsRehashing(d)) {
return DICT_OK;
}
// 对应初始化场景,不属于 rehash 操作
if (d->ht[0].size == 0) {
return dictExpand(d, DICT_HT_INITIAL_SIZE);
}
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;
}
从上述代码可以看到有两个扩容条件:
- Hash 表可以进行扩容,同时 ht[0] 的负载因子大于等于 1
- Hash 表不可以进行扩容,但 ht[0] 的负载因子大于 dict_force_resize_ratio(默认值为 5)
dict_can_resize 这个变量由以下两个函数设置:
void dictEnableResize(void) {
dict_can_resize = 1;
}
void dictDisableResize(void) {
dict_can_resize = 0;
}
上面两个函数被 updateDictResizePolicy 函数调用,启用扩容的条件是:当前没有 RDB 子进程,也没有 AOF 子进程,即没有在执行 BGSAVE 命令或 BGREWRITEAOF 命令。
void updateDictResizePolicy(void) {
if (server.rdb_child_pid == -1 && server.aof_child_pid == -1) {
dictEnableResize();
} else {
dictDisableResize();
}
}
为什么有子进程时要提高执行扩展所需的负载因子呢?
这是因为目前大多数操作系统都采用写时复制技术来优化子进程的使用效率,这样可以避免不必要的内存写入操作。
_dictExpandIfNeeded 被 _dictKeyIndex 调用,而 _dictKeyIndex 又被 dictAddRaw 调用。
rehash 扩容扩多大
在 Redis 中,扩容是通过调用 dictExpand 函数完成的。上面的调用中有如下调用:
dictExpand(d, d->ht[0].used*2);
是将当前已使用大小的 2 倍传给 dictExpand 函数,dictExpand 代码如下:
int dictExpand(dict *d, unsigned long size) {
// 新的哈希表
dictht n;
unsigned long realsize = _dictNextPower(size);
// 申请空间并进行初始化
n.size = realsize;
n.sizemask = realsize-1;
n.table = zcalloc(realsize*sizeof(dictEntry*));
n.used = 0;
d->ht[1] = n;
d->rehashidx = 0;
return DICT_OK;
}
上面的代码调用 _dictNextPower 获取了 realsize。如果执行的是扩展操作,那么 realsize 的大小为第一个大于等于 dt[0].used * 2 的 2 n 2^n 2n。
为什么要扩容为之前的 2 倍,并且是 2 n 2^n 2n 的倍数呢?这样做有两方面好处:
- 可以将 hash % n 操作转换为 hash & (n - 1),取模转换为位运算操作,提高效率
- 对元素的重新分配操作更简单,只需要根据 hash & oldCap 是否等于 0 拆分成两部分。等于 0 的 newTable[i] = oldTable[i];不等于 0 的 newTable[i + oldCap] = oldTable[i]
渐进式 rehash 如何执行
扩容哈希表需要将 ht[0] 里面的所有键值对 rehash 到 ht[1] 里面,但这个 rehash 动作并不是一次性完成的,而是分多次,渐进式完成的。
这样做的原因在于,如果哈希表里保存的键值对数量很多,那么要一次性将这些键值对全部 rehash 到 ht[1] 的话,庞大的计算量可能会导致服务器在一段时间内停止服务。
以下是哈希表渐进式 rehash 的详细步骤:
- 为 ht[1] 分配空间,让字典同时持有 ht[0] 和 ht[1] 两个哈希表
- 在字典中维护一个索引计数器变量 rehashidx,并将它的值设置为 0,表示 rehash 工作开始
- 在 rehash 进行期间,每次对字典执行添加、删除、查找或更新操作时程序除了执行指定的操作之外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1],当 rehash 工作完成之后,程序将 rehashidx 属性的值加 1
- 随着字典操作的不断进行,ht[0] 上的所有键值对都会被 rehash 到 ht[1],这时程序将 rehashidx 属性的值设为 -1,表示 rehash 操作已完成
rehash 在代码层面是如何实现的呢?有两个关键函数:dictRehash 和 _dictRehashStep。下面是 dictRehash 的代码,它将 n 个桶的元素移动到 ht[1]:
int dictRehash(dict *d, int n) {
// 要访问的空桶的最大数量
int empty_visits = n*10;
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
// 当前桶为空,访问下一位置
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) {
return 1;
}
}
// 待移动的桶
de = d->ht[0].table[d->rehashidx];
// 将该桶中的元素从 ht[0] 移动到 ht[1]
while(de) {
uint64_t h;
nextde = de->next;
// 获取在 ht[1] 的下标
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
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;
}
// 还有更多需要 rehash
return 1;
}
_dictRehashStep 函数实现了对一个 bucket 执行 rehash。一共有 5 个函数调用 _dictRehashStep 函数,分别是:dictAddRaw、dictGenericDelete、dictFind、dictGetRandomKey 和 dictGetSomeKeys。
那么此时问题来了,如果我们将 ht[0] 中 0、1、2 号下标 bucket 中的元素移动到了 ht[1],后续又往这几个地方插入该怎么办?
不用担心,因为在 rehash 时,会用如下代码判断要操作的哈希表,所以不会有上述问题。
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
查找和删除操作会检查两个哈希表,所以也没有任何问题。
参考资料
- 《Redis 设计与实现》
- 极客时间:Redis 源码剖析与实战