持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第20天,点击查看活动详情
Redis缓存淘汰是为在Redis server内存使用量超过阈值时,筛选一些冷数据,从Redis server删除。LRU、LFU在最后淘汰数据时,都会删除被淘汰数据。
但它们在删除淘汰数据时,会根据如下配置决定是否启用Lazy Free(惰性删除)
惰性删除,Redis4.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负责执行数据淘汰,筛选出被淘汰的KV对后,就要开始删除被淘汰的数据:
- 为被淘汰的key创建一个SDS对象,然后调用propagateExpire:
Redis server可能针对缓存淘汰场景启用惰性删除,propagateExpire会根据全局变量server.lazyfree_lazy_eviction决定删除操作对应命令:
-
lazyfree_lazy_eviction=1(启用缓存淘汰时的惰性删除),则删除操作对应UNLINK命令
-
否则,就是DEL命令
因为这些命令经常使用,所以Redis为这些命令创建共享对象,即sharedObjectsStruct结构体,并用一个全局变量shared表示
在该结构体中包含指向共享对象的指针,这其中就包括:
- unlink
- 和del命令对象
然后,propagateExpire在为删除操作创建命令对象时,使用shared变量中的unlink或del对象:
接着,propagateExpire判断Redis server是否启用AOF日志。
若启用,则propagateExpire调用feedAppendOnlyFile,把被淘汰key的删除操作记录到AOF文件,保证后续使用AOF文件进行Redis数据库恢复时,和恢复前保持一致。
然后,propagateExpire调用propagate,把删除操作同步给从节点,以保证主从节点的数据一致。propagate流程:
接下来,performEvictions就会开始执行删除。
performEvictions根据server是否启用惰性删除,分别执行:
- Case1:server启用惰性删除,调用dbAsyncDelete异步删除
- Case2:server未启用惰性删除,调用dbSyncDelete同步删除
performEvictions调用删除函数前,都调用zmalloc_used_memory计算当前使用内存量。
调用删除函数后,再次调用zmalloc_used_memory计算此时内存使用量
计算删除操作导致的内存使用量差值,即通过删除操作而被释放的内存量。
performEvictions最后把这部分释放的内存量和已释放内存量相加,得到最新内存释放量:
所以performEvictions在选定被删除的KV对后,可通过异步或同步操作来完成数据实际删除。
3 数据删除操作
删除操作两步走:
- 将被淘汰的KV对从哈希表剔除,这哈希表既可能是设置了过期K的哈希表,也可能是全局哈希表
- 释放被淘汰KV对所占用的内存空间
- 俩操作一起做,即同步删除
- 只做1,而2由后台线程执行,即异步删除
Redis使用dictGenericDelete实现这俩操作。
3.1 dictGenericDelete
dictGenericDelete先在哈希表查找要删除的key。
它会计算被删除K的哈希值,然后根据哈希值找到K所在哈希桶。
因为不同K的哈希值可能相同,而Redis哈希表采用链式哈希,所以即使我们根据一个K的哈希值,定位到它所在的哈希桶,仍需在此哈希桶比对查找,这个K是否真存在。
也正是由于这个原因,dictGenericDelete函数紧接着就会在哈希桶中,进一步比对查找要删除的key。如果找到了,它就先把这个key从哈希表中去除,也就是把这个key从哈希桶的链表中去除。
然后,dictGenericDelete根据入参nofree,决定是否实际释放K和V的内存空间:
dictGenericDelete根据nofree决定执行同步or异步删除。
3.2 dictDelete V.S dictUnlink
给dictGenericDelete传递的nofree参数值是0 or 1:
-
nofree=0,同步删除
-
nofree=1,异步删除
performEvictions函数在删除KV对时,调用的dbAsyncDelete和dbSyncDelete两个函数如何使用dictDelete和dictUnlink实际删除被淘汰数据的。
4 基于异步删除的数据淘汰
dbAsyncDelete执行:
-
调用dictDelete
-
调用dictUnlink:
被淘汰的KV对只是在全局哈希表中被移除,其占用内存空间还没有实际释放。所以dbAsyncDelete调用lazyfreeGetFreeEffort,计算释放被淘汰KV对内存空间的开销:
lazyfreeGetFreeEffort
- 开销较小,dbAsyncDelete直接在主I/O线程中同步删除
- 开销较大,dbAsyncDelete创建惰性删除任务,并交给后台线程完成
所以,虽然dbAsyncDelete名义上是执行惰性删除,但实际执行过程,还是会使用lazyfreeGetFreeEffort评估删除开销。lazyfreeGetFreeEffort根据 要删除的KV对的类型 计算删除开销:
- 属List、Hash、Set和Sorted Set的一种,且未使用紧凑型内存结构,则该KV对的删除开销=集合中的元素个数
- 否则,删除开销=1
案例
如下代码展示azyfreeGetFreeEffort计算List、Set类型KV对的删除开销:KV对Set类型,同时使用哈希表结构而非整数集合来保存数据,则删除开销就是Set中元素个数。
通过lazyfreeGetFreeEffort计得被淘汰KV对的删除开销后:把删除开销和宏定义LAZYFREE_THRESHOLD(默认64)对比:
-
被淘汰KV对为包含>64个元素的集合类型
dbAsyncDelete才调用bioCreateBackgroundJob创建后台任务,执行惰性删除
-
被淘汰KV对不是集合类型或是集合类型但包含元素个数≤64个
dbAsyncDelete调用dictFreeUnlinkedEntry释放KV对所占内存空间。
dbAsyncDelete使用后台任务或主I/O线程释放内存空间:
主线程如何知道后台线程释放的内存空间,已满足待释放空间的大小?performEvictions在调用dbAsyncDelete或dbSyncDelete前后,都会统计已使用内存量,并计算调用删除函数前后的差值,即知晓已释放的内存空间大小。
此外,performEvictions调用dbAsyncDelete后,再主动检测当前内存使用量,是否已满足最大内存容量要求。一旦满足,performEvictions就会停止淘汰数据的执行流程。
后台线程删除淘汰数据过程,主线程仍可处理外部请求?
可以。主线程决定淘汰这 key 后,会先把这 key 从「全局哈希表」剔除,然后评估释放内存代价,如符合条件,则丢到「后台线程」执行「释放内存」操作。
之后就可继续处理客户端请求,尽管后台线程还未完成释放内存,但因 key 已被全局哈希表剔除,所以主线程已查询不到该 key,对客户端无影响。
5 同步删除的数据淘汰
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类型的K
-
多种数据类型的V
所以,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释放内存空间。
6 总结
Redis4.0后提供惰性删除功能,所以Redis缓存淘汰数据时,就会根据是否启用惰性删除,决定是执行同步 or 异步惰性删除。
同步删除还是异步的惰性删除,都会先把被淘汰的KV对从哈希表中移除。然后:
- 同步删除就会紧接着调用dictFreeKey、dictFreeVal和zfree分别释放key、value和键值对哈希项的内存空间
- 异步的惰性删除,则是把空间释放任务交给了后台线程完成
虽惰性删除是由后台线程异步完成,但后台线程启动后会监听惰性删除的任务队列,一旦有惰性删除任务,后台线程就会执行并释放内存空间。所以,从淘汰数据释放内存空间的角度来说,惰性删除并不影响缓存淘汰时的空间释放要求。
后台线程需通过同步机制获取任务,这过程会引入一些额外时间开销,会导致内存释放不像同步删除那样非常及时。这也是Redis在被淘汰数据是小集合(元素不超过64个)时,仍使用主线程进行内存释放的考虑因素。