Redis惰性删除源码解析

123 阅读9分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 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对后,就要开始删除被淘汰的数据:

  1. 为被淘汰的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 数据删除操作

删除操作两步走:

  1. 将被淘汰的KV对从哈希表剔除,这哈希表既可能是设置了过期K的哈希表,也可能是全局哈希表
  2. 释放被淘汰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执行:

  1. 调用dictDelete

  2. 调用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:

  1. 先调用dictDelete,在过期key的哈希表中删除被淘汰的KV对
  2. 再调用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个)时,仍使用主线程进行内存释放的考虑因素。