目录
一、dict数据结构
二、Redis的rehash
2.1 redis中dict构成
2.2 为什么进行rehash
2.3 rehash触发条件
2.4 rehash时其它操作
三、渐进式rehash
一、dict数据结构
dict字典结构是一个key -> Value映射的数据结构,Redis的一个database中所有key到value的映射,就是使用一个dict来维护的。dict本质上是为了解决算法中的查找问题(Searching)。
dict本质上是为了解决算法中的查找问题(Searching)。一般查找问题的解法分为两个大类:一个是基于各种平衡树,一个是基于哈希表,平常使用的各种Map或dictionary,大都是基于哈希表实现的。dict也是一个基于哈希表的算法,跟java中的hashMap类似,用key计算出哈希值,并得到key在哈希表中的位置,再采用拉链法解决冲突,并在装载因子(load factor)超过预定值时自动扩展内存,引发重哈希(rehashing)。
二、Redis的rehash
2.1 redis中dict构成
#dict字典的数据结构 typedef struct dict{ dictType *type; //直线dictType结构,dictType结构中包含自定义的函数,这些函数使得key和value能够存储任何类型的数据 void *privdata; //私有数据,保存着dictType结构中函数的 参数 dictht ht[2]; //两张哈希表 long rehashidx; //rehash的标记,rehashidx=-1表示没有进行rehash,rehash时每迁移一个桶就对rehashidx加一 int itreators; //正在迭代的迭代器数量 } #dict结构中ht[0]、ht[1]哈希表的数据结构 typedef struct dictht{ dictEntry[] table; //存放一个数组的地址,数组中存放哈希节点dictEntry的地址 unsingned long size; //哈希表table的大小,出始大小为4 unsingned long sizemask; //用于将hash值映射到table位置的索引,大小为(size-1) unsingned long used; //记录哈希表已有节点(键值对)的数量 }
redis中dict结构和源码定义如上图所示,主要有以下几种特性:
- dict采用哈希函数对key取哈希值得到在哈希表中的位置(桶的位置),采用拉链法解决hash冲突。
- 两张哈希表(ht[2]):只有在重哈希的过程中,ht[0]和ht[1]才都有效。而在平常情况下,只有ht[0]有效,ht[1]里面没有任何数据。上图表示的就是重哈希进行到中间某一步时的情况。
- 重哈希:跟HashMap一样当装载因子(load factor)超过预定值时就会进行rehash。dict进行rehash扩容,将ht[0]上某一个bucket(即一个桶上dictEntry链表)上的每一个链表移动到扩容后的ht[1]上(每次只移动一个链表,即渐进式rehash。原因是为了防止redis长时间的堵塞导致不可用,减少对主线程的阻塞),触发rehash的操作有查询、插入和删除元素。rehashidx会记录每次需要移动链表bucket桶的位置(后面会详细讲解)。
- 当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后 (ht[0] 变为空表),释放 ht[0] , 将 ht[1] 设置为 ht[0] , 并在 ht[1] 新创建一个空白哈希表, 为下一次 rehash 做准备。
2.2 为什么进行rehash
在key进行哈希计算得到hash值时,可能不同的key会得到相同的hash值,从而出现hash冲突,redis采用链地址法,把数组中同一下index下的所有数据,通过链表的形式进行关联。而redis中查找key的过程为: 首先对key进行hash计算并对数组长度取模得到数据所在的桶,在该桶下遍历链表来查找key。此时查找key的复杂度就取决于链表的长度,如果链表的长度为n,那么复杂度就为o(n),n越大查询效率就会越低。当数据个数越多,哈希表的hash冲突的概率就会越高,导致链表长度越长,查询效率越低,所以要进行rehash。
2.3 rehash触发条件
rehash的触发条件主要跟哈希表的负载因子有关,负载因子的计算公式为:
load_factor = ht[0].used / ht[0].size
触发rehash主要有两种情况:一种是扩容触发,另一种是收缩触发。两种情况触发的条件不一样的,需要各自满足以下下条件才能导致rehash操作:
扩容时rehash:当以下条件中的任意一个被满足时, 程序会自动开始对哈希表执行扩容rehash操作
- 服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 1;
- 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 5
注意:根据 BGSAVE 命令或 BGREWRITEAOF 命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同,这是因为在执行 BGSAVE 命令或 BGREWRITEAOF命令的过程中, Redis会fork一个子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作, 最大限度地节约内存。
收缩时rehash:当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作
2.4 rehash时其它操作
- dict添加操作:如果正在重哈希中,会把数据插入到ht[1];否则插入到ht[0]。
- dict查询操作:先在第一个哈希表ht[0]上进行查找,再判断当前是否在重哈希,如果没有,那么在ht[0]上的查找结果就是最终结果。否则,在ht[1]上进行查找。查询时会先根据key计算出桶的位置,在到桶里的链表上寻找key。
- dict删除操作:判断当前是不是在重哈希过程中,如果是只在ht[0]中查找要删除的key;否则ht[0]和ht[1]它都要查找删除。
三、渐进式rehash
Java中的HashMap进行rehash是一次性完成的,而redis的扩展或收缩哈希表需要将 ht[0]里面的所有键值对 rehash 到 ht[1]里面,但是,这个 rehash 动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。这样是为了避免在哈希表里保存的键值对数量很大时, 一次性将这些键值对全部 rehash 到 ht[1] 的话,庞大的计算量(需要重新计算链表在桶中的位置)可能会导致服务器在一段时间内停止服务(redis是单线程的,如果全部移动会引起客户端长时间阻塞不可用)。
因此, 为了避免 rehash 对服务器性能造成影响, 服务器不是一次性将 ht[0]里面的所有键值对全部 rehash 到 ht[1], 而是分多次、渐进式地将 ht[0]里面的键值对慢慢地 rehash 到 ht[1]。以下是哈希表渐进式rehash的详细步骤:
- 为ht[1]分配空间,让dict字典同时持有 ht[0] 和 ht[1] 两个哈希表。
- 在字典中维持一个索引计数器变量rehashidx,初始时值为-1,代表没有rehash操作,当rehash工作正式开始,会将它的值设置为0。
- 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在 rehashidx索引(table[rehashidx]桶上的链表)上的所有键值对rehash到ht[1]上,当rehash工作完成之后,将rehashidx属性的值+1,表示下一次要迁移链表所在桶的位置。
- 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有桶对应的键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。
渐进式 rehash 的好处在于它采取分而治之的方式, 将 rehash 键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式 rehash 而带来的庞大计算量。