缓存污染:一些场景下,有些数据被访问的次数非常少,可能后续不再使用。继续留在缓存中会占用缓存空间。当这些不再使用的数据比较多时,后续淘汰会引入额外的操作时间,影响性能。

假如业务能够确定数据被访问时长,可以设置对应时长的过期时间,并用 volatile-ttl 来规避缓存污染。

LRU 只看数据访问时间,所以单次大量查询时,会引入大量数据到缓存,并长期存在,进而造成缓存污染。

LFU 先访问次数最低筛选,再时效性筛选,淘汰数据。Redis 实现时,将原来 24bit 大小的 lru 字段拆成了两部分,前 16bit 代表数据访问时间戳,后 8bit 代表访问次数。实际应用中,在 LRU 候选集基础上,选择访问次数最少的淘汰。

而 8bit 的大小最大计数为 255,为了解决高频数据访问次数问题,而是采用优化的计数规则。1/(count*factor)+1(0,1) 随机数比大小,若大,计数器才加一。factor 越大,越难到达 255.

为了解决数据高频访问后不再访问的问题,Redis 还有衰减因子 lfu_decay_time

但是 LFU 并不能完全保证能解决污染,若参数设置的不好,衰减的比较慢,数据还是会存在内存中。(看课后题)

Redis 好像不支持动态的更新过期策略。那一般要停机更新吗?如果一主多从,从库的过期策略会和主库一样吗?

我们应用 Redis 缓存时,如果能缓存会被反复访问的数据,那就能加速业务应用的访问。但是,如果发生了缓存污染,那么,缓存对业务应用的加速作用就减少了。

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

当缓存污染不严重时,只有少量数据占据缓存空间,此时,对缓存系统的影响不大。但是,缓存污染一旦变得严重后,就会有大量不再访问的数据滞留在缓存中。如果这时数据占满了缓存空间,我们再往缓存中写入新数据时,就需要先把这些数据逐步淘汰出缓存,这就会引入额外的操作时间开销,进而会影响应用的性能。

今天,我们就来看看如何解决缓存污染问题。

如何解决缓存污染问题?

要解决缓存污染,我们也能很容易想到解决方案,那就是得把不会再被访问的数据筛选出来并淘汰掉。这样就不用等到缓存被写满以后,再逐一淘汰旧数据之后,才能写入新数据了。而哪些数据能留存在缓存中,是由缓存的淘汰策略决定的。

到这里,你还记得咱们在【第 24 讲】一起学习的 8 种数据淘汰策略吗?它们分别是 noeviction、volatile-random、volatile-ttl、volatile-lru、volatile-lfu、allkeys-lru、allkeys-random 和 allkeys-lfu 策略。

在这 8 种策略中,noeviction 策略是不会进行数据淘汰的。所以,它肯定不能用来解决缓存污染问题。其他的 7 种策略,都会按照一定的规则来淘汰数据。这里有个关键词是“一定的规则”,那么问题来了,不同的规则对于解决缓存污染问题,是否都有效呢?接下来,我们就一一分析下。

因为 LRU 算法是我们在缓存数据淘汰策略中广泛应用的算法,所以我们先分析其他策略,然后单独分析淘汰策略使用 LRU 算法的情况,最后再学习下 LFU 算法用于淘汰策略时,对缓存污染的应对措施。使用 LRU 算法和 LFU 算法的策略各有两种(volatile-lru 和 allkeys-lru,以及 volatile-lfu 和 allkeys-lfu),为了便于理解,接下来我会统一把它们叫作 LRU 策略和 LFU 策略。

首先,我们看下  volatile-random 和 allkeys-random  这两种策略。它们都是采用随机挑选数据的方式,来筛选即将被淘汰的数据。

既然是随机挑选,那么 Redis 就不会根据数据的访问情况来筛选数据。如果被淘汰的数据又被访问了,就会发生缓存缺失。也就是说,应用需要到后端数据库中访问这些数据,降低了应用的请求响应速度。所以,volatile-random 和 allkeys-random 策略,在避免缓存污染这个问题上的效果非常有限。

我给你举个例子吧。如下图所示,假设我们配置 Redis 缓存使用 allkeys-random 淘汰策略,当缓存写满时,allkeys-random 策略随机选择了数据 20 进行淘汰。不巧的是,数据 20 紧接着又被访问了,此时,Redis 就会发生了缓存缺失。

我们继续看  volatile-ttl  策略是否能有效应对缓存污染。volatile-ttl 针对的是设置了过期时间的数据,把这些数据中剩余存活时间最短的筛选出来并淘汰掉。

虽然 volatile-ttl 策略不再是随机选择淘汰数据了,但是剩余存活时间并不能直接反映数据再次访问的情况。所以,按照 volatile-ttl 策略淘汰数据,和按随机方式淘汰数据类似,也可能出现数据被淘汰后,被再次访问导致的缓存缺失问题。

这时,你可能会想到一种例外的情况:业务应用在给数据设置过期时间的时候,就明确知道数据被再次访问的情况,并根据访问情况设置过期时间。此时,Redis 按照数据的剩余最短存活时间进行筛选,是可以把不会再被访问的数据筛选出来的,进而避免缓存污染。例如,业务部门知道数据被访问的时长就是一个小时,并把数据的过期时间设置为一个小时后。这样一来,被淘汰的数据的确是不会再被访问了。

讲到这里,我们先小结下。除了在明确知道数据被再次访问的情况下,volatile-ttl 可以有效避免缓存污染。在其他情况下,volatile-random、allkeys-random、volatile-ttl 这三种策略并不能应对缓存污染问题。

接下来,我们再分别分析下 LRU 策略,以及 Redis 4.0 后实现的 LFU 策略。LRU 策略会按照数据访问的时效性,来筛选即将被淘汰的数据,应用非常广泛。在第 24 讲,我们已经学习了 Redis 是如何实现 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 缓存策略的优化

Redis LFU 改进

小结

今天这节课,我们学习的是“如何解决缓存污染”这个问题。

缓存污染问题指的是留存在缓存中的数据,实际不会被再次访问了,但是又占据了缓存空间。如果这样的数据体量很大,甚至占满了缓存,每次有新数据写入缓存时,还需要把这些数据逐步淘汰出缓存,就会增加缓存操作的时间开销。

因此,要解决缓存污染问题,最关键的技术点就是能识别出这些只访问一次或是访问次数很少的数据,在淘汰数据时,优先把它们筛选出来并淘汰掉。因为 noviction 策略不涉及数据淘汰,所以这节课,我们就从能否有效解决缓存污染这个维度,分析了 Redis 的其他 7 种数据淘汰策略。

volatile-random 和 allkeys-random 是随机选择数据进行淘汰,无法把不再访问的数据筛选出来,可能会造成缓存污染。如果业务层明确知道数据的访问时长,可以给数据设置合理的过期时间,再设置 Redis 缓存使用 volatile-ttl 策略。当缓存写满时,剩余存活时间最短的数据就会被淘汰出缓存,避免滞留在缓存中,造成污染。

当我们使用 LRU 策略时,由于 LRU 策略只考虑数据的访问时效,对于只访问一次的数据来说,LRU 策略无法很快将其筛选出来。而 LFU 策略在 LRU 策略基础上进行了优化,在筛选数据时,首先会筛选并淘汰访问次数少的数据,然后针对访问次数相同的数据,再筛选并淘汰访问时间最久远的数据。

在具体实现上,相对于 LRU 策略,Redis 只是把原来 24bit 大小的 lru 字段,又进一步拆分成了 16bit 的 ldt 和 8bit 的 counter,分别用来表示数据的访问时间戳和访问次数。为了避开 8bit 最大只能记录 255 的限制,LFU 策略设计使用非线性增长的计数器来表示数据的访问次数。

在实际业务应用中,LRU 和 LFU 两个策略都有应用。LRU 和 LFU 两个策略关注的数据访问特征各有侧重,LRU 策略更加关注数据的时效性,而 LFU 策略更加关注数据的访问频次。通常情况下,实际应用的负载具有较好的时间局部性,所以 LRU 策略的应用会更加广泛。但是,在扫描式查询的应用场景中,LFU 策略就可以很好地应对缓存污染问题了,建议你优先使用。

此外,如果业务应用中有短时高频访问的数据,除了 LFU 策略本身会对数据的访问次数进行自动衰减以外,我再给你个小建议:你可以优先使用 volatile-lfu 策略,并根据这些数据的访问时限设置它们的过期时间,以免它们留存在缓存中造成污染。

每课一问

按照惯例,我给你提个小问题。使用了 LFU 策略后,你觉得缓存还会被污染吗?

答案:在 Redis 中,我们使用了 LFU 策略后,还是有可能发生缓存污染的。@yeek 回答得不错,我给你分享下他的答案。

在一些极端情况下,LFU 策略使用的计数器可能会在短时间内达到一个很大值,而计数器的衰减配置项设置得较大,导致计数器值衰减很慢,在这种情况下,数据就可能在缓存中长期驻留。例如,一个数据在短时间内被高频访问,即使我们使用了 LFU 策略,这个数据也有可能滞留在缓存中,造成污染。