哈希表是一种保存键值对(key - value)的数据结构。
哈希表中的每一个 key 都是独一无二的,程序可以根据 key 查找到与之关联的 value,或者通过 key 来更新 value,又或者根据 key 来删除整个 key - value 等等。
在讲压缩列表的时候,提到过 Redis 的 Hash 对象的底层实现之一是压缩列表(最新 Redis 代码已将压缩列表替换成 listpack)。Hash 对象的另一个底层实现就是哈希表。
哈希表优点在于,它能以 O(1)的复杂度快速查询数据。怎么做到的呢?将 key 通过 Hash 函数的计算,就能定位数据在表中的位置,因为哈希表实际上是数组,所以可以通过索引值快速查询到数据。
但是存在的风险也是有,在哈希表大小固定的情况下,随着数据不断增多,那么哈希冲突的可能性也会越高。
解决哈希冲突的方式,有很多种。
Redis 采用了【链式哈希】来解决哈希冲突,在不扩容哈希表的前提下,将具有相同哈希值的数据穿起来,形成链接起,以便这些数据在表中仍然可以被查询到。
接下来,详细说说哈希表。
1. 哈希表结构设计
Redis 的哈希表结构如下:
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
unsigned long sizemask;
// 该哈希表已有的节点数量
unsigned long used;
} dictht;
可以看到,哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向【哈希表节点(dictEntry)】的指针。
哈希表节点的结构如下:
typedef struct dictEntry {
// 键值对中的键
void *key;
// 键值对中的值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 指向下一个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
dictEntry 结构里不仅包含指向键和值的指针,还包含了指向下一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对链接起来,以此来解决哈希冲突的问题,这就是链式哈希。
另外,这里再提一下,dictEntry 结构里键值对中的值是一个【联合体 v】定义的,因此,键值对中的值可以是一个指向实际值的指针,或者是一个无符号的 64 位整数或有符号的 64 位整数或 double 类的值,这么做的好处是可以节省内存空间,因为当【值】是整数或浮点数时,就可以将值的数据内嵌在 dictEntry 结构里,无需再用一个指针指向实际的值,从而节省了内存空间。
2. 哈希冲突
哈希表实际上是一个数组,数组里每多一个元素就是一个哈希桶。
当一个键值对的键经过 Hash 函数计算后得到哈希值,再将(哈希值 % 哈希表大小)取模计算,得到的结果值就是该 key - value 对应的数组元素位置,也就是第几个哈希桶。
什么是哈希冲突呢?
举个例子,有一个可以存放 8 个哈希桶的哈希表,key1 经过哈希函数计算后,再将【哈希值 % 8】进行取模计算,结果值为 1,那么就对应哈希桶 1,类似的,key 9 和 key10 分别对应哈希桶 1 和 桶 6.
因此,key1 和 key9 对应到了相同的哈希桶中,这就发生了哈希冲突。
因此,当有两个以上数量的 key 被分配到了哈希表中同一个哈希桶上时,此时称这些 key 发生了冲突。
3. 链式哈希
Redis 采用了【链式哈希】的方法来解决哈希冲突。
链式哈希是怎么实现的?
实现的方式就是每个哈希表节点都有一个 next 指针,用于指向下一个哈希表节点,因此多个哈希表节点可以用 next 指针构成一个单向链表。被分配到同一个哈希桶上的多个节点可以用这个单向链表连接起来,这样就解决了哈希冲突。
还是用前面的哈希冲突例子,key1 和 key9 经过哈希计算后,都落在同一个哈希桶,链式哈希的话,key1 就会通过 next 指针指向 key9,形成一个单向链表。
不过,链式哈希局限性也很明显,随着链表长度的增加,在查询这一位置上的数据的耗时就是增加,毕竟链表的查询的时间复杂度是 O(n)。
想要解决这一问题,就需要进行 rehash,也就是对哈希表的大小进行扩展。
接下来,看看 Redis 是如何实现 rehash 的。
4. rehash
哈希表结构设计这一小节,给大家介绍了 Redis 使用 dictht 结构体表示哈希表。不过,在实际使用哈希表时,Redis 定义一个 dict 结构体,这个结构体里定义了两个哈希表(ht[2])。
typedef struct dict {
...
// 两个 Hash 表,交替使用,用于 rehash 操作
dictht ht[2];
...
} dict;
之所以定义了 2 个哈希表,是因为进行 rehash 的时候,需要用上 2 个哈希表了。
在正常服务请求阶段,插入的数据,都会写入到【哈希表1】,此时的【哈希表2】并没有被分配空间。
随着数据逐步增多,出发了 rehash 操作,这个过程分三步:
-
给【哈希表2】分配空间,一般会比【哈希表1】大一倍(两倍的意思);
-
将【哈希表1】的数据迁移到【哈希表2】中;
-
迁移完成后,【哈希表1】的空间会被释放,并将【哈希表2】设置为【哈希表1】,然后在【哈希表2】新创建一个空白的哈希表,为下次 rehash 做准备。
这个过程看起来简单,但是其实第二步很有问题,如果【哈希表1】的数据量非常大,那么在迁移至【哈希表2】的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成堵塞,无法服务其他请求。
5. 渐进式 rehash
为了避免 rehash 在数据迁移过程中,因拷贝数据的耗时,影响 Redis 性能的情况,所以 Redis 采用了渐进式 rehash,也就是将数据的迁移的工作不再是一次性迁移完成,而是分多次迁移。
渐进式 rehash 步骤如下:
-
给【哈希表2】分配空间;
-
在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将【哈希表1】中索引位置上的所有 key - value 迁移到【哈希表2】上;
-
随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把【哈希表1】的所有 key - value 迁移到【哈希表2】,从而完成 rehash 操作。
这样就巧妙地拔一次性大量数据迁移工作的开销,分摊到了多次处理请求的过程中,避免了一次性 rehash 的耗时操作。
在进行渐进式 rehash 的过程中,会有两个哈希表,所以在渐进式 rehash 进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。
比如,查找一个 key 的值的话,会先在【哈希表1】里面进行查找,如果没找到,就会继续到哈希表2 里面进行查找。
另外,在渐进式 rehash 进行期间,新增一个 key - value 时,会被保存到【哈希表2】里面,而【哈希表1】则不再进行任何添加操作,这样保证了【哈希表1】的 key -value 只会减少,随着 rehash 操作的完成,最终【哈希表1】就会变成空表。
6. rehash 触发条件
介绍了 rehash 这么多,还没说什么情况下会触发 rehash 操作。
rehash 的触发条件和负载因子(load factor)有关系。
负载因子可以通过下面的公式计算:
触发 rehash 操作的条件,主要有两个:
-
当负载因子大于等于 1,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。
-
当负载因子大于等于 5,此时说明哈希冲突非常严重了,不管有没有在执行 RDB 快照或者 AOF 重写,都会强制进行 rehash 操作。