• Redis(05)| 数据结构-哈希表


    哈希表是一种保存键值对(key-value)的数据结构。
    哈希表中的每一个 key 都是独一无二的,程序可以根据 key 查找到与之关联的 value,或者通过 key 来更新 value,又或者根据 key 来删除整个 key-value等等。
    在讲压缩列表的时候,提到过 Redis 的 Hash 对象的底层实现之一是压缩列表(最新 Redis 代码已将压缩列表替换成 listpack)。Hash 对象的另外一个底层实现就是哈希表。
    哈希表优点在于,它能以 O(1) 的复杂度快速查询数据。怎么做到的呢?将 key 通过 Hash 函数的计算,就能定位数据在表中的位置,因为哈希表实际上是数组,所以可以通过索引值快速查询到数据。
    但是存在的风险也是有,在哈希表大小固定的情况下,随着数据不断增多,那么哈希冲突的可能性也会越高。
    解决哈希冲突的方式,有很多种。
    Redis 采用了「链式哈希」来解决哈希冲突,在不扩容哈希表的前提下,将具有相同哈希值的数据串起来,形成链接起,以便这些数据在表中仍然可以被查询到。
    接下来,详细说说哈希表。

    哈希表结构设计

    Redis 的哈希表结构如下:

    typedef struct dictht{
        //哈希表数组
        dictEntry **table;
        //哈希表大小
        unsignedlong size;
        //哈希表大小掩码,用于计算索引值
        unsignedlong sizemask;
        //该哈希表已有的节点数量
        unsignedlong used;
    } dictht;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    可以看到,哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。
    在这里插入图片描述

    哈希表节点的结构如下:

    typedef struct dictEntry{
        //键值对中的键
        void*key;
        //键值对中的值
        union{
            void*val;
            uint64_t u64;
            int64_t s64;
            double d;
        } v;
        //指向下一个哈希表节点,形成链表
        structdictEntry*next;
    } dictEntry;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    dictEntry 结构里不仅包含指向键和值的指针,还包含了指向下一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对链接起来,以此来解决哈希冲突的问题,这就是链式哈希。
    另外,这里还跟你提一下,dictEntry 结构里键值对中的值是一个「联合体 v」定义的,因此,键值对中的值可以是一个指向实际值的指针,或者是一个无符号的 64 位整数或有符号的 64 位整数或double 类的值。这么做的好处是可以节省内存空间,因为当「值」是整数或浮点数时,就可以将值的数据内嵌在 dictEntry 结构里,无需再用一个指针指向实际的值,从而节省了内存空间。

    哈希冲突

    哈希表实际上是一个数组,数组里多每一个元素就是一个哈希桶。
    当一个键值对的键经过 Hash 函数计算后得到哈希值,再将(哈希值 % 哈希表大小)取模计算,得到的结果值就是该 key-value 对应的数组元素位置,也就是第几个哈希桶。
    什么是哈希冲突呢?
    举个例子,有一个可以存放 8 个哈希桶的哈希表。key1 经过哈希函数计算后,再将「哈希值 % 8 」进行取模计算,结果值为 1,那么就对应哈希桶 1,类似的,key9 和 key10 分别对应哈希桶 1 和桶 6。

    在这里插入图片描述

    此时,key1 和 key9 对应到了相同的哈希桶中,这就发生了哈希冲突。
    因此,当有两个以上数量的 kay 被分配到了哈希表中同一个哈希桶上时,此时称这些 key 发生了冲突。

    链式哈希

    Redis 采用了「链式哈希」的方法来解决哈希冲突。
    链式哈希是怎么实现的?
    实现的方式就是每个哈希表节点都有一个 next 指针,用于指向下一个哈希表节点,因此多个哈希表节点可以用 next 指针构成一个单项链表,被分配到同一个哈希桶上的多个节点可以用这个单项链表连接起来,这样就解决了哈希冲突。
    还是用前面的哈希冲突例子,key1 和 key9 经过哈希计算后,都落在同一个哈希桶,链式哈希的话,key1 就会通过 next 指针指向 key9,形成一个单向链表。

    在这里插入图片描述

    不过,链式哈希局限性也很明显,随着链表长度的增加,在查询这一位置上的数据的耗时就会增加,毕竟链表的查询的时间复杂度是 O(n)。
    要想解决这一问题,就需要进行 rehash,也就是对哈希表的大小进行扩展。
    接下来,看看 Redis 是如何实现的 rehash 的。

    rehash

    哈希表结构设计的这一小节,我给大家介绍了 Redis 使用 dictht 结构体表示哈希表。不过,在实际使用哈希表时,Redis 定义一个 dict 结构体,这个结构体里定义了两个哈希表(ht[2])。

    typedef struct dict{//两个Hash表,交替使用,用于rehash操作
        dictht ht[2];} dict;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    之所以定义了 2 个哈希表,是因为进行 rehash 的时候,需要用上 2 个哈希表了。

    在这里插入图片描述

    在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。
    随着数据逐步增多,触发了 rehash 操作,这个过程分为三步:

    • 给「哈希表 2」 分配空间,一般会比「哈希表 1」 大 2 倍;
    • 将「哈希表 1 」的数据迁移到「哈希表 2」 中;
    • 迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备。
      为了方便你理解,我把 rehash 这三个过程画在了下面这张图:
      在这里插入图片描述

    这个过程看起来简单,但是其实第二步很有问题,如果「哈希表 1 」的数据量非常大,那么在迁移至「哈希表 2 」的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。

    渐进式 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 」就会变成空表。

    rehash 触发条件

    介绍了 rehash 那么多,还没说什么时情况下会触发 rehash 操作呢?
    rehash 的触发条件跟**负载因子(load factor)**有关系。
    负载因子可以通过下面这个公式计算:
    在这里插入图片描述

    触发 rehash 操作的条件,主要有两个:

    • 当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。
    • 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。
  • 相关阅读:
    2022面试相关 - react相关原理
    sql存储引擎
    什么是泛型,泛型的具体使用?
    计数类dp,完全背包,900. 整数划分
    与git相关错误的究极解决方案
    phar反序列化学习
    Shiro 550、721
    部分聚合平台“真自营 假聚合”?专家:扰乱市场公平秩序
    java8 新特性2-接口中的默认方法和静态方法
    阿里笔试——北京阿里笔试题总结
  • 原文地址:https://blog.csdn.net/Allen202/article/details/134084607