写在前面
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 数据结构底层实现原理介绍