前言

本文参考源码版本 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 的删除损耗,如果损耗很小,则直接删除,反之,再采用惰性删除




相关参考:
Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐