Rehash 执行过程
字典的 rehash 操作实际上就是执行以下任务:
- 创建一个比 ht[0]->table 更大的 ht[1]->table ;
- 将 ht[0]->table 中的所有键值对迁移到 ht[1]->table ;
- 将原有 ht[0] 的数据清空,并将 ht[1] 替换为新的 ht[0] ;
经过以上步骤之后, 程序就在不改变原有键值对数据的基础上, 增大了哈希表的大小。
dict的rehash 本质就是扩容,就是将数组+链表结构中的数组扩容;
这个过程,需要开辟一个更大空间的数组,将老数组中每个非空索引的bucket,搬运到新数组;搬运完成后再释放老数组的空间。
作为例子, 以下四个小节展示了一次对哈希表进行 rehash 的完整过程。
1: 开始 rehash
这个阶段有两个事情要做:
- 设置字典的 rehashidx 为 0 ,标识着 rehash 的开始;
- 为 ht[1]->table 分配空间,大小至少为 ht[0]->used 的两倍;
这时的字典是这个样子:
2: Rehash 进行中
在这个阶段, ht[0]->table 的节点会被逐渐迁移到 ht[1]->table , 因为 rehash 是分多次进行的(细节在下一节解释), 字典的 rehashidx 变量会记录 rehash 进行到 ht[0] 的哪个索引位置上。
注意除了节点的移动外, 字典的 rehashidx 、 ht[0]->used 和 ht[1]->used 三个属性也产生了变化。
3: 节点迁移完
到了这个阶段,所有的节点都已经从 ht[0] 迁移到 ht[1] 了:
4: Rehash 完毕
在 rehash 的最后阶段,程序会执行以下工作:
- 释放 ht[0] 的空间;
- 用 ht[1] 来代替 ht[0] ,使原来的 ht[1] 成为新的 ht[0] ;
- 创建一个新的空哈希表,并将它设置为 ht[1] ;
- 将字典的 rehashidx 属性设置为 -1 ,标识 rehash 已停止;
以下是字典 rehash 完毕之后的样子:
incremental rehashing 增量/渐进式rehash
在前面我们已经了解了字典的 rehash 过程, 需要特别指出的是,rehash 并不是在触发之后,马上就执行直到完成, 而是分多次、渐进式地完成的
rehash会产生的问题
- rehash的过程,会使用两个哈希表,创建了一个更大空间的ht[1],此时会造成内存陡增;
- rehash的过程,可能涉及大量KV键值对dictEntry的搬运,耗时较长;
如果这个 rehash 过程必须将所有键值对迁移完毕之后才将结果返回给用户, 这样的处理方式将不满足Redis高效响应的特性。
rehash会产生的问题
- 主要层面就是内存占用陡增、和处理耗时长的问题
- 基于这两点,还会带来其他影响。
为了解决这些问题, Redis 使用了incremental rehashing,是一种 增量/渐进式的 rehash 方式: 通过将 rehash 分散到多个步骤中进行, 从而避免了集中式的计算/节点迁移。
dictAdd 添加键值对到dict,检查到需要进行rehash时,会将dict.rehashidx 设置为 0 ,标识着 rehash 的开始;
后续请求,在执行add、delete、find操作时,都会判断dict是否正在rehash,如果是,就执行_dictRehashStep()函数,进行增量rehash。
每次执行 _dictRehashStep , 会将ht[0]->table 哈希表第一个不为空的索引上的所有节点就会全部迁移到 ht[1]->table 。
也就是在某次dictAdd 添加键值对时,触发了rehash;后续add、delete、find命令在执行前都会检查
- 如果dict正在rehash,就先不急去执行自己的命令,先去帮忙搬运一个bucket;
- 搬运完一个bucket,再执行add、delete、find命令 原有处理逻辑。
实际上incremental rehashing增量/渐进式rehash,只解决了第二个:耗时长的问题,将集中式的节点迁移分摊到多步进行,ht[1]占用的双倍多内存,还一直占用。
下面我们通过dict的查找(dictFind)来看渐进式rehash过程;
dict的查找(dictFind)
dictEntry *dictFind(dict *d, const void *key)
{
dictEntry *he;
unsigned int h, idx, table;
if (d->ht[0].used + d->ht[1]