[Redis]扩容篇
字典扩容
Java 中的 HashMap 有扩容的概念,当 LoadFactor 达到阈值时,需要重新分配一个新的 2 倍大小的数组,然后将所有的元素全部 rehash 挂到新的数组下面。 rehash就是将元素的 hash 值对数组长度进行取模运算,因为长度变了,所以每个元素挂接的槽位可能也发生了变化。又因为数组的长度是2的n次方,所以取模运算等价于位与操作。

这里的7 15 31 称为字典的 mask 值, mask 的作用就是保留 hash 值的低位,高位都被设置为 0
接下来我们看看 rehash 前后元素槽位的变化。
如图 1-37 所示,假设当前的字典的数组长度由 8 位扩窑到 16 位,那么 3 号槽 011 将会被 rehash 3 号槽位和 11 号槽位,也就是说该槽位链表中大约有一半的元素还是 3 号槽位,其他的元素会放到 11 号槽位, 11 这个数字的二进制是 1011 ,就是对 3 的二进制 011 增加了一个高位 1。

抽象一点说,假设开始槽位的二进制数是 xxx ,那么该槽位中的元素将被 rehash 0xxx 1xxx 中。如果字典长度由 16 位扩窑到 32 位,那么对于二进制槽 xxxx 中的元素将被 rehash 0xxxx,1xxxx中。
对比扩容缩容前后的遍历顺序
仔细观察图 38 ,我们会发现采用高位进位加法的遍历顺序, rehash 后的槽位在遍历顺序上是相邻的。

假设当前要遍历 110 这个位置(橙色),那么扩容后,当前槽位上所有的元素对应的新槽位是 0110 和 1110(深绿色),也就是在槽位的二进制数增加一个高位0或1。这时我们可以直接从 0110这个槽位开始往后继续遍历,0110槽位之前的所有槽位都是已经遍历过的,这样就可以避免扩容后对已经遍历过的槽位进行重复遍历。
再考虑缩容,假设当前即将遍历 110这个位置(橙色),那么缩容后,当前位所有的元素对应的新槽位是 10(深绿色),也就是去掉槽位二进制最高位。这时我们可以直接从 10 这个槽位继续往后遍历,10槽位之前的所有槽位都是已经遍历过的,这样就可以避免缩容的重复遍历。不过缩容还是不太一样,它会对图中 010这个槽位上的元素进行重复遍历,因为缩融后 10槽位的元素是 010和110上挂接的元素的融合。
渐进式 rehash
Java 的 HashMap 在扩容时会一次性将旧数组下挂接的元素全部转移到新数组面。如果 HashMap 中元素特别多,线程就会出现卡顿现象。Redis 为了解决这个问题采用“渐进式 rehash”,它会同时保留旧数组和新数组,然后在定时任务中以及后续对 hash 的指令操作中渐渐地将旧数组中挂接的元素迁移到新数组上。这意味着要操作处于rehash中的字典,需要同时访问新旧两个数组结构。如果在旧数组下面找不到元素,还需要去新数组下面寻找。
scan 也需要考虑这个问题,对于rehash 中的字典,它需要同时扫描新旧槽位然后将结果融合后返回给客户端。
扩容条件
/*Expand the hash table if needed */
static int _dictExpandIfNeeded(dict *d)
/*Incremental rehashing already in progress.Return.*/
if (dictIsRehashing(d)) return DICT OK;
/* If the hash table is empty expand it to the initial size. */
if(d->ht[0].size ==0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
/* If we reached the 1:1 ratio, and we are allowed to resize the hash table(global setting)
or we should avoid it but the ratio between elements/buckets is overthe “safe” threshold,
we resize doubling the number of buckets.*/
if(d->ht[0].used >= d->ht[0].size &&(dict_can_resize || d->ht[0].used/d->ht[0].size >dict_force_resize_ratio))
{
return dictExpand(d,d->ht[0].used*2);
}
return DICT_OK;
正常情况下,当 hash 表中元素的个数等于第 1 维数组的长度时,就会开始扩容,扩容的新数组是原数组大小的 2 倍。不过如果 redis 正在做 bgsave, 为了减少内存页的过多分离(CopyOnWrit时,redis尽量不去扩容(dict_can_resize),bgsave 的时候如果进行写操作会产生很多的分离页,占用不必要的内存,而且后面还要用改动页去替换原始页但是如果 hash 表已经非常满了,元素的个数已经达到了第一维数组长度的 5 倍(dict_force_resize_ratio),说明 hash 表已经过于拥挤了,这个时候就会强制扩容。
缩容条件
int htNeedsResize(dict*dict)
{
long long size,used;
size = dictslots(dict);
used = dictSize(dict);
return (size>DICT_HT_INITIAL_SIZE && (used*100 / Size < HASHTABLE_MIN_FILL))
}
当 hash 表因为元素逐渐被删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用。
缩容的条件是元素个数低于数组长度的10%。缩容不会考虑 Redis 是否正在做 bgsave。
原因
扩容原因:当hashtable存储的元素过多,可能由于碰撞也过多,导致其中某链表很长,最后致使查找和插入时间复杂度很大。因此当元素超多一定的时候就需要扩容。
缩容原因:当元素数量比较少的时候就需要缩容以节约不必要的内存。为了让哈希表的负载因子(load factor)维持在一个合理的范围内,会使用rehash(重新散列)操作对哈希表进行相应的扩展或收缩。
负载因子的计算公式:哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
扩容条件(满足任意一个即可)
- Redis服务器目前没有在执行BGSAVE或BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
- Redis服务器目前在执行BGSAVE或BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。
为什么BGSAVE或BGREWRITEAOF命令是否在执行,Redis服务器哈希表执行扩容所需的负载因子不相同(1或5)?
BGSAVE:用于在后台异步保存当前数据库的数据到磁盘。
BGREWRITEAOF:用于异步执行一个 AOF( Append Only File ) 文件重写操作。
因为当执行BGSAVE或BGREWRITEAOF命令过程中,Redis需要创建服务器进程的子进程,操作系统采用的是COW,即 写时复制 copy-on-write的技术来优化子进程的使用效率。所以在子进程存在时,服务器会提高执行扩容所需的负载因子,从而尽可能避免在子进程存在期间进行扩容,可以避免将中间状态写入内存。
PS:COW
以下是 COW 的基本工作原理:
Redis在持久化时会调用glibc的函数fork产生一个子进程,快照持久化完全交给子进程来处理,父进程继续处理客户端请求。子进程刚刚产生时,它和父进程共享内存里面的代码段和数据段。这时你可以把父子进程想象成一个连体婴儿,它们在共享身体。这是Linux操作系统的机制,为了节约内存资源,所以尽可能让它们共享起来。在进程分离的瞬间,内存的增长几乎没有明显变化。
fork函数会在父子进程同时返回,在父进程里返回子进程的pid,在子进程里返回零。如果操作系统的内存资源不足pid就会是负数,表示fork失败。
子进程做数据持久化,不会修改现有的内存数据结构,它只是对数据结构进行遍历读取,然后序列化写到碰盘中。但是父进程不一样,它必须持续服务客户端请求,然后对内存数据结构进行不间断的修改。
这个时候就会使用操作系统的cow机制来进行数据段页面的分离。数据段是由很多操作系统的页面组合而成,当父进程对其中一个页面的数据进行修改时,会将被共享的页面复制一份分离出来,然后对这个复制的页面进行修改。这时子进程相应的页面是没有变化的,还是进程产生时那一瞬间的数据。
随着父进程修改操作的持续进行,越来越多的共享页面被分离出来,内存就会持续增长,但是也不会超过原有数据内存的2倍大小。另外,Redis实例里冷数据占的比例往往是比较高的,所以很少会出现所有的页面都被分离的情况,被分离的往往只有其中一部分页面。每个页面的大小只有4KB,一个Redis实例里面一般都会有成千上万个页面子进程因为数据没有变化,它能看到的内存里的数据在进程产生的瞬间就凝固了,再也不会改变,这也是为什Redis的持久化叫"快照"的原因。接下来子进程就可以非常安心地遍历数据,进行序列化写磁盘了。
rehash
对字典的哈希表rehash步骤
- 为ht[1]分配空间:
扩容操作:ht[1] 的大小为第一个大于等于ht[0].used*2的2的n次幂
收缩操作:ht[1] 的大小为第一个大于等于ht[0].used 的2的n次幂 - 元素转移
将ht[0]中的数据转移到ht[1]中,在转移的过程中,重新计算键的哈希值和索引值,然后将键值对放置到ht[1]的指定位置。 - 释放h[0]
当ht[0]的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),将ht[0]释放,然后将ht[1]设置成ht[0],最后为ht[1]分配一个空白哈希表
渐进式rehash
为什么要进行渐进式rehash?
在元素数量较少时,rehash会非常快的进行,但是当元素数量达到几百万、甚至几个亿时进行rehash将会是一个非常耗时的操作。如果一次性将成万上亿的元素的键值对rehash到ht[1],庞大的计算量可能会导致服务器在一段时间内停止服务,这是非常危险的!所以,rehash这个动作不能一次性、集中式的完成,而是分多次、渐进式地完成。
渐进式rehash步骤
- 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
- 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。
- 在rehash进行期间,每次对字典执行CRUD:添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx+1(表示下次将rehash下一个桶)。
- 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为
-1,表示rehash完成。
渐进式rehash的好处:在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash 而带来的庞大计算量。
在迁移过程中,会不会造成读少数据?
不会,因为在迁移时,首先会从ht[0]读取数据,如果ht[0]读不到,则会去ht[1]读。
在迁移过程中,新增加的数据会存放在哪个ht?
迁移过程中,新增的数据只会存在ht[1]中,而不会存放到ht[0],ht[0]只会减少不会新增。
codis 扩容
字符串扩容
在字符串长度小于 1MB 之前,扩容空间采用加倍策略,也就是保留 100%的冗余空间。当字符串长度超过 1MB 之后,为了避免加倍后的冗余空间过大而导致浪费,每次扩容只会多分配 1MB 大小的冗余空间。

浙公网安备 33010602011771号