14- Redis 中的 哈希表 数据结构

哈希表是一种保存键值对(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 操作

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值