• 「Redis数据结构」哈希表(Dict)


    「Redis数据结构」哈希表(Dict)

    我们知道Redis是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。
    Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)

    一、概述

    哈希表中的每一个 key 都是独一无二的,程序可以根据 key 查找到与之关联的 value,或者通过 key 来更新 value,又或者根据 key 来删除整个 key-value等等。

    哈希表优点在于,它能以 O(1) 的复杂度快速查询数据。怎么做到的呢?将 key 通过 Hash 函数的计算,就能定位数据在表中的位置,因为哈希表实际上是数组,所以可以通过索引值快速查询到数据。

    但是存在的风险也是有,在哈希表大小固定的情况下,随着数据不断增多,那么哈希冲突的可能性也会越高。

    解决哈希冲突的方式,有很多种。

    Redis 采用了「链式哈希」来解决哈希冲突,在不扩容哈希表的前提下,将具有相同哈希值的数据串起来,形成链接起,以便这些数据在表中仍然可以被查询到。

    接下来,详细说说哈希表。


    二、结构

    Redis 的哈希表结构如下:

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

    可以看到,哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。

    image-20221116001114253

    哈希表节点的结构如下:

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

    dictEntry 结构里不仅包含指向键和值的指针,还包含了指向下一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对链接起来,以此来解决哈希冲突的问题,这就是链式哈希

    另外,这里还跟你提一下,dictEntry 结构里键值对中的值是一个「联合体 v」定义的,因此,键值对中的值可以是一个指向实际值的指针,或者是一个无符号的 64 位整数或有符号的 64 位整数或double 类的值。这么做的好处是可以节省内存空间,因为当「值」是整数或浮点数时,就可以将值的数据内嵌在 dictEntry 结构里,无需再用一个指针指向实际的值,从而节省了内存空间。


    三、哈希冲突

    哈希表实际上是一个数组,数组里多每一个元素就是一个哈希桶。

    当一个键值对的键经过 Hash 函数计算后得到哈希值,再将(哈希值 % 哈希表大小)取模计算,得到的结果值就是该 key-value 对应的数组元素位置,也就是第几个哈希桶。

    什么是哈希冲突呢?

    举个例子,有一个可以存放 8 个哈希桶的哈希表。key1 经过哈希函数计算后,再将「哈希值 % 8 」进行取模计算,结果值为 1,那么就对应哈希桶 1,类似的,key9 和 key10 分别对应哈希桶 1 和桶 6。

    img

    此时,key1 和 key9 对应到了相同的哈希桶中,这就发生了哈希冲突。

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


    四、链式哈希

    Redis 采用了「链式哈希」的方法来解决哈希冲突。

    链式哈希是怎么实现的?

    实现的方式就是每个哈希表节点都有一个 next 指针,用于指向下一个哈希表节点,因此多个哈希表节点可以用 next 指针构成一个单项链表,被分配到同一个哈希桶上的多个节点可以用这个单项链表连接起来,这样就解决了哈希冲突。

    还是用前面的哈希冲突例子,key1 和 key9 经过哈希计算后,都落在同一个哈希桶,链式哈希的话,key1 就会通过 next 指针指向 key9,形成一个单向链表。

    img

    不过,链式哈希局限性也很明显,随着链表长度的增加,在查询这一位置上的数据的耗时就会增加,毕竟链表的查询的时间复杂度是 O(n)

    要想解决这一问题,就需要进行 rehash,也就是对哈希表的大小进行扩展

    接下来,看看 Redis 是如何实现的 rehash 的。


    五、rehash

    不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程称为rehash。过程是这样的:

    • 计算新hash表的realeSize,值取决于当前要做的是扩容还是收缩:

      • 如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1的2^n
      • 如果是收缩,则新size为第一个大于等于dict.ht[0].used的2^n (不得小于4)
    • 按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]

    • 设置dict.rehashidx = 0,标示开始rehash

    • 将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1]

    • 将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存

    • 将rehashidx赋值为-1,代表rehash结束

    • 在rehash过程中,新增操作,则直接写入ht[1],查询、修改和删除则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只减不增,随着rehash最终为空

    整个过程可以描述成:

    image-20221118152647373

    rehash 触发条件

    介绍了 rehash 那么多,还没说什么时情况下会触发 rehash 操作呢?

    rehash 的触发条件跟**负载因子(load factor)**有关系。

    负载因子可以通过下面这个公式计算:

    img

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

    • 当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。
    • 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 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 」就会变成空表


    七、总结

    Dict的结构

    • 类似java的HashTable,底层是数组加链表来解决哈希冲突
    • Dict包含两个哈希表,ht[0]平常用,ht[1]用来rehash

    Dict的伸缩

    • 当LoadFactor大于5或者LoadFactor大于1并且没有子进程任务时,Dict扩容
    • 当LoadFactor小于0.1时,Dict收缩
    • 扩容大小为第一个大于等于used + 1的2^n
    • 收缩大小为第一个大于等于used 的2^n
    • Dict采用渐进式rehash,每次访问Dict时执行一次rehash
    • rehash时ht[0]只减不增,新增操作只在ht[1]执行,其它操作在两个哈希表

    参考

    黑马程序员

    小林coding

  • 相关阅读:
    3D RPG Course | Core 学习日记三:Navigation智能导航地图烘焙
    论如何在使用RedisStandaloneConfiguration时让JedisConnectionFactory用上JedisPoolConfig
    核心配置文件的说明
    Python list列表修改元素
    【光学】Matlab模拟等倾干涉仿真
    十五、修改VGG16网络来适应自己的需求
    【前端】[vue3] [uni-app] 组件样式击穿:deep
    RK3568驱动指南|第六篇-平台总线-第55章 初识设备树
    js题解(三)
    Java基础 | Stream流原理与用法总结
  • 原文地址:https://blog.csdn.net/u014571143/article/details/127923838