23-27节 缓存特征、缓存类型、缓存异常、缓存污染

23-旁路缓存:Redis是如何工作的?

正因为Redis用作缓存的普遍性以及它在业务应用中的重要作用,所以,我们需要系统地掌握缓存的一系列内容,包括工作原理、替换策略、异常处理和扩展机制。具体来说,我们需要解决四个关键问题:

  • Redis缓存具体是怎么工作的?
  • Redis缓存如果满了,该怎么办?
  • 为什么会有缓存一致性、缓存穿透、缓存雪崩、缓存击穿等异常,该如何应对?
  • Redis的内存毕竟有限,如果用快速的固态硬盘来保存数据,可以增加缓存的数据量,那么,Redis缓存可以使用快速固态硬盘吗?

缓存的特征

所以,计算机系统中,默认有两种缓存:

  • CPU里面的末级缓存,即LLC,用来缓存内存中的数据,避免每次从内存中存取数据;
  • 内存中的高速页缓存,即page cache,用来缓存磁盘中的数据,避免每次从磁盘中存取数据。

我们可以看出来缓存的第一个特征:在一个层次化的系统中,缓存一定是一个快速子系统,数据存在缓存中时,能避免每次从慢速子系统中存取数据。对应到互联网应用来说,Redis就是快速子系统,而数据库就是慢速子系统了。

LLC的大小是MB级别,page cache的大小是GB级别,而磁盘的大小是TB级别。这其实包含了缓存的第二个特征:缓存系统的容量大小总是小于后端慢速系统的,我们不可能把所有数据都放在缓存系统中

Redis缓存处理请求的两种情况

把Redis用作缓存时,我们会把Redis部署在数据库的前端,业务应用在访问数据时,会先查询Redis中是否保存了相应的数据。此时,根据数据是否存在缓存中,会有两种情况。

  • 缓存命中:Redis中有相应数据,就直接读取Redis,性能非常快。
  • 缓存缺失:Redis中没有保存相应数据,就从后端数据库中读取数据,性能就会变慢。而且,一旦发生缓存缺失,为了让后续请求能从缓存中读取到数据,我们需要把缺失的数据写入Redis,这个过程叫作缓存更新。缓存更新操作会涉及到保证缓存和数据库之间的数据一致性问题

使用Redis缓存时,我们基本有三个操作:

  • 应用读取数据时,需要先读取Redis;
  • 发生缓存缺失时,需要从数据库读取数据;
  • 发生缓存缺失时,还需要更新缓存。

那么,这些操作具体是由谁来做的呢?这和Redis缓存的使用方式相关。接下来,我就来和你聊聊Redis作为旁路缓存的使用操作方式。

Redis作为旁路缓存的使用操作

Redis是一个独立的系统软件,和业务应用程序是两个软件,当我们部署了Redis实例后,它只会被动地等待客户端发送请求,然后再进行处理。所以,如果应用程序想要使用Redis缓存,我们就要在程序中增加相应的缓存操作代码。所以,我们也把Redis称为旁路缓存,也就是说,读取缓存、读取数据库和更新缓存的操作都需要在应用程序中来完成。

那么,使用Redis缓存时,具体来说,我们需要在应用程序中增加三方面的代码:

  • 当应用程序需要读取数据时,我们需要在代码中显式调用Redis的GET操作接口,进行查询;
  • 如果缓存缺失了,应用程序需要再和数据库连接,从数据库中读取数据;
  • 当缓存中的数据需要更新时,我们也需要在应用程序中显式地调用SET操作接口,把更新的数据写入缓存。

在使用Redis缓存时,有一个地方就需要注意了:因为需要新增程序代码来使用缓存,所以,Redis并不适用于那些无法获得源码的应用,例如一些很早之前开发的应用程序,它们的源码已经没有再维护了,或者是第三方供应商开发的应用,没有提供源码,所以,我们就没有办法在这些应用中进行缓存操作。

也正因为Redis是旁路缓存,是一个独立的系统,我们可以单独对Redis缓存进行扩容或性能优化。而且,只要保持操作接口不变,我们在应用程序中增加的代码就不用再修改了。

我们既可以在缓存中修改,也可以在后端数据库中进行修改,我们该怎么选择呢?
其实,这就涉及到了Redis缓存的两种类型:只读缓存和读写缓存。只读缓存能加速读请求,而读写缓存可以同时加速读写请求。而且,读写缓存又有两种数据写回策略,可以让我们根据业务需求,在保证性能和保证数据可靠性之间进行选择。所以,接下来,我们来具体了解下Redis的缓存类型和相应的写回策略。

缓存的类型

按照Redis缓存是否接受写请求,我们可以把它分成只读缓存和读写缓存。

只读缓存

当应用再次读取这些数据时,会发生缓存缺失,应用会把这些数据从数据库中读出来,并写到缓存中。这样一来,这些数据后续再被读取时,就可以直接从缓存中获取了,能起到加速访问的效果。

只读缓存直接在数据库中更新数据的好处是,所有最新的数据都在数据库中,而数据库是提供数据可靠性保障的,这些数据不会有丢失的风险。当我们需要缓存图片、短视频这些用户只读的数据时,就可以使用只读缓存这个类型了。

读写缓存

在使用读写缓存时,最新的数据是在Redis中,而Redis是内存数据库,一旦出现掉电或宕机,内存中的数据就会丢失。这也就是说,应用的最新数据可能会丢失,给应用业务带来风险。

所以,根据业务应用对数据可靠性和缓存性能的不同要求,我们会有同步直写和异步写回两种策略。其中,同步直写策略优先保证数据可靠性,而异步写回策略优先提供快速响应。学习了解这两种策略,可以帮助我们根据业务需求,做出正确的设计选择。

  • 同步直写是指,写请求发给缓存的同时,也会发给后端数据库进行处理,等到缓存和数据库都写完数据,才给客户端返回。这样,即使缓存宕机或发生故障,最新的数据仍然保存在数据库中,这就提供了数据可靠性保证。不过,同步直写会降低缓存的访问性能。(Redis快于数据库,增加了缓存的响应延迟)
  • 异步写回策略,则是优先考虑了响应延迟。此时,所有写请求都先在缓存中处理。等到这些增改的数据要被从缓存中淘汰出来时,缓存将它们写回后端数据库。这样一来,处理这些数据的操作是在缓存中进行的,很快就能完成。只不过,如果发生了掉电,而它们还没有被写回数据库,就会有丢失的风险了。

关于是选择只读缓存,还是读写缓存,主要看我们对写请求是否有加速的需求。

  • 如果需要对写请求进行加速,我们选择读写缓存;
  • 如果写请求很少,或者是只需要提升读请求的响应速度的话,我们选择只读缓存。

举个例子,在商品大促的场景中,商品的库存信息会一直被修改。如果每次修改都需到数据库中处理,就会拖慢整个应用,此时,我们通常会选择读写缓存的模式。而在短视频App的场景中,虽然视频的属性有很多,但是,一般确定后,修改并不频繁,此时,在数据库中进行修改对缓存影响不大,所以只读缓存模式是一个合适的选择。

课后问题

24-替换策略:缓存满了怎么办?

缓存数据的淘汰机制。简单来说,数据淘汰机制包括两步:第一,根据一定的策略,筛选出对应用访问来说“不重要”的数据;第二,将这些数据从缓存中删除,为新来的数据腾出空间,

通常,我们也把它叫作缓存替换机制,同时还会讲到一系列选择淘汰数据的具体策略。

设置多大的缓存容量合适?


在这条红线上,80%的数据贡献的访问量,超过了传统的长尾效应中80%数据能贡献的访问量。原因在于,用户的个性化需求越来越多,在一个业务应用中,不同用户访问的内容可能差别很大,所以,用户请求的数据和它们贡献的访问量比例,不再具备长尾效应中的“八二原理”分布特征了。也就是说,20%的数据可能贡献不了80%的访问,而剩余的80%数据反而贡献了更多的访问量,我们称之为重尾效应。

正是因为20%的数据不一定能贡献80%的访问量。这个容量规划不能一概而论,是需要结合应用数据实际访问特征和成本开销来综合考虑的。

这其实也是我一直在和你分享的经验,系统的设计选择是一个权衡的过程:大容量缓存是能带来性能加速的收益,但是成本也会更高,而小容量缓存不一定就起不到加速访问的效果。一般来说,我会建议把缓存容量设置为总数据量的15%到30%,兼顾访问性能和内存空间开销。

对于Redis来说,一旦确定了缓存最大容量,比如4GB,你就可以使用下面这个命令来设定缓存的大小了:

1
CONFIG SET maxmemory 4gb

不过,缓存被写满是不可避免的。即使你精挑细选,确定了缓存容量,还是要面对缓存写满时的替换操作。缓存替换需要解决两个问题:决定淘汰哪些数据,如何处理那些被淘汰的数据。

Redis缓存有哪些淘汰策略?

Redis缓存有哪些淘汰策略?
Redis 4.0之前一共实现了6种内存淘汰策略,在4.0之后,又增加了2种策略。我们可以按照是否会进行数据淘汰把它们分成两类:

  • 不进行数据淘汰的策略,只有noeviction这一种。
  • 会进行淘汰的7种其他策略。

会进行淘汰的7种策略,我们可以再进一步根据淘汰候选数据集的范围把它们分成两类:

  • 在设置了过期时间的数据中进行淘汰,包括volatile-random、volatile-ttl、volatile-lru、volatile-lfu(Redis 4.0后新增)四种。

  • 在所有数据范围内进行淘汰,包括allkeys-lru、allkeys-random、allkeys-lfu(Redis 4.0后新增)三种。

  • noeviction策略:一旦缓存被写满了,再有写请求来时,Redis不再提供服务,而是直接返回错误。

  • 对于设置了过期时间的数据进行淘汰:无论是这些键值对的过期时间是快到了,还是Redis的内存使用量达到了maxmemory阈值,Redis都会进一步按照volatile-ttl、volatile-random、volatile-lru、volatile-lfu这四种策略的具体筛选规则进行淘汰。

    • volatile-ttl在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除。
    • volatile-random就像它的名称一样,在设置了过期时间的键值对中,进行随机删除。
    • volatile-lru会使用LRU算法筛选设置了过期时间的键值对。
    • volatile-lfu会使用LFU算法选择设置了过期时间的键值对。(在LRU算法的基础上,同时考虑了数据的访问时效性和数据的访问次数,可以看作是对淘汰策略的优化。)
  • 在所有数据中进行淘汰

    • allkeys-random策略,从所有键值对中随机选择并删除数据;
    • allkeys-lru策略,使用LRU算法在所有数据中进行筛选。
    • allkeys-lfu策略,使用LFU算法在所有数据中进行筛选。

LRU算法的全称是Least Recently Used,从名字上就可以看出,这是按照最近最少使用的原则来筛选数据,最不常用的数据会被筛选出来,而最近频繁使用的数据会留在缓存中。

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

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

LRU算法背后的想法非常朴素:它认为刚刚被访问的数据,肯定还会被再次访问,所以就把它放在MRU端;长久不访问的数据,肯定就不会再被访问了,所以就让它逐渐后移到LRU端,在缓存满时,就优先删除它。

不过,LRU算法在实际实现时,需要用链表管理所有的缓存数据,这会带来额外的空间开销。而且,当有数据被访问时,需要在链表上把该数据移动到MRU端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低Redis缓存性能。

【注意LRU算法的简化】 在Redis中,LRU算法被做了简化,以减轻数据淘汰对缓存性能的影响。具体来说,Redis默认会记录每个数据的最近一次访问的时间戳(由键值对数据结构RedisObject中的lru字段记录)。然后,Redis在决定淘汰的数据时,第一次会随机选出N个数据,把它们作为一个候选集合。接下来,Redis会比较这N个数据的lru字段,把lru字段值最小的数据从缓存中淘汰出去。

Redis提供了一个配置参数maxmemory-samples,这个参数就是Redis选出的数据个数N。例如,我们执行如下命令,可以让Redis选出100个数据作为候选数据集:

1
CONFIG SET maxmemory-samples 100

当需要再次淘汰数据时,Redis需要挑选数据进入第一次淘汰时创建的候选集合。这儿的挑选标准是:能进入候选集合的数据的lru字段值必须小于候选集合中最小的lru值。当有新数据进入候选数据集后,如果候选数据集中的数据个数达到了maxmemory-samples,Redis就把候选数据集中lru字段值最小的数据淘汰出去。

我再给你三个使用建议。

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

如何处理被淘汰的数据?

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

而脏数据就是曾经被修改过的,已经和后端数据库中保存的数据不一致了。此时,如果不把脏数据写回到数据库中,这个数据的最新值就丢失了,就会影响应用的正常使用。
这么一来,缓存替换既腾出了缓存空间,用来缓存新的数据,同时,将脏数据写回数据库,也保证了最新数据不会丢失。

不过,对于Redis来说,它决定了被淘汰的数据后,会把它们删除。即使淘汰的数据是脏数据,Redis也不会把它们写回数据库。所以,我们在使用Redis缓存时,如果数据被修改了,需要在数据修改时就将它写回数据库。否则,这个脏数据被淘汰时,会被Redis删除,而数据库里也没有最新的数据了。

小结(建议)

所以,我给你的建议是,先根据是否有始终会被频繁访问的数据(例如置顶消息),来选择淘汰数据的候选集,也就是决定是针对所有数据进行淘汰,还是针对设置了过期时间的数据进行淘汰。候选数据集范围选定后,建议优先使用LRU算法,也就是,allkeys-lru或volatile-lru策略。

当然,设置缓存容量的大小也很重要,我的建议是:结合实际应用的数据总量、热数据的体量,以及成本预算,把缓存空间大小设置在总数据量的15%到30%这个区间就可以。

课后问题

25-缓存异常(上):如何解决缓存和数据库的数据不一致问题?

在实际应用Redis缓存时,我们经常会遇到一些异常问题,概括来说有4个方面:缓存中的数据和数据库中的不一致;缓存雪崩;缓存击穿和缓存穿透。

缓存和数据库的数据不一致是如何发生的?

首先,我们得清楚“数据的一致性”具体是啥意思。其实,这里的“一致性”包含了两种情况:

  • 缓存中有数据,那么,缓存的数据值需要和数据库中的值相同;
  • 缓存中本身没有数据,那么,数据库中的值必须是最新值。

当缓存的读写模式不同时,缓存数据不一致的发生情况不一样,我们的应对方法也会有所不同,所以,我们先按照缓存读写模式,来分别了解下不同模式下的缓存不一致情况。根据【是否接收写请求】,我们可以把缓存分成读写缓存和只读缓存。

对于读写缓存来说,如果要对数据进行增删改,就需要在缓存中进行,同时还要根据采取的写回策略,决定是否同步写回到数据库中。

  • 同步直写策略:写缓存时,也同步写数据库,缓存和数据库中的数据一致;
  • 异步写回策略:写缓存时不同步写数据库,等到数据从缓存中淘汰时,再写回数据库。使用这种策略时,如果数据还没有写回数据库,缓存就发生了故障,那么,此时,数据库就没有最新的数据了。

所以,对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略。不过,需要注意的是,如果采用这种策略,就需要同时更新缓存和数据库。所以,我们要在业务应用中使用事务机制,来保证缓存和数据库的更新具有原子性,也就是说,两者要不一起更新,要不都不更新,返回错误信息,进行重试。否则,我们就无法实现同步直写。

当然,在有些场景下,我们对数据一致性的要求可能不是那么高,比如说缓存的是电商商品的非关键属性或者短视频的创建或修改时间等,那么,我们可以使用异步写回策略。

对于只读缓存来说,如果有数据新增,会直接写入数据库;而有数据删改时,就需要把只读缓存中的数据标记为无效。这样一来,应用后续再访问这些增删改的数据时,因为缓存中没有相应的数据,就会发生缓存缺失。此时,应用再从数据库中把数据读入缓存,这样后续再访问数据时,就能够直接从缓存中读取了。

在更新数据库和删除缓存值的过程中,无论这两个操作的执行顺序谁先谁后,只要有一个操作失败了,就会导致客户端读取到旧值。我画了下面这张表,总结了刚刚所说的这两种情况。

如何解决数据不一致问题?

重试机制

  • 具体来说,可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka消息队列)。当应用没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
  • 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作。
  • 如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。

下图显示了先更新数据库,再删除缓存值时,如果缓存删除失败,再次重试后删除成功的情况,你可以看下。

刚刚说的是在更新数据库和删除缓存值的过程中,其中一个操作失败的情况,实际上,即使这两个操作第一次执行时都没有失败,当有大量并发请求时,应用还是有可能读到不一致的数据。

同样,我们按照不同的删除和更新顺序,分成两种情况来看。在这两种情况下,我们的解决方法也有所不同。

情况一:先删除缓存,再更新数据库。

一种解决方案:在线程A更新完数据库值以后,我们可以让它先sleep一小段时间,再进行一次缓存删除操作。

因为这个方案会在第一次删除缓存值后,延迟一段时间再次进行删除,所以我们也把它叫做“延迟双删”。

下面的这段伪代码就是“延迟双删”方案的示例,你可以看下。

1
2
3
4
redis.delKey(X)
db.update(X)
Thread.sleep(N)
redis.delKey(X)

情况二:先更新数据库值,再删除缓存值。

在这种情况下,如果其他线程并发读缓存的请求不多,那么,就不会有很多请求读取到旧值。而且,线程A一般也会很快删除缓存值,这样一来,其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值。所以,这种情况对业务的影响较小。

小结

  • 对于读写缓存来说,如果我们采用同步写回策略,那么可以保证缓存和数据库中的数据一致。
  • 只读缓存的情况比较复杂,我总结了一张表,以便于你更加清晰地了解数据不一致的问题原因、现象和应对方案。

    在大多数业务场景下,我们会把Redis作为只读缓存使用。针对只读缓存来说,我们既可以先删除缓存值再更新数据库,也可以先更新数据库再删除缓存。我的建议是,优先使用先更新数据库再删除缓存的方法,原因主要有两个:
  1. 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力;
  2. 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。

不过,当使用先更新数据库再删除缓存时,也有个地方需要注意,如果业务层要求必须读取一致的数据,那么,我们就需要在更新数据库时,先在Redis缓存客户端暂存并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性。

课后问题

26-缓存异常(下):如何解决缓存雪崩、击穿、穿透难题?

缓存雪崩

缓存雪崩是指大量的应用请求无法在Redis缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增。

缓存雪崩一般是由两个原因导致的,应对方案也有所不同,我们一个个来看。

  • 第一个原因是:缓存中有大量数据同时过期,导致大量请求无法得到处理。
    • 方案一:我们可以避免给大量的数据设置相同的过期时间。如果业务层的确要求有些数据同时失效,你可以在用EXPIRE命令给每个数据设置过期时间时,给这些数据的过期时间增加一个较小的随机数(例如,随机增加1~3分钟)
    • 方案二:服务降级
      • 当业务应用访问的是非核心数据(例如电商商品属性)时,暂时停止从缓存中查询这些数据,而是直接返回预定义信息、空值或是错误信息;
      • 当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取。
    • 方案二:Redis缓存实例发生故障宕机了,无法处理请求,这就会导致大量请求一下子积压到数据库层,从而发生缓存雪崩。
      • 第一个建议,是在业务系统中实现服务熔断或请求限流机制。
        • 使用服务熔断或是请求限流机制,来应对Redis实例宕机导致的缓存雪崩问题,是属于“事后诸葛亮”,也就是已经发生缓存雪崩了,我们使用这两个机制,来降低雪崩对数据库和整个业务系统的影响。
      • 第二个建议就是事前预防。
        • 通过主从节点的方式构建Redis缓存高可靠集群。

缓存击穿

缓存击穿是指,针对某个访问非常频繁的热点数据的请求,无法在缓存中进行处理,紧接着,访问该数据的大量请求,一下子都发送到了后端数据库,导致了数据库压力激增,会影响数据库处理其他请求。缓存击穿的情况,经常发生在热点数据过期失效时【和缓存雪崩相比,缓存击穿失效的数据数量要小很多】

解决方法也比较直接,对于访问特别频繁的热点数据,我们就不设置过期时间了。这样一来,对热点数据的访问请求,都可以在缓存中进行处理,而Redis数万级别的高吞吐量可以很好地应对大量的并发请求访问。

缓存穿透

缓存穿透是指要访问的数据既不在Redis缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。【缓存变成摆设】

一般有两种情况发生:

  • 业务层误操作:缓存中的数据和数据库中的数据被误删除了,所以缓存和数据库中都没有数据;
  • 恶意攻击:专门访问数据库中没有的数据。

为了避免缓存穿透的影响,我来给你提供三种应对方案。

  • 第一种方案是,缓存空值或缺省值。
    • 一旦发生缓存穿透,我们就可以针对查询的数据,在Redis中缓存一个空值或是和业务层协商确定的缺省值(例如,库存的缺省值可以设为0)
  • 第二种方案是,使用布隆过滤器快速判断数据是否存在,避免从数据库中查询数据是否存在,减轻数据库压力。
    • 布隆过滤器由一个初值都为0的bit数组和N个哈希函数组成,可以用来快速判断某个数据是否存在。当我们想标记某个数据存在时(例如,数据已被写入数据库),布隆过滤器会通过三个操作完成标记:
      • 首先,使用N个哈希函数,分别计算这个数据的哈希值,得到N个哈希值。
        然后,我们把这N个哈希值对bit数组的长度取模,得到每个哈希值在数组中的对应位置。
      • 最后,我们把对应位置的bit位设置为1,这就完成了在布隆过滤器中标记数据的操作。
      • 如果数据不存在(例如,数据库里没有写入数据),我们也就没有用布隆过滤器标记过数据,那么,bit数组对应bit位的值仍然为0。
    • 当需要查询某个数据时,我们就执行刚刚说的计算过程,先得到这个数据在bit数组中对应的N个位置。紧接着,我们查看bit数组中这N个位置上的bit值。只要这N个bit值有一个不为1,这就表明布隆过滤器没有对该数据做过标记,所以,查询的数据一定没有在数据库中保存。
  • 最后一种方案是,在请求入口的前端进行请求检测。
    • 对业务系统接收到的请求进行合法性检测,把恶意的请求(例如请求参数不合理、请求参数是非法值、请求字段不存在)直接过滤掉,不让它们访问后端缓存和数据库。

跟缓存雪崩、缓存击穿这两类问题相比,缓存穿透的影响更大一些,希望你能重点关注一下。从预防的角度来说,我们需要避免误删除数据库和缓存中的数据;从应对角度来说,我们可以在业务系统中使用缓存空值或缺省值、使用布隆过滤器,以及进行恶意请求检测等方法。

小结


尽量使用预防式方案:

  • 针对缓存雪崩,合理地设置数据过期时间,以及搭建高可靠缓存集群;
  • 针对缓存击穿,在缓存访问非常频繁的热点数据时,不要设置过期时间;
  • 针对缓存穿透,提前在入口前端实现恶意请求检测,或者规范数据库的数据删除操作,避免误删除。

27-缓存被污染了,该怎么办?

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

如何解决缓存污染问题?

把不会再被访问的数据筛选出来并淘汰掉。这样就不用等到缓存被写满以后,再逐一淘汰旧数据之后,才能写入新数据了。而哪些数据能留存在缓存中,是由缓存的淘汰策略决定的。

volatile-random和allkeys-random:如果被淘汰的数据又被访问了,就会发生缓存缺失。也就是说,应用需要到后端数据库中访问这些数据,降低了应用的请求响应速度。

volatile-ttl:针对的是设置了过期时间的数据,把这些数据中剩余存活时间最短的筛选出来并淘汰掉。
在明确知道数据被再次访问的情况下,volatile-ttl可以有效避免缓存污染。

LRU缓存策略

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

按照这个核心思想,Redis中的LRU策略,会在每个数据对应的RedisObject结构体中设置一个lru字段,用来记录数据的访问时间戳。在进行数据淘汰时,LRU策略会在候选数据集中淘汰掉lru字段值最小的数据(也就是访问时间最久的数据)。

因为只看数据的访问时间,使用LRU策略在处理扫描式单次查询操作时,无法解决缓存污染。 所谓的扫描式单次查询操作,就是指应用对大量的数据进行一次全体读取,每个数据都会被读取,而且只会被读取一次。此时,因为这些被查询的数据刚刚被访问过,所以lru字段值都很大。

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

与LRU策略相比,LFU策略中会从两个维度来筛选并淘汰数据:一是,数据访问的时效性(访问时间离当前时间的远近);二是,数据的被访问次数。

LFU缓存策略的优化

LFU缓存策略是在LRU策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用LFU策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。

LFU策略会优先把这些访问次数低的数据淘汰出缓存。这样一来,LFU策略就可以避免这些数据对缓存造成污染了。

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

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

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

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

总结一下:当LFU策略筛选数据时,Redis会在候选集合中,根据数据lru字段的后8bit选择访问次数最少的数据进行淘汰。当访问次数相同时,再根据lru字段的前16bit值大小,选择访问时间最久远的数据进行淘汰。

到这里,还没结束,Redis只使用了8bit记录数据的访问次数,而8bit记录的最大值是255。只要访问次数超过了255,数据的counter值就一样了。在进行数据淘汰时,LFU策略就无法很好地区分并筛选这些数据。

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

简单来说,LFU策略实现的计数规则是:每当数据被访问一次时,首先,用计数器当前的值乘以配置项lfu_log_factor再加1,再取其倒数,得到一个p值;然后,把这个p值和一个取值范围在(0,1)间的随机数r值比大小,只有p值大于r值时,计数器才加1。

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

1
2
3
4
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策略时,还设计了一个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策略在它们不再被访问后,会较快地衰减它们的访问次数,尽早把它们从缓存中淘汰出去,避免缓存污染。

小结

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

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

课后问题