华为技术专家深度解析 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 表示
在该结构体中包含了指向共享对象的指针,这其中就包括了 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 个)时,仍使用主线程进行内存释放的考虑因素。
版权声明: 本文为 InfoQ 作者【JavaEdge】的原创文章。
原文链接:【http://xie.infoq.cn/article/52f7078a50ce4f7fa1aa5b555】。文章转载请联系作者。
评论