Redis数据结构四之字典和哈希表

本文首发于公众号:Hunter后端

原文链接:Redis数据结构四之字典和哈希表

字典在 Redis 中应用相当广泛,在介绍字典之前,先来介绍一下字典、哈希表、哈希表节点的几个概念。

Redis 中,字典是用哈希表及额外的一些属性实现的,而哈希表则由多个哈希表节点构成,我们引用基础命令笔记中介绍的哈希命令中的一个例子来做介绍,一个哈希结构如下:

{
    "student":
        {
            "name": "Hunter",
            "number": "00001",
            "rank": 1
        }
}

其中,student: {} 的底层就是一个哈希表

student 下的 {"name": "Hunter"}{"number": "00001"}{"rank": 1} 就是组成哈希表的各个哈希表节点

对于 Redis,哈希表和哈希表节点两个数据结构就可以实现我们对 key-value 数据的操作,但是随着哈希表保存的键值对增加或者减少,我们就需要对哈希表进行扩展或者收缩操作,因此就额外引入了字典结构来做一些额外的辅助操作,具体的实现我们接下来介绍。

以下是本篇笔记目录:

  1. 哈希表
  2. 哈希表节点
  3. 字典
  4. 哈希算法和键冲突
  5. rehash
  6. 渐进式 rehash

1、哈希表

哈希表结构如下:

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 算法,有兴趣的可以去了解一下。

2、哈希表节点

哈希表节点的结构如下:

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 就是指向的就是 nameval 指向的是 Hunter

next 属性则是指向另一个哈希表节点的指针,因为不同的键在经过哈希函数处理之后得到的值在跟 sizemask 进行与操作之后得到的索引位置可能相同,所以哈希表节点通过链地址法来解决键冲突的问题。

3、字典

字典的结构如下:

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。

4、哈希算法和键冲突

如何将一个新的键值对插入到哈希表中,这个过程我们在介绍哈希表的属性的时候就已经模拟过这个流程了。

当多个键值对通过哈希算法被分配到哈希表的同一个索引上,我们就称这些键发生了冲突。

为了解决冲突,前面在介绍哈希表节点的结构的时候,介绍了哈希表节点有一个 next 属性,指向的下一个哈希表节点,因此哈希表节点就用使用 next 属性将不同的哈希表节点连接起来。

因为哈希表节点组成的链表没有指向链表表尾的指针,所以为了避免遍历节点到链表尾部,程序会将新节点添加到链表的表头位置,排在其他节点的前面。

5、rehash

前面介绍过,当哈希表保存的键值对数量太多或者太少,会需要对哈希表的大小进行相应的扩展或者收缩。

用于衡量哈希表的大小与键值对的数量之间的关系有一个计算关系,称为哈希表的负载因子(load factor)。

负载因子的计算公式如下:

load_factor = ht[0].used / ht[0].size

即哈希表已保存的节点数量 / 哈希表大小。

1. 哈希表的扩展与收缩

当以下条件任意一个被满足时,程序自动开始对哈希表进行扩展操作:

  • 负载因子大于等于1,且服务器没有在执行 BGSAVE 或者 BGREWRITEAOF 命令时
  • 负载因子大于等于5,且服务器正在执行 BGSAVE 或者 BGREWRITEAOF 命令时

BGSAVE 和 BGREWRITEAOF 是 Redis 进行持久化操作的两个后台执行的命令。

当哈希表的负载因子小于 0.1 时,程序自动开始对哈希表执行收缩操作。

2. rehash 步骤

接下来介绍一下 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 作准备。

6、渐进式 rehash

上面的 rehash 的操作并不是一次性、集中式完成的,而是分多次、渐进式完成的。

因为当 ht[0] 的键值对数量小的时候可以瞬间完成,但是当键值对数量很大,比如几百万,几千万个键值对,一次性进行迁移操作可能导致 Redis 一段时间内停止服务。

因此 rehash 的操作是分多次、渐进式地将 ht[0] 里面的键值对慢慢地 rehash 到 ht[1]。

以下是其详细步骤:

  1. 为 ht[1] 分配空间,让字典同时持有 ht[0] 和 ht[1] 两个哈希表
  2. 在字典中维持索引计数器变量 rehashidx,将其设置为 0,表示 rehash 工作正式开始
  3. 在 rehash 期间,每次对字典进行添加、删除、查找、或者更新操作时,程序除了执行指定的操作,还会顺带将 ht[0] 上对应索引位置上的哈希表节点都 rehash 到 ht[1]
  4. 随着字典操作的不断执行,在某个时间点,ht[0] 的所有键值对都会被 rehash 到 ht[1],这时候 rehashidx 属性的值设为 -1,表示 rehash 操作已经完成

在进行渐进式 rehash 的过程中,哈希表节点,也就是键值对会在两个哈希表中分布,所以字典的删除、查找、更新操作会在两个哈希表上进行。

比如如果在字典查找一个键,在 ht[0] 里没有查找到,那么会继续在 ht[1] 里查找。

另外,在渐进式 rehash 期间,新添加到字典的键值对一律会被保存到 ht[1] 里面,ht[0] 则不再进行任何添加操作,这个措施保证了 ht[0] 包含的键值对数量只减不增,并随着 rehash 操作的执行最终变成空表。

你可能感兴趣的:(Redis笔记,redis,散列表,哈希表,字典,渐进式rehash)