本文首发于公众号:Hunter后端
原文链接:Redis数据结构四之字典和哈希表
字典在 Redis 中应用相当广泛,在介绍字典之前,先来介绍一下字典、哈希表、哈希表节点的几个概念。
Redis 中,字典是用哈希表及额外的一些属性实现的,而哈希表则由多个哈希表节点构成,我们引用基础命令笔记中介绍的哈希命令中的一个例子来做介绍,一个哈希结构如下:
{
"student":
{
"name": "Hunter",
"number": "00001",
"rank": 1
}
}
其中,student: {}
的底层就是一个哈希表
student 下的 {"name": "Hunter"}
、{"number": "00001"}
、{"rank": 1}
就是组成哈希表的各个哈希表节点
对于 Redis,哈希表和哈希表节点两个数据结构就可以实现我们对 key-value 数据的操作,但是随着哈希表保存的键值对增加或者减少,我们就需要对哈希表进行扩展或者收缩操作,因此就额外引入了字典结构来做一些额外的辅助操作,具体的实现我们接下来介绍。
以下是本篇笔记目录:
哈希表结构如下:
typedef struct dictht{
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
在上面的结构中,table
属性是一个数组,每个元素都是一个指向哈希表节点的指针
size
属性记录了哈希表的大小,也就是 table
数组的大小
used
属性记录了记录了哈希表目前已有节点(键值对)的数量
sizemask
属性总是等于 size - 1
,这个属性和哈希值一起决定一个键该被放到 table
数组中的第几个索引上。
现在我们模拟一遍往 student
哈希表里插入一条数据 {"name": "Hunter"}
的过程。
首先,假设我们的哈希表数组的大小为 4(这个大小会随着哈希表里的键值对数量增加或者减少有自动的调整,也就是 rehash 的操作,我们后面再介绍),也就是 size
属性为 4,那么 sizemask = 4 - 1 = 3
。
我们要往 table 里插入 {"name": "Hunter"}
这条数据,而哈希表 table
属性有四个长度,应该放在第几个位置呢,就需要使用这个键值对的键,也就是 name
通过哈希函数得到它的哈希值,这个哈希值是一个数值,假设得到的哈希值是 8,那么接下来就需要将 8 与 sizemask 也就是 3 进行 与
操作,得到的结果是 0,那么将这条数据放在 table
数组的索引 0,也就是第一个位置。
至于根据键值对的键获取哈希值使用的哈希函数,Redis 里使用的是 Murmurhash 算法,有兴趣的可以去了解一下。
哈希表节点的结构如下:
typedef struct dictEntry{
// 键
void *key;
// 值
union{
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
在这个结构中,key 属性保存着键值对中的键,v 属性则保存着键值对的值,其中值可以是一个指针,或者是一个 uint64_t 整数,也可以是一个 int64_t 整数。
假设我们还是使用 {"name": "Hunter"}
为例,那么这个 key
就是指向的就是 name
,val
指向的是 Hunter
。
next 属性则是指向另一个哈希表节点的指针,因为不同的键在经过哈希函数处理之后得到的值在跟 sizemask 进行与操作之后得到的索引位置可能相同,所以哈希表节点通过链地址法来解决键冲突的问题。
字典的结构如下:
typedef struct dict{
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
int rehashidx;
}
type 属性和 privdata 属性是针对不同类型的键值对,为创建多态字典而设置的。
ht 属性则是一个包含了两个哈希表的数组,一般情况下只使用 ht[0] 哈希表,ht[1] 只有在 rehash,也就是对哈希表进行进行扩展或者收缩的时候才会使用。
rehashidx 属性记录了 rehash 目前的进度,如果目前没有进行 rehash 操作,那么它的值就是 -1。
如何将一个新的键值对插入到哈希表中,这个过程我们在介绍哈希表的属性的时候就已经模拟过这个流程了。
当多个键值对通过哈希算法被分配到哈希表的同一个索引上,我们就称这些键发生了冲突。
为了解决冲突,前面在介绍哈希表节点的结构的时候,介绍了哈希表节点有一个 next 属性,指向的下一个哈希表节点,因此哈希表节点就用使用 next 属性将不同的哈希表节点连接起来。
因为哈希表节点组成的链表没有指向链表表尾的指针,所以为了避免遍历节点到链表尾部,程序会将新节点添加到链表的表头位置,排在其他节点的前面。
前面介绍过,当哈希表保存的键值对数量太多或者太少,会需要对哈希表的大小进行相应的扩展或者收缩。
用于衡量哈希表的大小与键值对的数量之间的关系有一个计算关系,称为哈希表的负载因子(load factor)。
负载因子的计算公式如下:
load_factor = ht[0].used / ht[0].size
即哈希表已保存的节点数量 / 哈希表大小。
当以下条件任意一个被满足时,程序自动开始对哈希表进行扩展操作:
BGSAVE 和 BGREWRITEAOF 是 Redis 进行持久化操作的两个后台执行的命令。
当哈希表的负载因子小于 0.1 时,程序自动开始对哈希表执行收缩操作。
接下来介绍一下 Redis 的字典结构进行 rehash 的步骤。
1) 首先,需要用到字典结构中的 ht 数组的第二个,也就是 ht[1],需要为其分配空间。
分配的空间大小取决于是扩展还是收缩操作,以及 ht[0] 当前包含的键值对数量,也就是 ht[0].used 属性的值。
如果是扩展操作,我们将需要 ht[1] 的空间大小设置为 x,那么 x 的应该是 2 的 n 次方,且是大于等于 ht[0].used * 2
的第一个 2 的 n 次方。
我们假设当前 ht[0].used
的值为 9,那么大于等于 9 * 2 = 18
的最小的一个 2 的 n 次方的值是 32,也就是 2 的 5 次方,所以我们要将其 ht[1] 的大小设置为 32,也就是哈希表结构里的 size 属性。
如果是收缩操作,我们需要将 ht[1] 的空间大小设置为 x,这个 x 应该是 2 的 n 次方,且是大于等于 ht[0].used
的第一个 2 的 n 次方。
假设 ht[0].used
的值为 9,那么大于等于 9 的最小的 2 的次方的值是 16,也就是 2 的 4 次方。
2) 分配好 ht[1] 的空间之后,将保存在 ht[0] 的所有键值对 rehash 到 ht[1] 上,rehash 的操作就是逐个重新计算哈希表节点的键的哈希值和对应的索引值,然后将键值对放置到 ht[1] 哈希表对应的位置上。
3) 将 ht[0] 上的所有键值对都迁移到 ht[1] 之后,此时 ht[0] 就变成了空表,释放 ht[0],将 ht[1] 设置为 ht[0],并在 ht[1] 新创建一个空哈希表,为下一次 rehash 作准备。
上面的 rehash 的操作并不是一次性、集中式完成的,而是分多次、渐进式完成的。
因为当 ht[0] 的键值对数量小的时候可以瞬间完成,但是当键值对数量很大,比如几百万,几千万个键值对,一次性进行迁移操作可能导致 Redis 一段时间内停止服务。
因此 rehash 的操作是分多次、渐进式地将 ht[0] 里面的键值对慢慢地 rehash 到 ht[1]。
以下是其详细步骤:
在进行渐进式 rehash 的过程中,哈希表节点,也就是键值对会在两个哈希表中分布,所以字典的删除、查找、更新操作会在两个哈希表上进行。
比如如果在字典查找一个键,在 ht[0] 里没有查找到,那么会继续在 ht[1] 里查找。
另外,在渐进式 rehash 期间,新添加到字典的键值对一律会被保存到 ht[1] 里面,ht[0] 则不再进行任何添加操作,这个措施保证了 ht[0] 包含的键值对数量只减不增,并随着 rehash 操作的执行最终变成空表。