前言
字典,又称为符号表,关联数组或映射,是一种用于保存键值对的数据结构
字典的实现
Redis 的字典使用 哈希表 作为底层实现
哈希表
typedef struct dictht{
dictEntry** table; //哈希表数组
unsigned long size; //哈希表大小
unsigned long sizemask; //哈希表大小掩码,总是等于 size - 1
unsigned long used; //该哈希表已有节点的数量,即所保存键值对的数目
}dictht;
table 属性是一个数组,其中每个元素都是一个指向 dictEntry 结构的指针,每一个 dictEntry 结构保存着一个键值对
哈希表节点
typedef struct dictEntry{
void *key; //键
union{
void *val;
uint64_tu64;
int64_ts64;
}v; //值
struct dictEntry *next; //指向下一个哈希表节点,形成链表
}dictEntry;
可以看出,Redis 中的哈希表使用 链地址法 解决哈希冲突的问题。而且由于没有保存指向链表表尾的指针,为了速度考虑,程序使用的是 头插入法,插入的复杂度为 O(1)
字典
typedef struct dict{
dictType *type; //类型特定函数
void *privdata; //私有数据
dictht ht[2]; //哈希表
int rehashidx; //rehash 索引,当rehash过程不在进行时,值为-1
}dict;
type 属性和 privdata 属性是针对不同类型的键值对,为创建多态字典而设置。
其中,type 属性是一个指向 dictType 结构的指针,每个 dictType 结构保存了一簇用于操作特定类型键值对的函数,Redis 就会为用途不同的字典设置不同的函数
ht 属性是一个包含两个元素的数组,每个项都是一个哈希表,一般使用的是 ht[0] 哈希表,ht[1] 只有在 rehash 时会使用
整个字典的结构如下:
哈希算法
当要添加一个新的键值对到字典里时:
- 根据键值对的键计算出哈希值,使用字典中的哈希函数进行计算
hash = dict->type->hashFunction(key)
- 根据哈希值以及哈希表的大小掩码,进行相与运算,计算出索引值,
index = hash & dict->ht[x].sizemask
- 再将键值对放到哈希表数组的指定索引上面
当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis 使用 MurmurHash2 算法来计算键的哈希值
rehash
为了使哈希表的负载因子维持在一个合理的范围之内,当哈希表保存的键值对太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩,通过执行 rehash 操作来完成:
- 为字典的 ht[1] 哈希表分配空间,哈希表的空间大小取决于要执行的操作以及当前 ht[0] 包含的键值对数量
>>> 如果执行的是 扩展 操作,那么 ht[1] 的大小为 第一个大于等于 ht[0].used * 2 的 2 的 n 次方幂
>>> 如果执行的是 收缩 操作,那么 ht[1] 的大小为 第一个大于等于 ht[0].used 的 2 的 n 次方幂 - 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面。rehash 即重新计算键的哈希值以及索引值,然后根据新的索引值放置到 ht[1] 哈希表的相应位置上
- 当 ht[0] 包含的所有键值对都迁移到了 ht[1] 后,释放 ht[0],将 ht[1] 设置为 ht[0],并在 ht[1] 新创建一个空白哈希表,为下一次 rehash 做准备
扩展或收缩的时机
当服务器目前 没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 1,或者服务器 正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 5,程序就会自动对哈希表执行扩展操作。其中负载因子为 load_factor = ht[0].used / ht[0].size
为何根据 BGSAVE 命令或者 BGREWRITEAOF 命令是否正在执行,所选择的扩展所需负载因子不同?
- 这是因为在执行 BGSAVE 命令或者 BGREWRITEAOF 命令的过程中,Redis 需要创建当前服务器进程的 子进程,而大多数操作系统在创建使用子进程时都会采用 写时复制 的技术,此时父进程如果对哈希表进行了扩展操作,则会触发子进程对父进程的内存拷贝操作。所以在子进程存在的时候,服务器会提高执行扩展操作所需的负载因子,从而尽可能避免在子进程存在期间父进程进行扩展操作,避免不必要的内存写入动作,最大限度地节约内存
当哈希表的负载因子小于 0.1 时,程序自动开始对哈希表执行收缩操作
渐进式 rehash
rehash 的过程并不是一次性,集中式地完成的,而是分多次,渐进式地完成的
原因是,如果一次性完成,对于 ht[0] 中保存键值对较少的情况下还可以接受,因为一次性 rehash 也不会耗费太多时间;而如果 ht[0] 中保存很多键值对,几千万个,那么要一次性将这些键值对全部 rehash 到 ht[1] 中的话,庞大的计算量以及工作量可能会导致服务器在一段时间内停止服务
渐进式 rehash 的步骤为:
-
为 ht[1] 分配空间,让字典同时持有 ht[0] 和 ht[1]
-
将字典中的索引计数器变量 rehashidx 设置为 0,表示 rehash 工作正式开始
-
在 rehash 进行期间,在每次对字典执行添加,删除,查找或者更新操作时,程序除了会执行指定的操作外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] ,当 rehash 完成之后,程序将 rehashidx 的属性的值增一,准备 rehash 下一索引位置上的键值对
在这期间执行的查找 (删除,更新时也会涉及到查找) 操作会在两个哈希表上进行,先在 ht[0] 中进行查找,如果没找到,就继续到 ht[1] 中进行查找;
添加操作一律对 ht[1] 进行,保证 ht[0] 包含的键值对数量只会减少而不会增加,最终必定能变为空表 -
随着字典操作的不断执行,最终在某个时间点上,ht[0] 的所有键值对都会被 rehash 到 ht[1] 中,这时程序就会将 rehashidx 的值设为 -1,表示整个 rehash 操作已经完成