Redis底层数据结构之hash

​hash​​是日常开发过程中使用​​Redis​​的一个数据结构,其底层实现方式有两种,如下所示。一种是​​zipList​​,这种是当​​hash​​结构的​​V​​值较小的时候使用的编码方式。这个已经在​​上一篇文章​​中介绍过了。这篇文章主要讲解一下另外一种实现方式,字典​​dict​​,当​​hash​​结构的​​V​​值较大时采用的编码方式。

1|0  dict

这里又要开始鞭尸​​C​​语言了,字典​​dict​​作为一种常用的数据结构,​​C​​语言内部并不具备,因而​​Redis​​的开发人员自己设计和开发了​​Redis​​中的​​dict​​结构,其定义如下:



typedf struct dict{ dictType *type;//类型特定函数,包括一些自定义函数,这些函数使得key和 //value能够存储 void *private;//私有数据 dictht ht[2];//两张hash表 int rehashidx;//rehash索引,字典没有进行rehash时,此值为-1 unsigned long iterators; //正在迭代的迭代器数量 }dict;


  • ​type​​和​​private​​这两个属性是为了实现字典多态而设置的,当字典中存放着不同类型的值,对应的一些复制,比较函数也不一样,这两个属性配合起来可以实现多态的方法调用;
  • ​ht[2]​​,两个​​hash​​表
  • ​rehashidx​​,这是一个辅助变量,用于记录​​rehash​​过程的进度,以及是否正在进行​​rehash​​等信息,当此值为-1时,表示该​​dict​​此时没有​​rehash​​过程
  • ​iterators​​,记录此时​​dict​​有几个迭代器正在进行遍历过程

1|1  dictht

由上面可以看出,​​dict​​本质上是对哈希表​​dictht​​的一个简单封装,​​dictht​​的定义如下所示:



typedf struct dictht{ dictEntry **table;//存储数据的数组 二维 unsigned long size;//数组的大小 unsigned long sizemask;//哈希表的大小的掩码,用于计算索引值,总是等于 //size-1 unsigned long used;//// 哈希表中中元素个数 }dictht;


​**table​​是一个​​dictEntry​​类型的数组,用于真正存储数据;​​size​​表示​​**table​​这个数组的大小;​​sizemask​​用于计算索引位置,且总是等于​​size-1​​;​​used​​表示​​dictht​​中已有的节点数量,其示意图如下所示:

redis之hash解析_redis

1|2  dictEntry

上面分析​​dictht​​时说到,真正存储数据的结构是​​dictEntry​​数组,其结构定义如下:



typedf struct dictEntry{ void *key;//键 union{ void val; unit64_t u64; int64_t s64; double d; }v;//值 struct dictEntry *next;//指向下一个节点的指针 }dictEntry;


其示意图如下所示:

redis之hash解析_数据_02

最后整个​​dict​​的结构示意图如下所示:

上图是一个没有处于​​rehash​​状态下的字典​​dict​​,整个​​dict​​中有两个哈希表​​dictht​​,其中一个哈希表存储数据,另一个哈希表为空。

2|0  扩容与缩容

当哈希表中元素数量逐渐增加时,此时产生​​hash冲突​​的概率逐渐增大,且由于​​dict​​也是采用拉链法解决​​hash冲突​​的,随着​​hash冲突​​概率上升,链表会越来越长,这就会导致查找效率下降。相反,当元素不断减少时,元素占用​​dict​​的空间就越少,出于对内存的极致利用,此时就需要进行缩容操作。

既然说到扩容和缩容,熟悉​​Java​​集合的小伙伴是不是想到了什么。不错,那就是负载因子负载因子一般用于描述集合当前被填充的程度。在​​Redis​​的字典​​dict​​中,负责因子=哈希表中已保存节点数量/哈希表的大小,即:



load factor = ht[0].used / ht[0].size


​Redis​​中,三条关于扩容和缩容的规则:

  • 没有执行BGSAVE和BGREWRITEAOF指令的情况下,哈希表的负载因子大于等于1时进行扩容;
  • 正在执行BGSAVE和BGREWRITEAOF指令的情况下,哈希表的负载因大于等于5时进行扩容;
  • 负载因子小于0.1时,​​Redis​​自动开始对哈希表进行收缩操作;

其中,扩容和缩容的数量大小也有一定的规则:

  • 扩容:扩容后的dictEntry数组数量为第一个大于等于​ht[0].used * 2​​2^n​​;
  • 缩容:缩容后的dictEntry数组数量为第一个大于等于​ht[0].used​​2^n​​;

3|0  rehash

与​​Java​​中的​​HashMap​​类似,当​​Redis​​中的​​dict​​进行扩容或者缩容,会发生​​reHash​​过程。​​Java​​中​​HashMap​​的​​rehash​​过程如下:新建一个哈希表,一次性将当前所有节点进行​​rehash​​然后复制到新哈希表相应的位置上,之后释放掉原有的​​hash​​表,而持有新的表,这个过程是一个时间复杂度为​​O(n)​​的操作。而对于单线程的​​Redis​​而言很难承受这么高时间复杂度的操作,因而其​​rehash​​的过程有所不同,使用的是一种称之为渐进式rehash的方式,一点一点地进行搬迁。其过程如下:

  • 假设当前数据在​​dictht[0]​​中,那么首先为​​dictht[1]​​分配足够的空间,如果是扩容,则​​dictht[1]​​大小就按照扩容规则设置;如果是缩减,则​​dictht[1]​​大小就按照缩减规则进行设置;
  • 在字典​​dict​​中维护一个变量,rehashidx=0,表示​​rehash​​正式开始;
  • ​rehash​​进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将​​dictht[0]​​哈希表在​​rehashidx​​索引上的所有键值对​​rehash​​到​​dictht[1]​​,当一次​​rehash​​工作完成之后,程序将​​rehashidx​​属性的值+1。同时在​​serverCron​​中调用​​rehash​​相关函数,在1​​ms​​的时间内,进行​​rehash​​处理,每次仅处理少量的转移任务(100个元素);
  • 随着字典操作的不断执行,最终在某个时间点上,​​dictht[0]​​的所有键值对都会被​​rehash​​至​​dictht[1]​​,这时程序将​​rehashidx​​属性的值设为-1,表示rehash操作已完成;

上述就是​​Redis​​中​​dict​​的渐进式rehash过程,但在这个过程会存在两个明显问题。第一,第三步说了,每次对字典执行增删改查时才会触发​​rehash​​过程,万一某一时间段并没有任何请求命令呢?此时应该怎么办?第二,在维护两个​​dictht​​的时候,此时哈希表如何正常对外提供服务?

​Redis​​的设计人员在设计时就已经考虑到了这两个问题。对于第一个问题,​​Redis​​在有一个定时器,会定时去判断rehash是否完成,如果没有完成,则继续进行​​rehash​​。定时函数如下所示:



// 服务器定时任务 void databaseCron() { ... if (server.activerehashing) { for (j = 0; j < dbs_per_call; j++) { int work_done = incrementallyRehash(rehash_db);//rehash方法 if (work_done) { /* If the function did some work, stop here, we'll do * more at the next cron loop. */ break; } else { /* If this db didn't need rehash, we'll try the next one. */ rehash_db++; rehash_db %= server.dbnum; } } } }


对于第二个问题,对于添加操作,会将新的数据直接添加到​​dictht[1]​​上面,这样就可以保证​​dictht[0]​​上的数量只减少不增加。而对于删除、更改、查询操作,会直接在​​dictht[0]​​上进行,尤其是这三个操作,都会涉及到查询,当在dictht[0]上查询不到时,会接着去​dictht[1]​上查找,如果再找不到,则表明不存在该​​K-V​​值。

3|1  渐进式rehash的优缺点

优点:采用了分而治之的思想,将 ​rehash 操作分散到每一个对该哈希表的操作上以及定时函数上,避免了集中式​​rehash​​ 带来的性能压力;

缺点:在 rehash 的时间内,需要保存两个 hash 表,对内存的占用稍大,而且如果在 redis 服务器本来内存满了的时候,突然进行 rehash 会造成大量的 key 被抛弃

4|0  思考题

为什么扩容的时候要考虑​​BIGSAVE​​的影响,而缩容时不需要?

  • ​BIGSAVE​​时,​​dict​​要是进行扩容,则此时就需要为​​dictht[1]​​分配内存,若是​​dictht[0]​​的数据量很大时,就会占用更多系统内存,造成内存页过多分离,所以为了避免系统耗费更多的开销去回收内存,此时最好不要进行扩容;
  • 缩容时,结合缩容的条件,此时负载因子<0.1,说明此时​​dict​​中数据很少,就算为​​dictht[1]​​分配内存,也消耗不了多少资源;

5|0  总结

redis之hash解析_redis_03