You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

146 lines
17 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 27 | 缓存被污染了,该怎么办?
你好,我是蒋德钧。
我们应用Redis缓存时如果能缓存会被反复访问的数据那就能加速业务应用的访问。但是如果发生了缓存污染那么缓存对业务应用的加速作用就减少了。
那什么是缓存污染呢?在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。这种情况,就是缓存污染。
当缓存污染不严重时,只有少量数据占据缓存空间,此时,对缓存系统的影响不大。但是,缓存污染一旦变得严重后,就会有大量不再访问的数据滞留在缓存中。如果这时数据占满了缓存空间,我们再往缓存中写入新数据时,就需要先把这些数据逐步淘汰出缓存,这就会引入额外的操作时间开销,进而会影响应用的性能。
今天,我们就来看看如何解决缓存污染问题。
## 如何解决缓存污染问题?
要解决缓存污染,我们也能很容易想到解决方案,那就是得把不会再被访问的数据筛选出来并淘汰掉。这样就不用等到缓存被写满以后,再逐一淘汰旧数据之后,才能写入新数据了。而哪些数据能留存在缓存中,是由缓存的淘汰策略决定的。
到这里,你还记得咱们在[第24讲](https://time.geekbang.org/column/article/294640)一起学习的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就会发生了缓存缺失。
![](https://static001.geekbang.org/resource/image/d8/c8/d8e81168d83b411524a91c2f5554e3c8.jpg)
我们继续看**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滞留在缓存中造成了污染。
![](https://static001.geekbang.org/resource/image/76/75/76909482d30097da81273f7bda18b275.jpg)
所以对于采用了LRU策略的Redis缓存来说扫描式单次查询会造成缓存污染。为了应对这类缓存污染问题Redis从4.0版本开始增加了LFU淘汰策略。
与LRU策略相比LFU策略中会从两个维度来筛选并淘汰数据一是数据访问的时效性访问时间离当前时间的远近二是数据的被访问次数。
那Redis的LFU策略是怎么实现的又是如何解决缓存污染问题的呢我们来看一下。
## LFU缓存策略的优化
LFU缓存策略是在LRU策略基础上为每个数据增加了一个计数器来统计这个数据的访问次数。当使用LFU策略筛选淘汰数据时首先会根据数据的访问次数进行筛选把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同LFU策略再比较这两个数据的访问时效性把距离上一次访问时间更久的数据淘汰出缓存。
和那些被频繁访问的数据相比扫描式单次查询的数据因为不会被再次访问所以它们的访问次数不会再增加。因此LFU策略会优先把这些访问次数低的数据淘汰出缓存。这样一来LFU策略就可以避免这些数据对缓存造成污染了。
那么LFU策略具体又是如何实现的呢既然LFU策略是在LRU策略上做的优化那它们的实现必定有些关系。所以我们就再复习下第24讲学习过的LRU策略的实现。
为了避免操作链表的开销Redis在实现LRU策略时使用了两个近似方法
* Redis是用RedisObject结构来保存数据的RedisObject结构中设置了一个lru字段用来记录数据的访问时间戳
* Redis并没有为所有的数据维护一个全局的链表而是通过随机采样方式选取一定数量例如10个的数据放入候选集合后续在候选集合中根据lru字段值的大小进行筛选。
在此基础上,**Redis在实现LFU策略的时候只是把原来24bit大小的lru字段又进一步拆分成了两部分**。
1. ldt值lru字段的前16bit表示数据的访问时间戳
2. counter值lru字段的后8bit表示数据的访问次数。
总结一下当LFU策略筛选数据时Redis会在候选集合中根据数据lru字段的后8bit选择访问次数最少的数据进行淘汰。当访问次数相同时再根据lru字段的前16bit值大小选择访问时间最久远的数据进行淘汰。
到这里,还没结束,**Redis只使用了8bit记录数据的访问次数而8bit记录的最大值是255**,这样可以吗?
在实际应用中一个数据可能会被访问成千上万次。如果每被访问一次counter值就加1的话那么只要访问次数超过了255数据的counter值就一样了。在进行数据淘汰时LFU策略就无法很好地区分并筛选这些数据反而还可能会把不怎么访问的数据留存在了缓存中。
我们一起来看个例子。
假设第一个数据A的累计访问次数是256访问时间戳是202010010909所以它的counter值为255而第二个数据B的累计访问次数是1024访问时间戳是202010010810。如果counter值只能记录到255那么数据B的counter值也是255。
此时缓存写满了Redis使用LFU策略进行淘汰。数据A和B的counter值都是255LFU策略再比较A和B的访问时间戳发现数据B的上一次访问时间早于A就会把B淘汰掉。但其实数据B的访问次数远大于数据A很可能会被再次访问。这样一来使用LFU策略来淘汰数据就不合适了。
的确Redis也注意到了这个问题。因此**在实现LFU策略时Redis并没有采用数据每被访问一次就给对应的counter值加1的计数规则而是采用了一个更优化的计数规则**。
简单来说LFU策略实现的计数规则是每当数据被访问一次时首先用计数器当前的值乘以配置项lfu\_log\_factor再加1再取其倒数得到一个p值然后把这个p值和一个取值范围在01间的随机数r值比大小只有p值大于r值时计数器才加1。
下面这段Redis的部分源码显示了LFU策略增加计数器值的计算逻辑。其中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了。
为了更进一步说明LFU策略计数器递增的效果你可以看下下面这张表。这是Redis[官网](https://redis.io/topics/lru-cache)上提供的一张表它记录了当lfu\_log\_factor取不同值时在不同的实际访问次数情况下计数器的值是如何变化的。
![](https://static001.geekbang.org/resource/image/8e/3e/8eafa57112b01ba0yyf93034ca109f3e.jpg)
可以看到当lfu\_log\_factor取值为1时实际访问次数为100K后counter值就达到255了无法再区分实际访问次数更多的数据了。而当lfu\_log\_factor取值为100时当实际访问次数为10M时counter值才达到255此时实际访问次数小于10M的不同数据都可以通过counter值区分出来。
正是因为使用了非线性递增的计数器方法即使缓存数据的访问次数成千上万LFU策略也可以有效地区分不同的访问次数从而进行合理的数据筛选。从刚才的表中我们可以看到当lfu\_log\_factor取值为10时百、千、十万级别的访问次数对应的counter值已经有明显的区分了所以我们在应用LFU策略时一般可以将lfu\_log\_factor取值为10。
前面我们也提到了应用负载的情况是很复杂的。在一些场景下有些数据在短时间内被大量访问后就不会再被访问了。那么再按照访问次数来筛选的话这些数据会被留存在缓存中但不会提升缓存命中率。为此Redis在实现LFU策略时还设计了一个counter值的衰减机制。
简单来说LFU策略使用衰减因子配置项lfu\_decay\_time来控制访问次数的衰减。LFU策略会计算当前时间和数据最近一次访问时间的差值并把这个差值换算成以分钟为单位。然后LFU策略再把这个差值除以lfu\_decay\_time值所得的结果就是数据counter要衰减的值。
简单举个例子假设lfu\_decay\_time取值为1如果数据在N分钟内没有被访问那么它的访问次数就要减N。如果lfu\_decay\_time取值更大那么相应的衰减值会变小衰减效果也会减弱。所以如果业务应用中有短时高频访问的数据的话建议把lfu\_decay\_time值设置为1这样一来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策略后你觉得缓存还会被污染吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。