redis 过期删除策略、惰性删除(lazy-free),深入剖析
如果一个键过期了,什么时候会被删除呢?又如何找出这些过期 key 并删除?会不会影响服务正常运行?redis 中有两种 key,一种带有 expire 过期时间,另一种则是不带过期时间,本文讨论的过期删除策略是针对带有 expire 过期时间的 key。为了方便找出带过期时间并且已经过期的 key,redis 用了额外的字典专门存储带 expire 过期时间的 key,这样一来,只需遍历该字典即可
文章目录
前言
本文参考源码版本
redis6.2
如果一个键过期了,什么时候会被删除呢?又如何找出这些过期 key 并删除?会不会影响服务正常运行?
redis 中有两种 key,一种带有 expire 过期时间,另一种则是不带过期时间,本文讨论的过期删除策略是针对带有 expire 过期时间的 key。
为了方便找出带过期时间并且已经过期的 key,redis 用了额外的字典专门存储带 expire 过期时间的 key,这样一来,只需遍历该字典即可找出过期的 key。
删除时机?
redis 提供两种删除策略:
请求时删除
:执行命令操作时,先检查下 key 是否已经过期,若已过期则删除,反之,继续操作。实现起来比较简单。定期清理
:请求时删除的补充,按一定的请求频率清理已过期的 key。
其中,针对定期清理 redis 提供了两种触发机制:
- 一种是低频次,频率由周期性 server.hz 决定。
- 另一种是高频次,频率与主事件循环有关,由 beforeSleep 方法触发。
不管哪种方式触发,都需要考虑以下问题:
- redis 的时间事件和用户请求(文件事件)是单线程处理,不能长时间阻塞
核心问题抛出来了 ------ 不能长时间阻塞
,主要涉及两类问题:
- 如果一次性过期很多 key,要一次性清理完?
- 如果是个大 key,要花很长时间才能删除怎么办?
我们可以采用以下两种方案分别应对:
少量、高频次
进行过期数据清理- 大 key 采用异步
惰性删除
策略,避免主线程阻塞
特别说明的是,在很多参考文档中用了 惰性过期
这个词,本文中为了避免与 laze_free
混淆,特意用了 请求时删除
这个词,你需要知道的是两者本质上是一个意思!
一、过期删除
1. 删除哪些?
过期 key 删除,肯定是针对那些设置了 expire 过期时间的 key,当到达了过期时间后,就会考虑直接删除掉,并释放内存。
redis DB 定义了两个字典结构:
- dict *dict:保存所有数据关系,全局字典。
- dict *expires:保存带有 expire 过期时间的 key 关系,当要检查过期数据时直接从该字典查找并删除。
虽然,定义了专门的 expires 字典耗费了内存空间,但对查询却更加友好,典型的空间换时间:
2. 触发删除
从触发删除时机来看,redis 主要有两种触发场景:
- 请求时触发:对所有请求命令执行之前都检查下 key 是否过期。
- 定期触发:定期的检查下是否有 key 过期,有的话就尝试删除。
如果你熟悉 redis 请求处理流程应该清楚,文件事件和时间事件是主线程轮询交替处理。换句话说,我们的 请求时清理
和 定期清理
都是 主线程
触发,甚至是清理:
2.1 请求时触发:
本质上这是一种 惰性
的思想,你主动发起请求了,我就顺便帮你检查下当前状态,如果没有请求,那么我就不做任何处理;站在系统的角度,它需要客户端触发,然后系统被动
的接受指令。
当然,这是最简单的实现方式,我相信,你应该在很多项目中见到过这种实现。
过期键的惰性删除策略由 db.c#expireIfNeeded
函数实现,所有读写数据库的 redis 命令在执行之前都会调用 expireIfNeeded 函数对输入键进行检查:
- 如果输入键已经过期,那么 expireIfNeeded 函数将输入键从数据库中删除。
- 如果输入键未过期,那么 expireIfNeeded 函数不做动作。
这种操作基本完全能保证业务逻辑上的正确性,如果你没有其他硬件层面的顾虑的话,一般做到这就差不多了。
但是,redis 不一样,寸土寸金的内存空间,既然已经过期,就得释放占用的内存;另外,在扫描字典的时候也能更节省算力。
2.2 定期触发:
定期触发
删除可以看作是 请求时触发
的一种补充,也是应对那种长时间不访问的 key 的高效手段。
redis 提供了两种删除策略用于定期删除:
-
ACTIVE_EXPIRE_CYCLE_FAST:快处理。主事件循环中,由 server.c#beforeSleep 触发
-
ACTIVE_EXPIRE_CYCLE_SLOW:慢处理。由时间事件控制进行 key 删除(server.c#serverCron),处理频率和 server.hz 有关,频率越快,两次执行间隔越短。
从执行效果上来看,两者本质区别是:
- xxx_FAST:
高频次
,每次处理的工作更少,遵循小步快跑原则。 - xxx_SLOW:
低频次
,每次处理更多的工作。
我们知道,redis 默认有 16 个 DB,过期删除时尝试轮询每个 DB 的 expire 字典结果,有合适删除的直接删除掉。同时,还会全局记录当前处理到了哪个 DB,方便后续从当前位置开始。
另外,为避免主线程长时间阻塞,提供了两个主要条件限制:
- timelimit:
时间限制
。不管是快删除还是慢删除,都会计算一个合理的删除时间上限,避免删除时间过长,这是全局限制(所有 DB
) - config_cycle_acceptable_stale:
过期 key 占比限制
。计算当前 DB 过期 key 的比例 p = 采样中过期 key 数 / 采样数,当 p > config_cycle_acceptable_stale 则继续处理,反之退出当前 DB。可以通过配置参数active-expire-effort
影响该值。针对当前 DB 有效
beforeSleep 和 serverCron 都是触发定期删除的入口,负责实际删除的是 server.c#activeExpireCycle
函数,其工作模式可以总结如下:
- 函数每次运行时,都从一定数量的数据库中取出一定数量键进行检查(轮询字典表),并删除其中的过期键。
- 全局变量 current_db 会记录当前 activeExpireCycle 函数检查的进度,并在下一次 activeExpireCycle 函数调用时,接着上一次的进度进行处理。
- 随着 activeExpireCycle 函数的不断执行,服务器中的所有数据库都会被检查一遍,这时函数将 current_db 变量重置为 0,然后再次开始新一轮的检查工作。
值得说明的是,由于随机选择的特性,可能存在一些过期的 key 长时间未被清除,为了避免这种情况,在 redis 6.0 以及之后的版本,已经改成了顺序遍历
字典表的方式,同时会记录下标。
3. AOF、RDB和复制功能
我们知道,redis 有很多内部操作,比如持久化相关的 AOF / RDB 操作,以及专注于高可用的复制操作。
思考一下,当这些操作在进行中遇到过期的 key 时,该如何处理?
3.1 RDB:
1)生成 RDB:
在执行 SAVE 命令或者 BGSAVE 命令创建一个新的 RDB 文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的 RDB 文件中。
2)载入 RDB:
如果服务器以主节点模式运行,那么在载入 RDB 文件时,程序会对文件中保存的键进行检查,未过期的键会被载入到数据库中,而过期键则会被忽略,所以过期键对载入 RDB 文件的主节点不会造成影响。
如果服务器以从节点模式运行,那么在载入 RDB 文件时,文件中保存的所有键,不论是否过期,都会被载入到数据库中。
不过,因为主从节点在进行数据全同步
的时候,从服务器的数据库就会被清空,所以一般来讲,过期键对载入 RDB 文件的从服务器也不会造成影响。
简单来说,从节点需要与主节点保持数据完全一致,不能有自己的判断行为,一切写操作得来自于主节点。
3.2 AOF:
1)AOF 文件写入:
当服务器以 AOF 持久化模式运行时,如果数据库中的某个键已经过期,但它还没有被惰性删除或者定期删除,那么 AOF 文件不会因为这个过期键而产生任何影响。
当过期键被惰性删除或者定期删除之后,程序会向 AOF 文件追加(append)一条 DEL 命令,来显式地记录该键已被删除。
2)AOF 重写:
和生成 RDB 文件时类似,在执行 AOF 重写的过程中,程序会对数据库中的键进行检查,已过期的键不会被保存到重写后的 AOF 文件中。
3)AOF 载入:
我们知道,AOF 文件中存储的命令,通过 AOF 恢复数据一般是直接进行命令重放,前面我们提到,在命令执行之前都会检查过期时间,如果过期了就直接删除掉。也就是说,在 AOF 载入期间,遇到过期 key 也会直接删除掉。
另外,如果采用 RDB + AOF 混合持久化,我们的 AOF 文件前半部分存储 RDB 数据,后半部分存储 AOF 数据。
当载入 AOF 文件时,前半部分采用 RDB 载入方式,后半部分采用 AOF 载入方式,对过期 key 的处理方式也就是两者结合。
3.3 主从复制:
当服务器运行在复制模式下时,从节点的过期键删除动作由主节点控制:
- 主节点在删除一个过期键之后,会显式地向所有从节点发送一个DEL命令,告知从节点删除这个过期键。
- 从节点在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期的键一样来处理过期键。
- 从节点只有在接到主节点发来的 DEL 命令之后,才会删除过期键。
通过由主节点来控制从节点统一地删除过期键,可以保证主从节点数据的一致性,也正是因为这个原因,当一个过期键仍然存在于主节点的数据库时,这个过期键在从节点里的复制品也会继续存在。
4. 阻塞风险
不管是 请求时删除
还是 定期删除
都是由主线程来完成,这种方式风险是非常大的,一旦遇到大 key 或者一次性删除过多 key,将可能是秒
级别的阻塞,对动辄 1W+ 的 QPS 来说,影响相当恐怖!!!
4.1 风险点:
请求时删除策略:
- 一般情况下, 针对的是单 key 操作,如果 key-value 不大,直接删除即可
- 如果 value 是一个百万级的字典呢?
定期删除策略:
- 如果有太多过期的 key,一次性删除还是分批次删除?每个批次删除多少 key 合适?
- 大 key 如何删除?
总的来说,对于大批量的小 key 同时过期还比较好处理,分成多批次
处理,每一个批次处理一小部分 key。
对于一个大 key ,比如百万级的 hash 字典结构?就算一个批次只删除一条 key,也需要耗费相对长的时间,这期间用户请求将无法处理。
怎么解决?异步惰性删除
登场了 ~
4.2 惰性删除(lazy_free):
什么是惰性删除?这里惰性删除的意思是,将释放内存的操作交给后台线程异步的进行处理,也就意味着一个 key 真正意义上的删除,具有一定的延迟。
惰性,就体现在删除操作并没有真正执行,而是交给后台线程异步处理,可能比实际响应结果要晚一些才真正完成。
那删除的是啥?
从 DB 中删除过期 key 时,只是从 DB 字典中将关系
删除,内存没有真正释放,而是交给后台异步线程去删除。
之前系列文章我们分析过 redis 的后台线程,有兴趣可以点击详情 ,其中一个后台线程就是用于惰性删除:
主线程将待删除的 key 扔到 lazy_free 队列,并唤醒对应的后台线程,此时 bio_lazy_free 后台线程就从队列中取出对应的 key 进行内存清理。这也是一个典型的 生产者 - 消费者 模型。
二、再谈惰性删除
1. 配置化?
惰性删除是一种异步处理的思路,除了用在过期删除 key 的场景,思考下哪些地方还可以使用?
其实,只要是有删除 key 的场景,都可以考虑使用惰性删除,在 redis 中删除场景有:
- 用户提交指令删除
- 过期 key 自动删除
- 内存淘汰策略触发 key 删除
- 服务内自身删除策略
对应 redis.conf 配置:
lazyfree-lazy-eviction no
lazyfree-lazy-expire no
lazyfree-lazy-server-del no
replica-lazy-flush no
默认值都是 no,用途如下:
- lazyfree-lazy-eviction:当 redis 内存达到阈值 maxmemory 时,将执行内存淘汰
- lazyfree-lazy-expire:当设置了过期 key 的过期时间到了,将删除 key
- lazyfree-lazy-server-del:这种主要用户提交 del 删除指令
- replica-lazy-flush:主要用于复制过程中,全量同步的场景,从节点需要删除整个 db
另外,我们知道 redis 还提供了 unlink 指令,用于将特定的 key 从 DB 字典中“摘除”,但并不会真正释放内存。而正常的 del 指令会真正释放内存,为了更好的兼容,如果 del 指令也想仅“摘除”这个功能呢?
redis 提供了特殊配置:
lazyfree-lazy-user-del no
默认 no,当设置为 yes 时,本质来说和 unlink 指令的处理方式一致了。
2. 无法惰性处理?
总结来看,redis 提供多种配置来满足不同场景下 unlink 之后的内存释放问题。思考一下,是否所有 key 都需要通过惰性删除来处理?
答案是不需要,对于一些代价极小的 key,顺手就给清理了,这样可以有效、及时的清理内存。
redis 有自己的一套代价评估方法,也就是说,有了以上配置
,并且 redis 认为可以清理代价较大
,才会交给后台线程惰性处理;反之,顺手就给清理了。
总结
本文主要以 redis 过期删除为主线,讨论了:
- 过期删除的触发时机
- 对 AOF、RDB、replication 的影响
- 过期删除有哪些风险
- 最后引出惰性删除来解决相关问题
过期删除的两种触发时机:
- 请求时删除:实现简单、对 CPU 友好,长时间没有请求可能导致内存占用无法释放。
- 定期清理:请求时删除的补充,有一定 CPU 损耗,但能及时释放内存空间。
总的来说,处理方案中规中矩,在实际生产上你也可以借鉴此思路来处理你的业务。
问:redis 一个过期删除策略招式看起来有点多?
纵观 redis 过期策略迭代历史,这些招式要么是新增特性,要么是迭代改进,发展了多个版本才到了如今的面貌:
- 最开始只提供了
请求时删除
策略,后来发现该策略下,很多长时间未访问的过期 key 无法删除 - 接着引入
定期删除
策略的慢删除
方式,又出现 删除速度慢、卡顿 等问题。 - 继续引入 定期删除 策略的
快删除
方式,遵循小步快跑模式,效果还不错 - 为了达到过期删除的卡顿时长可控性,新增了一些限制,同时,将大 key 删除交给 lazy_free 后台线程去处理。
最后,值得再提下的是,不是说,我们选择了异步删除,redis 就一定会采用后台线程惰性的方式删除,在这之前,还需要评估下当前 key 的删除损耗,如果损耗很小,则直接删除,反之,再采用惰性删除
。
相关参考:
更多推荐
所有评论(0)