华为技术专家深度解析Redis惰性删除原理
Lazy Free会影响缓存替换吗?
Redis缓存淘汰是为了在Redis server内存使用量超过阈值时,筛选一些冷数据,从Redis server中删除。我们在前两节课,LRU和LFU在最后淘汰数据时,都会删除被淘汰数据。
但它们在删除淘汰数据时,会根据如下配置项决定是否启用Lazy Free(惰性删除)
惰性删除,Redis 4.0后功能,使用后台线程执行删除数据的任务,避免了删除操作阻塞主线程。但后台线程异步删除数据能及时释放内存吗?它会影响到Redis缓存的正常使用吗?
1 配置惰性删除
当Redis server希望启动惰性删除时,需在redis.conf设置惰性删除相关配置项,包括如下场景:
默认值都是no。所以,要在缓存淘汰时启用,就要将lazyfree-lazy-eviction置yes。Redis server在启动过程中进行配置参数初始化时,会根据redis.conf,设置全局变量server的lazyfree_lazy_eviction成员变量。
若看到对server.lazyfree_lazy_eviction变量值进行条件判断,那就是Redis根据lazyfree-lazy-eviction配置项,决定是否执行惰性删除。
2 被淘汰数据的删除过程
getMaxmemoryState负责执行数据淘汰,筛选出被淘汰的键值对后,就要开始删除被淘汰的数据:
-
为被淘汰的key创建一个SDS对象,然后调用propagateExpire:
Redis server可能针对缓存淘汰场景启用惰性删除,propagateExpire会根据全局变量server.lazyfree_lazy_eviction决定删除操作对应命令:
-
lazyfree_lazy_eviction=1(启用缓存淘汰时的惰性删除),则删除操作对应UNLINK命令
-
否则,就是DEL命令
因为这些命令经常使用,所以Redis为这些命令创建共享对象,即sharedObjectsStruct结构体,并用一个全局变量shared表示
![image-20211225010625978](/Users/apple/Library/Application Support/typora-user-images/image-20211225010625978.png)
![image-20211225011045661](/Users/apple/Library/Application Support/typora-user-images/image-20211225011045661.png)
在该结构体中包含了指向共享对象的指针,这其中就包括了unlink和del命令对象。
然后,propagateExpire在为删除操作创建命令对象时,就使用了shared变量中的unlink或del对象:
接着,propagateExpire会判断Redis server是否启用AOF日志:
- 若启用,则propagateExpire会调用feedAppendOnlyFile,把被淘汰key的删除操作记录到AOF文件,保证后续使用AOF文件进行Redis数据库恢复时,可以和恢复前保持一致。通过实现。
- 然后,propagateExpire调用propagate,把删除操作同步给从节点,以保证主从节点的数据一致。propagate流程:
接下来,performEvictions就会开始执行删除。
- performEvictions根据server是否启用了惰性删除,分别执行:
- Case1:若server启用惰性删除,performEvictions调用dbAsyncDelete异步删除
- Case2:若server未启用惰性删除,performEvictions调用dbSyncDelete同步删除
performEvictions在调用删除函数前,都会调用zmalloc_used_memory计算当前使用内存量。然后,它在调用删除函数后,会再次调用zmalloc_used_memory函数计算此时的内存使用量,并计算删除操作导致的内存使用量差值,这个差值就是通过删除操作而被释放的内存量。
performEvictions最后把这部分释放的内存量和已释放内存量相加,得到最新内存释放量:
所以performEvictions在选定被删除的KV对后,可通过异步或同步操作来完成数据的实际删除。那数据异步删除和同步删除到底如何执行的?
3 数据删除操作
删除操作包含两步:
- 将被淘汰的KV对从哈希表剔除,这哈希表既可能是设置了过期key的哈希表,也可能是全局哈希表
- 释放被淘汰KV对所占用的内存空间
若这俩操作一起做,就是同步删除;只做1,而2由后台线程执行,就是异步删除。
Redis使用dictGenericDelete实现了这俩操作。
**首先,dictGenericDelete函数会先在哈希表中查找要删除的key。**它会计算被删除key的哈希值,然后根据哈希值找到key所在的哈希桶。
因为不同key的哈希值可能相同,而Redis的哈希表是采用了链式哈希(你可以回顾下第3讲中介绍的链式哈希),所以即使我们根据一个key的哈希值,定位到了它所在的哈希桶,我们也仍然需要在这个哈希桶中去比对查找,这个key是否真的存在。
也正是由于这个原因,dictGenericDelete函数紧接着就会在哈希桶中,进一步比对查找要删除的key。如果找到了,它就先把这个key从哈希表中去除,也就是把这个key从哈希桶的链表中去除。
然后,dictGenericDelete会根据入参nofree,决定是否实际释放K和V的内存空间:
dictGenericDelete根据nofree决定执行同步or异步删除。
dictDelete V.S dictUnlink
给dictGenericDelete传递的nofree参数值是0 or 1:
-
nofree=0:同步删除
-
nofree=1,异步删除
好了,到这里,我们就了解了同步删除和异步删除的基本代码实现。下面我们就再来看下,在刚才介绍的performEvictions函数中,它在删除键值对时,所调用的dbAsyncDelete和dbSyncDelete这两个函数,是如何使用dictDelete和dictUnlink来实际删除被淘汰数据的。
基于异步删除的数据淘汰
由dbAsyncDelete执行:
-
调用dictDelete
-
调用dictUnlink:
被淘汰的KV对只是在全局哈希表中被移除,其占用内存空间还是没有实际释放。所以dbAsyncDelete会调用lazyfreeGetFreeEffort,计算释放被淘汰KV对内存空间的开销:
lazyfreeGetFreeEffort
- 若开销较小,dbAsyncDelete直接在主I/O线程中进行同步删除
- 否则,dbAsyncDelete创建惰性删除任务,并交给后台线程完成
虽dbAsyncDelete说是执行惰性删除,但在实际执行过程中,会使用lazyfreeGetFreeEffort评估删除开销。
lazyfreeGetFreeEffort根据要删除的KV对的类型计算删除开销:
- 若KV对类型属于List、Hash、Set和Sorted Set这四种集合类型中的一种,且未使用紧凑型内存结构,则该KV对的删除开销就等于集合中的元素个数
- 否则,删除开销等于1
举个例子,如下代码就展示了azyfreeGetFreeEffort计算List和Set类型键值对的删除开销:KV对是Set类型,同时它是使用哈希表结构而不是整数集合来保存数据的话,那么它的删除开销就是Set中的元素个数。
这样,当dbAsyncDelete通过lazyfreeGetFreeEffort计得被淘汰KV对的删除开销后:
-
把删除开销和宏定义LAZYFREE_THRESHOLD(默认64)比较。
当被淘汰KV对为包含超过64个元素的集合类型时,dbAsyncDelete才会调用bioCreateBackgroundJob实际创建后台任务执行惰性删除。
若被淘汰KV对不是集合类型或是集合类型但包含元素个数≤64个,则dbAsyncDelete调用dictFreeUnlinkedEntry释放KV对所占的内存空间。
dbAsyncDelete使用后台任务或主IO线程释放内存空间:
基于异步删除的数据淘汰过程,实际上会根据要删除的KV对包含的元素个数,决定是使用后台线程还是主线程执行删除操作。
- 主线程如何知道后台线程释放的内存空间,已满足待释放空间的大小?performEvictions在调用dbAsyncDelete或dbSyncDelete前后,都会统计已使用内存量,并计算调用删除函数前后的差值,这就能知晓已释放的内存空间大小。
此外,performEvictions在调用dbAsyncDelete后,会再主动检测当前内存使用量,是否已满足最大内存容量要求。一旦满足,performEvictions就会停止淘汰数据的执行流程。
使用后台线程删除被淘汰数据过程中,主线程是否仍可处理外部请求?
可以。主线程决定淘汰这个 key 后,会先把这个 key 从「全局哈希表」剔除,然后评估释放内存代价,如符合条件,则丢到「后台线程」执行「释放内存」操作。
之后就可继续处理客户端请求,尽管后台线程还未完成释放内存,但因 key 已被全局哈希表剔除,所以主线程已查询不到该 key,对客户端无影响。
同步删除的数据淘汰
dbSyncDelete实现。
- 首先,调用dictDelete,在过期key的哈希表中删除被淘汰的KV对
- 再次调用dictDelete,在全局哈希表中删除被淘汰的KV对
dictDelete调用dictGenericDelete同步释放KV对的内存空间时,最终分别调用dictFreeKey、dictFreeVal和zfree释放K、V和KV对所对应的哈希项这三者所占内存空间。
它们根据操作的哈希表类型,调用相应valDestructor和keyDestructor函数释放内存:
为方便能找到最终进行内存释放操作的函数,以全局哈希表为例,看当操作全局哈希表时,KV对的dictFreeVal和dictFreeKey两个宏定义对应的函数。
全局哈希表是在initServer中创建:
dbDictType是个dictType类型结构体:
dbDictType作为全局哈希表,保存:
-
SDS类型的key
-
多种数据类型的value
所以,dbDictType类型哈希表的K和V释放函数,分别是dictSdsDestructor和dictObjectDestructor:
dictSdsDestructor直接调用sdsfree,释放SDS字符串占用的内存空间。dictObjectDestructor会调用decrRefCount,执行释放操作:
decrRefCount会判断待释放对象的引用计数。:
- 只有当引用计数为1,才会根据待释放对象的类型,调用具体类型的释放函数来释放内存空间
- 否则,decrRefCount只是把待释放对象的引用计数减1
若待释放对象的引用计数为1,且String类型,则decrRefCount调用freeStringObject执行最终的内存释放操作。
若对象是List类型,则decrRefCount调用freeListObject最终释放内存:
基于同步删除的数据淘汰过程,就是通过dictDelete将被淘汰KV对从全局哈希表移除,并通过dictFreeKey、dictFreeVal和zfree释放内存空间。
释放v空间的函数是decrRefCount,根据V的引用计数和类型,最终调用不同数据类型的释放函数来完成内存空间释放。
基于异步删除的数据淘汰,它通过后台线程执行的函数是lazyfreeFreeObjectFromBioThread函数,该函数实际上也是调用了decrRefCount释放内存空间。
总结
Redis 4.0后提供惰性删除功能,所以Redis缓存淘汰数据时,就会根据是否启用惰性删除,决定是执行同步删除还是异步的惰性删除。
同步删除还是异步的惰性删除,都会先把被淘汰的KV对从哈希表中移除。然后,同步删除就会紧接着调用dictFreeKey、dictFreeVal和zfree分别释放key、value和键值对哈希项的内存空间。而异步的惰性删除,则是把空间释放任务交给了后台线程完成。
虽惰性删除是由后台线程异步完成,但后台线程启动后会监听惰性删除的任务队列,一旦有惰性删除任务,后台线程就会执行并释放内存空间。所以,从淘汰数据释放内存空间的角度来说,惰性删除并不影响缓存淘汰时的空间释放要求。
后台线程需通过同步机制获取任务,这过程会引入一些额外时间开销,会导致内存释放不像同步删除那样非常及时。这也是Redis在被淘汰数据是小集合(元素不超过64个)时,仍使用主线程进行内存释放的考虑因素。
- 点赞
- 收藏
- 关注作者
评论(0)