[Redis]-[底层数据结构]-字典

前言

字典,又称为符号表,关联数组或映射,是一种用于保存键值对的数据结构

字典的实现

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 时会使用

整个字典的结构如下:

在这里插入图片描述

哈希算法

当要添加一个新的键值对到字典里时:

  1. 根据键值对的键计算出哈希值,使用字典中的哈希函数进行计算
    hash = dict->type->hashFunction(key)
  2. 根据哈希值以及哈希表的大小掩码,进行相与运算,计算出索引值,
    index = hash & dict->ht[x].sizemask
  3. 再将键值对放到哈希表数组的指定索引上面

当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis 使用 MurmurHash2 算法来计算键的哈希值

rehash

为了使哈希表的负载因子维持在一个合理的范围之内,当哈希表保存的键值对太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩,通过执行 rehash 操作来完成:

  1. 为字典的 ht[1] 哈希表分配空间,哈希表的空间大小取决于要执行的操作以及当前 ht[0] 包含的键值对数量
    >>> 如果执行的是 扩展 操作,那么 ht[1] 的大小为 第一个大于等于 ht[0].used * 2 的 2 的 n 次方幂
    >>> 如果执行的是 收缩 操作,那么 ht[1] 的大小为 第一个大于等于 ht[0].used 的 2 的 n 次方幂
  2. 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面。rehash 即重新计算键的哈希值以及索引值,然后根据新的索引值放置到 ht[1] 哈希表的相应位置上
  3. 当 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 的步骤为:

  1. 为 ht[1] 分配空间,让字典同时持有 ht[0] 和 ht[1]

  2. 将字典中的索引计数器变量 rehashidx 设置为 0,表示 rehash 工作正式开始

  3. 在 rehash 进行期间,在每次对字典执行添加,删除,查找或者更新操作时,程序除了会执行指定的操作外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] ,当 rehash 完成之后,程序将 rehashidx 的属性的值增一,准备 rehash 下一索引位置上的键值对

    在这期间执行的查找 (删除,更新时也会涉及到查找) 操作会在两个哈希表上进行,先在 ht[0] 中进行查找,如果没找到,就继续到 ht[1] 中进行查找;
    添加操作一律对 ht[1] 进行,保证 ht[0] 包含的键值对数量只会减少而不会增加,最终必定能变为空表

  4. 随着字典操作的不断执行,最终在某个时间点上,ht[0] 的所有键值对都会被 rehash 到 ht[1] 中,这时程序就会将 rehashidx 的值设为 -1,表示整个 rehash 操作已经完成

猜你喜欢

转载自blog.csdn.net/Pacifica_/article/details/125347305