Redis 的内存淘汰机制,看这篇就够了。

为什么要有淘汰机制

Redis 缓存使用内存保存数据,避免了系统直接从后台数据库读取数据,提高了响应速度。由于缓存容量有限,当缓存容量到达上限,就需要删除部分数据挪出空间,这样新数据才可以添加进来。Redis 定义了「淘汰机制」用来解决内存被写满的问题。

缓存淘汰机制,也叫缓存替换机制,它需要解决两个问题:

  • 决定淘汰哪些数据;
  • 如何处理那些被淘汰的数据。

下面我们就开始讨论 Redis 中的内存淘汰策略。

这篇内容干货很多,希望你可以耐心看完。

Redis 的内存淘汰策略

在这里插入图片描述

本文我们基于 Redis 4.0.9 版本分析。

Redis 4.0 之前一共实现了 6 种内存淘汰策略,在 4.0 之后,又增加了 2 种策略。截止目前,Redis定义了「8种内存淘汰策略」用来处理 redis 内存满的情况:

  • noeviction: 不会淘汰任何数据,当使用的内存空间超过 maxmemory 值时,返回错误;
  • volatile-ttl:筛选设置了过期时间的键值对,越早过期的越先被删除;
  • volatile-random:筛选设置了过期时间的键值对,随机删除;
  • volatile-lru:使用 LRU 算法筛选设置了过期时间的键值对;
  • volatile-lfu:使用 LFU 算法选择设置了过期时间的键值对;
  • allkeys-random:在所有键值对中,随机选择并删除数据;
  • allkeys-lru:使用 LRU 算法在所有数据中进行筛选;
  • allkeys-lfu:,使用 LFU 算法在所有数据中进行筛选。

上面是内存不足的「淘汰策略」,还有一种是过期键的删除策略,两者是不同,不要搞混了。

下面我们解释下各个策略的含义。

1)、 noeviction 策略,也是 Redis 的默认策略,它要求 Redis 在使用的内存空间超过 maxmemory 值时,也不进行数据淘汰。一旦缓存被写满了,再有写请求来的时候,Redis 会直接返回错误。

我们实际项目中,一般不会使用这种策略。因为我们业务数据量通常会超过缓存容量的,而这个策略不淘汰数据,导致有些热点数据保存不到缓存中,失去了使用缓存的初衷。

2)、 我们再分析下 volatile-randomvolatile-ttlvolatile-lruvolatile-lfu 这四种淘汰策略。它们淘汰数据的时候,只会筛选设置了过期时间的键值对上。

比如,我们使用 EXPIRE 命令对一批键值对设置了过期时间,那么会有两种情况会对这些数据进行清理:

  • 一种是过期时间到期了,会被删除;
  • 一种是 Redis 的内存使用量达到了 maxmemory 阈值,Redis 会根据 volatile-randomvolatile-ttlvolatile-lruvolatile-lfu 这四种淘汰策略,具体的规则进行淘汰;这也就是说,如果一个键值对被删除策略选中了,即使它的过期时间还没到,也需要被删除。

3)、 allkeys-randomallkeys-lruallkeys-lfu 这三种策略跟上述四种策略的区别是:淘汰时数据筛选的数据范围是所有键值对

我们按照是否会进行数据淘汰,以及根据淘汰数据集的筛选的范围进行总结,如下图:

淘汰策略
通过我们上面的分析,volatile-randomvolatile-ttl以及allkeys-random 的筛选规则比较简单,而 volatile-lruvolatile-lfuallkeys-lruallkeys-lfu 分别用到了LRULFU 算法,我们继续分析。

LRU 算法

LRU 算法全称 Least Recently Used,一种常见的页面置换算法。按照「最近最少使用」的原则来筛选数据,筛选出最不常用的数据,而最近频繁使用的数据会留在缓存中。

LRU 的筛选逻辑

LRU 会把所有的数据组织成一个链表,链表的头和尾分别表示 MRU 端和 LRU 端,分别代表「最近最常使用」的数据和「最近最不常用」的数据。

如下图:

MRU端_LRU端.png
我们现在有数据 6、3、9、20、5。

数据 20 和 3 被先后访问,它们都会从现有的链表位置移到 MRU 端,而链表中在它们之前的数据则相应地往后移一位。

因为,LRU 算法选择删除数据时,都是从 LRU 端开始,所以把刚刚被访问的数据移到 MRU 端,就可以让它们尽可能地留在缓存中。

如果有一个新数据 15 要被写入缓存,但此时已经没有缓存空间了,也就是链表没有空余位置了,那么,LRU 算法会做两件事:

  1. 因为数据 15 是刚被访问的,所以它会被放到 MRU 端;
  2. 算法把 LRU 端的数据 5 从缓存中删除,相应的链表中就没有数据 5 的记录了。

其实,LRU 的算法逻辑十分简单:它认为,刚刚被访问的数据,肯定还会被再次访问,所以就把它放在 MRU端;LRU 端的数据被认为是长久不访问的数据,在缓存满时,就优先删除它。

我们可以把它理解为手机的后台应用窗口。它总是会把最近常用的窗口放在最前边,而不常用的应用窗口,就排列在后边了,如果再加上只能放置 N 个应用窗口的限制,淘汰最近最少用的应用窗口,那就是一个活生生的 LRU 了。

不过,LRU 算法在实际实现时,需要用「链表」管理所有的缓存数据,这会带来两个问题:

  1. 额外的空间开销;
  2. 当有数据被访问时,需要在链表上把该数据移动到 MRU 端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而降低 Redis 缓存性能。

所以,在 Redis 中,LRU 算法被做了简化,「以减轻数据淘汰对缓存性能的影响」。

Redis 对 LRU 算法的实现

简单来说,Redis 默认会记录每个数据的最近一次访问的时间戳(由键值对数据结构 RedisObject 中的 lru 字段记录)。

然后,Redis 在决定淘汰的数据时,第一次会随机选出 N 个数据,把它们作为一个候选集合。

接下来,Redis 会比较这 N 个数据的 lru 字段,lru 字段值最小的数据从缓存中淘汰出去

Redis 提供了一个配置参数 maxmemory-samples,这个参数就是 Redis 选出的备选数据个数。

例如,我们执行如下命令,可以让 Redis 选出 100 个数据作为备选数据集:

config set maxmemory-samples 100

当需要再次淘汰数据时,Redis 需要挑选数据进入「第一次淘汰时创建的候选集合」。

挑选的标准是:能进入候选集合的数据的 lru 字段值必须小于「候选集合中最小的 lru 值」。

当有新数据进入备选数据集后,如果备选数据集中的数据个数达到了设置的阈值时。Redis 就把备选数据集中 lru 字段值最小的数据淘汰出去

这样,Redis 缓存不用为所有的数据维护一个大链表,也不用在每次数据访问时都移动链表项,提升了缓存的性能。

简单做个总结:

  • 如果业务数据中「有明显的冷热数据区分」,建议使用 allkeys-lru策略。这样,可以充分利用 LRU 算法的优势,把最近最常访问的数据留在缓存中,提升应用的访问性能。
  • 如果业务应用中的「数据访问频率相差不大」,没有明显的冷热数据区分,建议使用 allkeys-random 策略,随机选择淘汰的数据。
  • 如果业务中有「置顶」的需求,比如置顶新闻、置顶视频,那么,可以使用 volatile-lru 策略,同时不给这些置顶数据设置过期时间。这样一来,这些需要置顶的数据一直不会被删除,而其他数据会在过期时根据 LRU 规则进行筛选。

分析完 Redis 如何选定淘汰的数据,我们继续分析 Redis 如何处理这些数据呢。

如何处理被淘汰的数据

一般来说,一旦被淘汰的数据选定后,如果这个数据是干净的,那么我们就直接删除;如果这个数据是脏数据,我们需要把它写回数据库。

干净数据和脏数据的区别就在于,和最初从后端数据库里读取时的值相比,有没有被修改过。
干净数据一直没有被修改,所以后端数据库里的数据也是最新值。在替换时,它可以被直接删除。而脏数据则相反。

但是,对于 Redis 来说,它决定了被淘汰的数据后,会把它们直接删除。即使淘汰的数据是脏数据,Redis 也不会把它们写回数据库。

所以,我们在使用 Redis 缓存时,如果数据被修改了,需要在数据修改时就将它写回数据库。
否则,这个脏数据被淘汰时,会被 Redis 删除,而数据库里也没有最新的数据了。

分析完 LRU 我们继续分析 LFU算法。在这之前先了解一个问题:缓存污染

缓存污染

在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用内存空间。这种情况,就是「缓存污染」。

缓存污染一旦变得严重,就会有大量不再访问的数据滞留在缓存中。如果这些数据占满了缓存空间,我们再往缓存中写入新数据时,就需要先把这些数据逐步淘汰出缓存,这就会引入额外的内存消耗,进而会影响应用的性能。

如何解决缓存污染问题

解决方案,那就是得把不会再被访问的数据筛选出来并淘汰掉。这样就不用等到缓存被写满以后,再逐一淘汰旧数据之后,才能写入新数据了。

至于哪些淘汰哪些数据,是由缓存的淘汰策略决定的。

我们上面分析了 Redis 的 8 种淘汰策略,下面我们一一分析,这些策略对于解决缓存污染问题,是否都有效呢?

volatile-random 和 allkeys-random

这两种策略,它们都是随机内存的数据进行淘汰。既然是随机挑选,那么 Redis 就不会根据「数据的访问情况」来筛选数据。

而且如果被淘汰的数据再次被访问了,就会发生缓存缺失。应用需要到后端数据库中访问这些数据,降低了应用的请求响应速度。

如图:

在这里插入图片描述

所以,volatile-randomallkeys-random 策略,在避免缓存污染这个问题上的效果非常有限。

volatile-ttl 策略

volatile-ttl 筛选的是设置了过期时间的数据,把这些数据中剩余存活时间最短的筛选出来并淘汰。

虽然 volatile-ttl 策略不再是随机选择淘汰数据了,但是剩余存活时间并不能直接反映数据再次访问的情况

所以,按照 volatile-ttl 策略淘汰数据,和按随机方式淘汰数据类似,也可能出现数据被淘汰后,被再次访问导致的缓存缺失问题。

有一种情况例外:业务应用在给数据设置过期时间的时候,明确知道数据被再次访问的情况,并根据访问情况设置过期时间。
此时,Redis 按照数据的剩余最短存活时间进行筛选,是可以把不会再被访问的数据筛选出来的,进而避免缓存污染。

我们先简单小结下:

  • 在明确知道数据被再次访问的情况下,volatile-ttl 可以有效避免缓存污染;
  • 在其他情况下,volatile-randomallkeys-randomvolatile-ttl 这三种策略不能应对缓存污染问题。

接下来,我们再分别分析 LRU 策略,以及 Redis 4.0 后实现的 LFU 策略。LRU 策略会按照数据访问的时效性,来筛选即将被淘汰的数据,应用非常广泛。

LRU 策略

LRU 策略的核心思想:如果一个数据刚刚被访问,那么这个数据肯定是热数据,还会被再次访问。

Redis 中的 LRU 策略,会在每个数据对应的 RedisObject 结构体中设置一个 lru 字段,用来记录数据的访问时间戳。

在进行数据淘汰时,LRU 策略会在候选数据集中淘汰掉 lru 字段值最小的数据,也就是最久不被访问的数据。

所以,在数据被频繁访问的业务场景中,LRU 策略的确能有效留存访问时间最近的数据。而且,因为留存的这些数据还会被再次访问,所以又可以提升应用的访问速度。

但是,也正是因为 只看数据的访问时间,使用 LRU 策略在处理「扫描式单次查询」操作时,无法解决缓存污染。

所谓的「扫描式单次查询操作」,就是指应用对大量的数据进行一次全体读取,每个数据都会被读取,而且只会被读取一次。
此时,因为这些被查询的数据刚刚被访问过,所以 lru 字段值都很大。

在使用 LRU 策略淘汰数据时,这些数据会留存在缓存中很长一段时间,造成缓存污染。

如果查询的数据量很大,这些数据占满了缓存空间,却又不会服务新的缓存请求。
此时,再有新数据要写入缓存的话,需要先把这些旧数据淘汰掉才行,这会影响缓存的性能。

举个简单例子。
如下图,数据 6 被访问后,被写入 Redis 缓存。但是,在此之后,数据 6 一直没有被再次访问,这就导致数据 6 滞留在缓存中,造成了污染。

在这里插入图片描述
所以,对于采用了 LRU 策略的 Redis 缓存来说,「扫描式单次查询」会造成缓存污染。
为了应对这类缓存污染问题,Redis 从 4.0 版本开始增加了 LFU 淘汰策略。

LRU 策略相比,LFU 策略中会从两个维度来筛选并淘汰数据:

  • 一是,数据访问的时效性(访问时间离当前时间的远近);
  • 二是,数据的被访问次数。

那 Redis 的 LFU 策略具体是怎么实现的,又是如何解决缓存污染问题的呢?我们继续看。

LFU策略的优化

LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个「计数器」,来统计这个数据的访问次数。

LFU策略的筛选规则

  • 当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。
  • 如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。
LFU 策略具体实现

我们上面提到,为了避免操作链表的开销,Redis 在实现 LRU 策略时使用了两个近似方法:

  • Redis 在 RedisObject结构中设置了 lru 字段,用来记录数据的访问时间戳;
  • Redis 并没有为所有的数据维护一个全局的链表,而是通过「随机采样」方式,选取一定数量的数据放入备选集合,后续在备选集合中根据 lru 字段值的大小进行筛选删除。

在此基础上,Redis 在实现 LFU 策略的时候,只是把原来 24bit 大小的 lru 字段,又进一步拆分成了两部分:

  1. ldt 值:lru 字段的前 16bit,表示数据的访问时间戳;
  2. counter 值:lru 字段的后 8bit,表示数据的访问次数。

举个简单例子。

假设第一个数据 A 的累计访问次数是 256,访问时间戳是 202010010909,所以它的 counter 值为 255。

Redis 只使用了 8bit 记录数据的访问次数,而 8bit 记录的最大值是 255。

而第二个数据 B 的累计访问次数是 1024,访问时间戳是 202010010810。
如果 counter 值只能记录到 255,那么数据 B 的 counter 值也是 255。

此时,缓存写满了,Redis 使用 LFU 策略进行淘汰。

由于数据 A 和 B 的 counter 值都是 255,LFU 策略会继续比较 A 和 B 的访问时间戳。发现数据 B 的上一次访问时间早于 A,就会把 B 淘汰掉。

但其实数据 B 的访问次数远大于数据 A,很可能会被再次访问。这样一来,使用 LFU 策略来淘汰数据就不合适了。

Redis 对此也进行了优化:在实现 LFU 策略时,Redis 并没有采用数据每被访问一次,就给对应的 counter 值加 1 的计数规则,而是采用了一个更优化的计数规则。

Redis 对 LFU 算法的实现

Redis 实现 LFU 策略的计数规则:

  • 每当数据被访问一次时,先用「计数器当前的值」乘以「配置项 」lfu_log_factor ,再加 1;取其倒数,得到一个 p 值;
  • 然后,把这个 p 值和一个取值范围在(0,1)间的随机数 r 值比大小,只有 p 值大于 r 值时,计数器才加 1。

下面是Redis 的部分源码,其中,baseval是计数器当前的值。计数器的初始值默认是 5(由代码中的 LFU_INIT_VAL 常量设置),而不是 0。这样可以避免数据刚被写入缓存,就因为访问次数少而被立即淘汰。

double r = (double)rand()/RAND_MAX;
...
double p = 1.0/(baseval*server.lfu_log_factor+1);
if (r < p) counter++;   

使用了这种计算规则后,我们可以通过设置不同的 lfu_log_factor 配置项,来控制计数器值增加的速度,避免 counter 值很快就到 255 了。

Redis 官网上提供的一张表,进一步说明 LFU 策略计数器递增的效果。它记录了当 lfu_log_factor 取不同值时,在不同的实际访问次数情况下,计数器值的变化情况。

在这里插入图片描述

可以看到,当 lfu_log_factor 取值为 1 时,实际访问次数为 100K 后,counter 值就达到 255 了,无法再区分实际访问次数更多的数据了。
而当 lfu_log_factor 取值为 100 时,当实际访问次数为 10M 时,counter 值才达到 255。

正是因为使用了非线性递增的计数器方法,即使缓存数据的访问次数成千上万,LFU 策略也可以有效的区分不同的访问次数,从而合理的进行数据筛选。

从刚才的表中,我们可以看到,当 lfu_log_factor 取值为 10 时,百、千、十万级别的访问次数对应的 counter 值已经有明显的区分了。所以,我们在应用 LFU 策略时,一般可以将 lfu_log_factor 取值为 10。

前面我们也提到了,应用负载的情况是很复杂的。比如某些业务场景,有些数据在「短时间内被大量访问后就不会再被访问了」。

那么再按照访问次数来筛选的话,这些数据会被留存在缓存中,但不会提升缓存命中率。
为此,Redis 在实现 LFU 策略时,还设计了一个「 counter 值的衰减机制」。

counter 值的衰减机制

简单来说,LFU 策略使用「衰减因子配置项」 lfu_decay_time 来控制访问次数的衰减。

  1. LFU 策略会计算当前时间和数据最近一次访问时间的差值,并把这个差值换算成以分钟为单位。
  2. 然后,LFU 策略再把这个差值除以 lfu_decay_time 值,所得的结果就是数据 counter 要衰减的值。

简单举个例子,假设 lfu_decay_time取值为 1,如果数据在 N 分钟内没有被访问,那么它的访问次数就要减 N。
如果 lfu_decay_time 取值更大,那么相应的衰减值会变小,衰减效果也会减弱。所以,如果业务应用中有短时高频访问的数据的话,建议把 lfu_decay_time 值设置为 1,

这样一来,LFU 策略在它们不再被访问后,会较快地衰减它们的访问次数,尽早把它们从缓存中淘汰出去,避免缓存污染。

使用了 LFU 策略会保证缓存不被污染吗?

在一些极端情况下,LFU 策略使用的计数器可能会在短时间内达到一个很大值,而计数器的「衰减配置项」 lfu_decay_time设置得较大,导致计数器值衰减很慢,在这种情况下,数据就可能在缓存中长期驻留。

如果你还想看更多优质原创文章,欢迎关注我的公众号「ShawnBlog」。

  • 9
    点赞
  • 34
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值