写点什么

华为技术专家深度解析 Redis 惰性删除原理

作者:JavaEdge
  • 2021 年 12 月 26 日
  • 本文字数:4026 字

    阅读完需:约 13 分钟

华为技术专家深度解析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 负责执行数据淘汰,筛选出被淘汰的键值对后,就要开始删除被淘汰的数据:

  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 就会开始执行删除。

  1. performEvictions 根据 server 是否启用了惰性删除,分别执行:

  • Case1:若 server 启用惰性删除,performEvictions 调用 dbAsyncDelete 异步删除

  • Case2:若 server 未启用惰性删除,performEvictions 调用 dbSyncDelete 同步删除

performEvictions 在调用删除函数前,都会调用 zmalloc_used_memory 计算当前使用内存量。然后,它在调用删除函数后,会再次调用 zmalloc_used_memory 函数计算此时的内存使用量,并计算删除操作导致的内存使用量差值,这个差值就是通过删除操作而被释放的内存量。

performEvictions 最后把这部分释放的内存量和已释放内存量相加,得到最新内存释放量:


所以 performEvictions 在选定被删除的 KV 对后,可通过异步或同步操作来完成数据的实际删除。那数据异步删除和同步删除到底如何执行的?

3 数据删除操作

删除操作包含两步:

  1. 将被淘汰的 KV 对从哈希表剔除,这哈希表既可能是设置了过期 key 的哈希表,也可能是全局哈希表

  2. 释放被淘汰 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 执行:

  1. 调用 dictDelete


  2. 调用 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 对的删除开销后:

  1. 把删除开销和宏定义 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 实现。

  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 类型的 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 个)时,仍使用主线程进行内存释放的考虑因素。

发布于: 1 小时前
用户头像

JavaEdge

关注

正在征服世界的 Javaer。 2019.09.25 加入

曾就职于百度、携程、华为等大厂,阿里云开发者社区专家博主、腾讯云+社区2019、2020年度最佳作者、慕课网认证作者、CSDN博客专家,简书优秀创作者兼《程序员》专题管理员,牛客网著有《Java源码面试解析指南》。

评论

发布
暂无评论
华为技术专家深度解析Redis惰性删除原理