redis的hash的冲突和扩容问题


写在前面

redis hash的冲突和扩容问题在面试过程中经常遇到,本文就来总结一下。

数据结构

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

typedef struct dict{
    dictType *type; //直线dictType结构,dictType结构中包含自定义的函数,这些函数使得key和value能够存储任何类型的数据
    void *privdata; //私有数据,保存着dictType结构中函数的 参数
    dictht ht[2]; //两张哈希表
    long rehashidx; //rehash的标记,rehashidx == -1,表示没有进行 rehash
    int itreators;  //正在迭代的迭代器数量
  }dict;
  
typedef struct dictht {

    // 哈希表数组
    dictEntry **table;

    // 哈希表大小
    unsigned long size;

    // 哈希表大小掩码,用于计算索引值
    // 总是等于 size - 1
    unsigned long sizemask;

    // 该哈希表已有节点的数量
    unsigned long used;

} dictht;


typedef struct dictEntry {

    // 键
    void *key;

    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;

    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;

} dictEntry;

hash冲突解决办法

当出现 Hash 冲突时,Redis 使用的是 链地址法 来解决冲突,链地址法就是将冲突的节点构成一个链表放在该索引位置上,Redis 采用的是头插法

rehash

随着不断的操作,hash 表中的键值对可能会增多或减少,为了让哈希表的负载因子保持在一个范围内,需要对 hash 表进行扩容或收缩,收缩和扩容的过程就叫 rehash

  • 为字典的 ht[1] 哈希表分配空间, 这个哈希表的空间大小取决于要执行的操作, 以及 ht[0] 当前包含的键值对数量 (也即是 ht[0].used 属性的值)(ht 是字典中的 hash 表,上文有介绍):
    • 如果执行的是扩展操作, 那么 ht[1] 的大小为第一个大于等于 ht[0].used * 2 的 2^n (2 的 n 次方幂);
    • 如果执行的是收缩操作, 那么 ht[1] 的大小为第一个大于等于 ht[0].used 的 2^n 。
  • 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面: rehash 指的是重新计算键的哈希值和索引值, 然后将键值对放置到 ht[1] 哈希表的指定位置上。
  • 当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后 (ht[0] 变为空表), 释放 ht[0] , 将 ht[1] 设置为 ht[0] , 并在 ht[1] 新创建一个空白哈希表, 为下一次 rehash 做准备

渐进式 rehash

rehash 时会将 ht[0] 所有的键值对迁移到 ht[1] 中,但这个动作不是一次性的,而是分多次、渐进式地完成。这样的所得原因时:当数据量大的时候一次性迁移会造成服务器在一段时间内定制服务。为了避免发生这样的事就出现了 渐进式 rehash。

以下是哈希表渐进式 rehash 的详细步骤:

  • 为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
  • 在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
  • 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] , 当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
  • 随着字典操作的不断执行, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 ,将ht[1]赋值到ht[0]上,清空ht[1]。 至此 rehash 操作已完成。

渐进式 rehash 的好处在于它采取分而治之的方式, 将 rehash 键值对所需的计算工作均滩到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式 rehash 而带来的庞大计算量。在 rehash 期间,字典的删改查操作都是同时作用在 ht[0] 以及 ht[1] 上的。如查找一个键,会现在 ht[0] 查找,找不到就去 ht[1] 查找,注意的是增加操作,新增的键值对只会保存到 ht[1]上,不会保存到 ht[0] 上,这一措施保证了 ht[0] 的键值只减不增,随着 rehash 操作 ht[0] 最终会变成空表。

rehash 触发条件

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

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

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

参考

[1] 小林coding-哈希表

[2] 超详细 Redis 数据结构底层实现原理介绍



文章作者: Alex
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Alex !
  目录