本文正在参加「技术专题19期 漫谈数据库技术」活动
Hash表是⼀种⾮常关键的数据结构,在计算机系统中发挥着重要作⽤。Hash表被广泛应的⼀个重要原因,就是从理论上来说,它能以O(1)的复杂度查询数据。Hash表通过hash函数计算,就能定位数据在表中的位置,紧接着可以对数据进⾏操作,这就使得数据操作⾮常快速。接下来本本将介绍Redis是如何实现⾼性能的Hash表。
Hash 表这个结构也并不难理解,当我们实现 Hash 表时就不得不考虑当数据量越来越大时哈希冲突和 rehash 开销的影响。⽽这两个问题的核⼼,其实都来⾃于 Hash 表要保存的数据量大于当前 Hash 表能容纳的数据量。
那么要如何应对这两个问题呢?事实上,这也是在⾯试中⾯试官经常会考核的问题。比如 Java 程序员在面试中经常被问的 HashMap 是怎样解决哈希冲突以及如何 rehash 的。下面我们来了解一下 Redis 是如何解决这两个问题的。针对哈希冲突,Redis 采⽤了链式哈希,在不扩容哈希表的前提下,将具有相同哈希值的数据用链表链接起来,以便这些数据在表中仍然可以被查询到;对于 rehash 开销,Redis 实现了渐进式 rehash ,进⽽缓解了 rehash 操作带来的额外开销对系统的性能影响。下面我们来分析 Redis 中针对 Hash 表的设计思路和实现⽅法,来掌握应对哈希冲突和优化 rehash 操作性能的能⼒。
数据结构
⼀个最简单的哈希表就是⼀个数组,数组⾥的每个元素是⼀个哈希桶(也叫做 Bucket),第⼀个数组元素被编为哈希桶 0,以此类推。当⼀个键值对的键经过 Hash 函数计算后,再对数组元素个数取模,就得到了该键值对对应的数组元素位置,也就是第⼏个哈希桶。 如下图所⽰,key 经过哈希计算和哈希值取模后,就对应哈希桶 3。
哈希冲突
如果两个不同的 key 经过 Hash 函数计算后,再对数组元素个数取模的值是相同的,那么它们就会落到同一个的桶上,这就是哈希冲突。
那么我们该如何解决哈希冲突呢?我们看下面几种常用方法:
- 拉链法。就是⽤⼀个链表把映射到哈希表同⼀桶中的键给连接起来,注意链表不能太⻓,否则会降低
Hash表的性能。 - 再哈希法。如果第一个哈希函数计算出来的
key的哈希值冲突了,则使用第二个哈希函数计算key的哈希值。 - 开发地址法。如果产生冲突,就去寻找下一个空的哈希地址。只要哈希表足够大,空的哈希地址总能找到,并将数据元素存入。
Redis 使用了拉链法来解决哈希冲突。接下来我们就来看看 Redis 是如何实现的。我们先了解一下 Redis 源码中对 Hash 表的实现。Redis 中和 Hash 表实现相关的⽂件主要是 dict.h 和 dict.c。其中,dict.h ⽂件定义了 Hash 表的结构、哈希项,以及 Hash 表的各种操作函数,⽽ dict.c ⽂件包含了 Hash 表各种操作的具体实现代码。 在 dict.h ⽂件中,Hash 表被定义为⼀个⼆维数组 dictEntry **table,这个数组的每个元素是⼀个指向哈希项(dictEntry)的指针。我们来看一下 Hash 表的定义:
typedef struct dictht {
// 二维数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
unsigned long sizemask;
// 哈希表已有节点的数量
unsigned long used;
} dictht;
接下来我们看一下 dictEntry 的实现,dictEntry 中除了包含指向键和值的指针,还包含了指向下⼀个哈希项的指针。dictEntry 结构体中包含了指向另⼀个 dictEntry 结构的指针 *next,这就是⽤来实现链表的,我们看一下 dictEntry 的定义:
typedef struct dictEntry {
// 指向键的指针
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 下一个哈希项,组成链表
struct dictEntry *next;
} dictEntry;
这⾥还有⼀个值得注意的地⽅,就是在 dictEntry 结构体中,值是由⼀个联合体 v 定义的。这个联合体 v 中包含了指向实际值的指针*val,还包含了⽆符号的 64 位整数、有符号的 64 位整数,以及 double 类型的值。 这种实现⽅法是⼀种节省内存的开发⼩技巧,当值为整数或双精度浮点数时,由于其本⾝就是 64 位,就可以不⽤指针指向了,⽽是可以直接存在键值对的结构体中,这样就避免了再⽤⼀个指针,从⽽节省了内存空间。
通过上图可以看出当发生哈希冲突时,新增的键值通过头插法插入链表,通过这个方法解决哈希冲突。在查找的时候先通过哈希函数找到键所在的桶,然后遍历链表来找到想要查询的键,不过这里存在一个问题,就是随着链表⻓度的增加,查询性能将会下降,比如极端情况下所有的键都在一个节点上查询的复杂度就变成
O(n) 了。如何解决这个问题,就用到我们下面讲的 rehash 操作了。
rehash
rehash 操作其实就是指扩⼤ Hash 表空间。⽽ Redis 实现 rehash 的基本思路是这样的:
Redis使用了两个哈希表⽤于rehash时交替保存数据。
Redis 定义了⼀个 dict 结构体。这个结构体中有⼀个数组(ht[2]),包含了两个 Hash 表 ht[0] 和 ht[1]。dict 结构体的代码定义如下所⽰:
typedef struct dict {
dictType *type;
void *privdata;
//两个Hash表,交替使⽤,⽤于rehash操作 long rehashidx;
dictht ht[2];
//Hash表是否在进⾏rehash的标识,-1表⽰没有进⾏rehash
long rehashidx;
unsigned long iterators;
} dict;
- 在正常服务请求阶段,所有的键值对写⼊哈希表
ht[0]。 - 当进⾏
rehash时,键值对被迁移到哈希表ht[1]中。 - 最后,当迁移完成后,
ht[0]的空间会被释放,并把ht[1]的地址赋值给ht[0],ht[1]的表⼤⼩设置为 0。这样⼀来,⼜回到了正常服务请求的阶段,ht[0]接收和服务请求,ht[1]作为下⼀次rehash时的迁移表。
举个例子,假设程序要对下图所示字典的 ht[0] 进行 rehash 操作,那么程序将执行以下步骤:
1、ht[0].used 当前的值为 4,4*2=8,而 8 恰好是第一个大于等于 4 的 2 的 n 次方,所以程序会将 ht[1] 哈希表的大小设置为 8。下图展示了 ht[1] 在分配空间之后字典的样子:
2、将
ht[0] 包含的四个键值对都 rehash 到 ht[1],如下图所示:
3、释放
ht[0],并将 ht[1] 设置为 ht[0],然后为 ht[1] 分配一个空白哈希表,如下图所示,至此就完成了 rehash 操作。
在了解了 Redis 交替使⽤两个 Hash 表实现 rehash 的基本思路后,我们还需要明确的是:
在实现 rehash 时,都需要解决哪些问题?我认为主要有以下三点:
rehash触发时机rehash扩容大小rehash执行过程 下面我们针对这三个问题对源码进行剖析。
rehash 触发时机
Redis⽤来判断是否触发rehash的函数是 _dictExpandIfNeeded。
static int _dictExpandIfNeeded(dict *d)
{
/* Incremental rehashing already in progress. Return. */
if (dictIsRehashing(d)) return DICT_OK;
/* If the hash table is empty expand it to the initial size. */
// 如果 Hash 表为空,将 Hash 表扩为初始⼤⼩
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
/* If we reached the 1:1 ratio, and we are allowed to resize the hash
* table (global setting) or we should avoid it but the ratio between
* elements/buckets is over the "safe" threshold, we resize doubling
* the number of buckets. */
// 如果Hash表使用的元素个数超过其当前⼤⼩,并且可以进⾏扩容或者Hash表承载的元素个数已是当前⼤⼩的5倍
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;
}
我们看一下 _dictExpandIfNeeded 函数中进⾏扩容的触发条件。_dictExpandIfNeeded 函数中定义了三个扩容条件。
ht[0]的⼤⼩为0ht[0]已有的元素个数已经超过了ht[0]的⼤⼩,同时Hash表可以进⾏扩容ht[0]已有的元素个数是ht[0]的⼤⼩的dict_force_resize_ratio倍,dict_force_resize_ratio的默认值是 5。
对于条件⼀来说,此时 Hash 表是空的,所以这里的工作属于 Hash 表空间的初始化,而非 rehash 操作。
⽽条件⼆和三就对应了 rehash 的场景。这两个条件中都⽐较了 Hash 表当前已有的元素个数(d- >ht[0].used)和 Hash 表当前设定的⼤⼩(d->ht[0].size),这两个值的⽐值⼀般称为负载因⼦(load factor)。也就是说,Redis 判断是否进⾏ rehash 的条件就是看 load factor 是否⼤于等于1和是否⼤于5。当 load factor ⼤于等于1时,Redis 还会判断 dict_can_resize 这个变量值,查看当前是否可以进⾏扩容。 其实,这个变量值是在 dictEnableResize 和 dictDisableResize 两个函数中设置的,它们的作⽤分别是启⽤和禁⽌哈希表执⾏ rehash 功能,如下所⽰:
void dictEnableResize(void) {
dict_can_resize = 1;
}
void dictDisableResize(void) {
dict_can_resize = 0;
}
这两个函数⼜被封装在了 updateDictResizePolicy 函数中。 updateDictResizePolicy 函数是⽤来启⽤或禁⽤ rehash 扩容功能的。这个函数调⽤ dictEnableResize 函数启⽤扩容功能的条件是:当前没有 RDB ⼦进程,并且也没有 AOF ⼦进程。这就对应了 Redis 没有执⾏ RDB 快照和没有进⾏ AOF 重写的场景。你可以参考下⾯的代码:
/* This function is called once a background process of some kind terminates,
* as we want to avoid resizing the hash tables when there is a child in order
* to play well with copy-on-write (otherwise when a resize happens lots of
* memory pages are copied). The goal of this function is to update the ability
* for dict.c to resize the hash tables accordingly to the fact we have o not
* running childs. */
void updateDictResizePolicy(void) {
if (server.rdb_child_pid == -1 && server.aof_child_pid == -1)
dictEnableResize();
else
dictDisableResize();
}
当 load factor ⼤于 5 时,就表明 Hash 表已经过载⽐较严重了,需要⽴刻进⾏库扩容。
我们看下 Redis 会在哪些函数中调⽤ _dictExpandIfNeeded 函数进⾏判断。
在 dict.c ⽂件中查看 _dictExpandIfNeeded 的被调⽤关系,我们可以发现, _dictExpandIfNeeded 是被 _dictKeyIndex 函数调⽤的,⽽ _dictKeyIndex 函数⼜被 dictAddRaw 函数调⽤,最后 dictAddRaw 会被以下三个函数调⽤。
dictAdd:向Hash表中添加⼀个键值对。dictRelace:向Hash表中添加⼀个键值对,如果键值对存在时修改键值对。dictAddorFind:直接调⽤dictAddRaw。
所以当我们往 Redis 中写⼊新的键值对或是修改键值对时,Redis 都会判断下是否需要进⾏ rehash。
总结一下,当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行 rehash:
- 服务器目前没有在执行
BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于 1。 - 服务器目前正在执行
BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于 5。
扩容大小
rehash 对 Hash 表空间的扩容是通过调⽤ dictExpand 函数来完成的。dictExpand 函数的参数有两个,⼀个是要扩容的 Hash 表 dict *d,另⼀个是要扩到的容量 unsigned long size。下面是这个函数的定义:
int dictExpand(dict *d, unsigned long size);
Redis 在执⾏ rehash 操作时,对 Hash 表扩容的思路也很简单,就是如果当前表的已⽤空间⼤⼩为 size ,那么就将表扩容到 size*2 的⼤⼩。 如下所⽰:
dictExpand(d, d->ht[0].used*2);
当 _dictExpandIfNeeded 函数在判断了需要进⾏ rehash 后,就调⽤ dictExpand 进⾏扩容。rehash 的扩容⼤⼩是当前 ht[0] 已使⽤⼤⼩的2倍。 ⽽在 dictExpand 函数中,获取扩容大小的操作是由 _dictNextPower 函数完成的,以下代码显⽰的 Hash 表扩容的操作, 就是从 Hash 表的初始⼤⼩(DICT_HT_INITIAL_SIZE),不停地乘以 2,直到达到⽬标⼤⼩。
static unsigned long _dictNextPower(unsigned long size)
{
// 哈希表的初始⼤⼩
unsigned long i = DICT_HT_INITIAL_SIZE;
// 如果要扩容的⼤⼩已经超过最⼤值,则返回最⼤值加1
if (size >= LONG_MAX) return LONG_MAX + 1LU;
while(1) {
// 如果扩容⼤⼩⼤于等于最⼤值,就返回当前扩到的⼤⼩
if (i >= size)
return i;
// 每次扩大2倍
i *= 2;
}
}
渐进式rehash
在 Hash 表在执⾏ rehash 时,由于 Hash 表空间扩⼤,原本映射到某⼀位置的键可能会被映射到⼀个新的位置上,因此很多键就需要从原来的位置拷⻉到新的位置。⽽在键拷⻉时,Redis 主线程⽆法执⾏其他请求,所以键拷⻉会阻塞主线程,如果 rehash 的时间越长,主线程阻塞的时间就会越长。为了缩短每次 rehash 的时长,Redis 使用了渐进式 rehash 的⽅法。
简单来说,渐进式 rehash 的意思就是 Redis 不会⼀次性把当前 Hash 表中的所有键,都拷⻉到新位置,⽽是会分批拷⻉,每次的键拷⻉只拷⻉ Hash表中⼀个 bucket 中的哈希项。这样⼀来每次键拷⻉的耗时有限,对主线程的影响也就有限了。
dictRehash
我们看一下 Redis 是如何实现渐进式 rehash 的。这⾥有两个关键函数:dictRehash 和 _dictRehashStep。我们先来看 dictRehash 函数,这个函数实际执⾏键拷⻉,它的输⼊参数有两个,分别是全局哈希表(即前⾯提到的dict结构体,包含了 ht[0] 和 ht[1])和需要进⾏键拷⻉的桶数量(bucket 数量)。dictRehash函数的整体逻辑如下:
- ⾸先,该函数会执⾏⼀个循环,根据要进⾏键拷⻉的
bucket数量n,依次完成这些bucket内部所有键的迁移。如果ht[0]哈希表中的数据已经都迁移完成了,键拷⻉的循环也会停⽌执⾏。 - 其次,在完成了
n个bucket拷⻉后,判断ht[0]表中数据是否都已迁移完。如果都迁移完了,那么ht[0]的空间会被释放。然后把ht[1]赋值给ht[0],再把h[1]的⼤⼩就会被重置为0,等待下⼀次rehash。与此同时,全局哈希表中的rehashidx变量会被标为 -1,表⽰rehash结束了。
我们通过下⾯代码,来了解 dictRehash 函数的主要执⾏逻辑。
/* Performs N steps of incremental rehashing. Returns 1 if there are still
* keys to move from the old to the new hash table, otherwise 0 is returned.
*
* Note that a rehashing step consists in moving a bucket (that may have more
* than one key as we use chaining) from the old to the new hash table, however
* since part of the hash table may be composed of empty spaces, it is not
* guaranteed that this function will rehash even a single bucket, since it
* will visit at max N*10 empty buckets in total, otherwise the amount of
* work it does would be unbound and the function may block for a long time. */
int dictRehash(dict *d, int n) {
int empty_visits = n*10; /* Max number of empty buckets to visit. */
if (!dictIsRehashing(d)) return 0;
// 根据要拷⻉的bucket数量n,循环n次后停⽌或ht[0]中的数据迁移完停⽌
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
/* Note that rehashidx can't overflow as we are sure there are more
* elements because ht[0].used != 0 */
assert(d->ht[0].size > (unsigned long)d->rehashidx);
// 如果当前bucket为空则移动到下一个bucket
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
// 如果超过最大空bucket数量,则直接结束
if (--empty_visits == 0) return 1;
}
// 获得哈希表中哈希项
de = d->ht[0].table[d->rehashidx];
/* Move all the keys in this bucket from the old to the new hash HT */
// 将当前桶的数据全部迁移到ht[1]中
// 如果当前 bucket 不为空
while(de) {
uint64_t h;
// 获得同⼀个bucket中下⼀个哈希项
nextde = de->next;
/* Get the index in the new hash table */
// 根据扩容后的哈希表ht[1]⼤⼩计算当前哈希项在扩容后哈希表中的bucket位置
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
// 将当前哈希项添加到扩容后的哈希表ht[1]中
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++;
}
/* Check if we already rehashed the whole table... */
if (d->ht[0].used == 0) {
// ht[0]迁移完后,释放ht[0]内存空间
zfree(d->ht[0].table);
// 让ht[0]指向ht[1]
d->ht[0] = d->ht[1];
// 重置ht[1]
_dictReset(&d->ht[1]);
// 返回0,表⽰ht[0]中所有元素都迁移完
d->rehashidx = -1;
return 0;
}
/* More to rehash... */
// 返回1,表⽰ht[0]中仍然有元素没有迁移完
return 1;
}
我们再看下渐进式 rehash 是如何按照 bucket 粒度拷⻉数据的,这其实就和全局哈希表 dict 结构中的 rehashidx 变量相关了。rehashidx 变量表⽰的是当前 rehash 在对哪个 bucket 做数据迁移。
dictRehash 函数的主循环,⾸先会判断 rehashidx 指向的 bucket 是否为空,如果为空,那就将 rehashidx 的值加 1,然后检查下⼀个 bucket。渐进式 rehash 在执⾏时设置了⼀个变量 empty_visits ⽤来表⽰本次调用已经检查过的空 bucket,当检查了⼀定数量的空 bucket后这⼀轮的 rehash 就停⽌执⾏,转⽽继续处理外来请求,防止空 bucket 过多对主线程的影响。如下面的代码所示:
while(n-- && d->ht[0].used != 0) {
...
// 如果当前bucket为空则移动到下一个bucket
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
// 如果超过最大空bucket数量,则直接结束
if (--empty_visits == 0) return 1;
}
...
}
_dictRehashStep
接下来,我们看一下 _dictRehashStep 这个函数, 它实现了每次只对⼀个 bucket 执⾏ rehash。 从Redis 的源码中我们可以看到,⼀共会有5个函数通过调⽤ _dictRehashStep 函数,进⽽调⽤ dictRehash 函数来执⾏rehash,它们分别是:dictAddRaw,dictGenericDelete,dictFind,dictGetRandomKey, dictGetSomeKeys。 其中,dictAddRaw 和 dictGenericDelete 函数,分别对应了往 Redis 中增加和删除键值对,⽽后三个函数则对应了在 Redis 中进⾏查询操作。下图展示了它们的调用关系。
要注意的是这 5 个函数调⽤的 _dictRehashStep 函数,给 dictRehash 传⼊的循环次数变量n的值都为 1。 这样⼀来,每次迁移完⼀个 bucket,Hash 表就会执⾏正常的增删查请求操作。下面是代码实现
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}
dictRehashMilliseconds
其实 dictRehashMilliseconds 这个函数也会调用 dictRehash,代码如下所示:
int dictRehashMilliseconds(dict *d, int ms) {
long long start = timeInMilliseconds();
int rehashes = 0;
// 每次迁移 100 个 bucket,如果再时间范围内继续迁移否则就停止
while(dictRehash(d,100)) {
rehashes += 100;
if (timeInMilliseconds()-start > ms) break;
}
return rehashes;
}
通过代码我们可以看到这个函数的作用是在规定的时间内对 Hash 表进行 rehash。dictRehashMilliseconds 实际被 databasesCron 这个函数调用。databasesCron 是个属于 Redis 后台定时任务,每隔一段时间就会调用一次,所以 Redis 在空闲时也会进行 rehash 操作。
总结
这篇文章带大家了解了 Redis 中 Hash 表的结构设计、如何解决哈希冲突,以及渐进式 rehash ⽅法的设计实现。Redis 通过拉链法来解决哈希冲突,Redis 还在全局哈希表中包含了两个 Hash 表,为了在实现 rehash 时,帮助数据从⼀个表迁移到另⼀个表。此外,Redis 实现的渐进式 rehash ⽤于在 Hash 表扩容的时候每次仅迁移有限个数的 bucket,避免⼀次性迁移给所有bucket 带来的性能影响。可以把这些设计思想放到自己以后的程序设计中去。