[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 大小的冗余空间。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?