从原理到应对:缓存雪崩、穿透、击穿深度揭秘

在日常工作里,Redis 作为缓存工具被广泛应用。而缓存击穿、穿透、雪崩这三种场景,均是由于在访问 Redis 缓存时未能获取到数据,进而直接查询数据库所引发的,只不过它们各自代表着不同的情况:

缓存雪崩

定义

缓存雪崩指的是缓存中大量数据在同一时间过期或者失效,导致大量请求直接涌向数据库。这种情况可能会使数据库瞬间承受巨大的压力,甚至导致服务崩溃。解决缓存雪崩的常见方法包括在设置缓存过期时间时增加随机性,以避免大批量缓存同时失效。

解决方案

解决方案1:随机化过期时间

在之前介绍缓存预留模式(Cache Aside)时,我们采用了统一设置过期时间的方式,将缓存过期时间设定为60秒,同时在服务发布之际,预先对热点key的缓存进行了初始化操作。

这种设置虽然在一定程度上保证了缓存的时效性,但也存在潜在风险。60秒时间一到,这些热点key的缓存会同时过期,届时,大部分流量将涌入加载数据库(DB)数据到缓存的逻辑流程中,这极有可能导致数据库瞬间承受巨大压力,进而引发缓存雪崩问题。

为有效避免这种情况,我们可以尝试在缓存过期时间的设置上引入随机数。例如,将过期时间设定在60 - 120秒这个区间内。如此一来,各个缓存的过期时间便不再整齐划一,部分缓存会稍晚一些过期,这就使得加载数据库数据到缓存的压力得以分散,避免了因大量缓存同时过期而产生的集中压力冲击。

在代码实现上,可以通过如下方式达成:

cacheService.set(key, data, 60 + new Random().nextInt(60));

上述代码中,new Random().nextInt(60)会生成一个0(包括)到60(不包括)之间的随机整数,将其与60相加,最终得到的结果就是一个在60 - 120秒之间的随机过期时间。

需要注意的是,随机时间范围的选择并非随意为之,而是需要紧密结合业务实际情况进行考量。通常情况下,如果过期时间设置得足够分散,那么缓存雪崩发生的概率就能得到显著降低 。

解决方案 2:热 key 数据不过期

缓存雪崩问题的根源在于缓存数据的集中过期。所以,在更新缓存的过程中,我们可以考虑不设置过期时间,而是采用在更新数据库(DB)时删除缓存的策略来更新缓存。然而,对于热 key 的更新操作,必须加锁处理。这是因为若不进行加锁,极有可能出现前文所提及的数据不一致问题。想象一下,多个并发请求同时尝试更新热 key 的缓存,若没有锁机制,不同请求的更新操作相互干扰,最终缓存中的数据可能并非预期的正确结果。

此时,或许有人会提出疑问:要是数据库更新成功了,但缓存更新却失败了,这就会导致后续查询无法获取到最新数据,该如何解决呢?

针对这个问题,我们可以设计一个定时任务。例如,设定每小时执行一次,通过查询数据库来刷新所有数据的缓存。这样一来,即便之前出现过缓存更新失败的情况,其影响范围也能被有效控制,不会持续扩大。

另外,当缓存更新失败时,还可以发送异步消息。通过消费这条消息,系统能够自动重试缓存更新操作。这就像是为缓存更新失败的情况提供了一个“补救通道”,确保缓存数据能够及时更新。

不过,需要注意的是,这种解决方案并非适用于所有企业。如果缓存中的数据量庞大,采用热 key 数据不过期的方式,会消耗大量的缓存资源。因此,企业在实际应用中,需要根据自身的业务特点和资源状况,谨慎且适当地做出选择。

缓存穿透

定义

缓存穿透并非是由于热点缓存过期引发大量并发请求导致的,准确来说,它指的是客户端请求的数据在缓存和数据库中均不存在的情况。当这种情况发生时,如果没有合理的并发控制机制,这些请求就会直接绕过诸如 if(data == null) 这样用于判断数据是否存在于缓存中的条件语句,毫无阻碍地进入到从数据库加载数据并写入缓存的逻辑流程。大量此类无效请求的涌入,会给数据库带来巨大压力,严重时甚至可能致使数据库崩溃。

解决方案

缓存穿透本质上可看作是缓存雪崩的一种特殊情形。基于此,我们能够在应对缓存雪崩的解决方案基础上,结合缓存穿透的特性进一步优化,从而提出更为有效的应对策略。

解决方案 1:加锁和排队

随机化过期时间这一策略,能够极大程度地降低不同缓存key同时过期的可能性。在这一方案的基础上,若在某个key读取缓存失败后、执行加载数据库(DB)数据的逻辑中融入并发控制机制,将能更为有效地解决缓存雪崩和缓存穿透问题。

下面通过一个具体例子来详细阐述并发控制的逻辑。

假设针对某个特定的key,瞬间有1000个并发请求蜂拥而至。在这些请求中,必然会有一个请求率先获取到锁,随后该请求便会前往数据库加载数据,并将加载的数据更新至缓存中。而其余的999个请求,会因锁的存在而被阻塞。但这些被阻塞的请求并不会直接报错返回,而是会设置一个等待时长,例如设定为2秒。

当请求1完成缓存更新操作并释放锁后,剩余的999个请求便会依次排队尝试获取锁。以请求2为例,当它成功获得锁并进入加载逻辑时,由于请求1已经完成了缓存更新,此时它实际上可以再次查询缓存。若在缓存中成功找到所需数据,便能够直接返回,无需重复进行数据库的数据加载操作,这就大大减轻了数据库的负载压力。

不过,在这2秒的等待时间内,系统可能仅能处理199个请求。剩余的800个请求在尝试获取锁时会遭遇失败。但实际上,由于请求1早已完成缓存更新,所以对于这些获取锁失败的请求而言,此时再次查询缓存是个合理的操作。如果缓存中有对应数据,请求就可以直接返回数据;若缓存中无数据,便可以报错提示用户系统繁忙,以此避免大量无效请求持续冲击数据库 。

为了更好地理解代码,以下是参考示例:

public class DataCacheManager {
    public Data query(Long id) {
        String key = "keyPrefix:" + id;
        //查询缓存
        Data data = cacheService.get(key);
        if(data == null) {
            try {
                cacheService.lock(key, 2);
                  //先检查缓存
                data = cacheService.get(key);
                if(data != null) {
                      return data;
                }
                //查询DB
                data = dataDao.get(id);
                //更新缓存
                cacheService.set(key, data);
            } catch (tryLockTimeoutException e) {
                    //超时后,再次检查缓存,如果存在则返回,否则会出现异常。
                  data = cacheService.get(key);
                  if (data != null) {
                      return data;
                  }
                  throw e;
            } finally {
                cacheService.unLock(key);
            }
        }
        return data;
    }
}

解决方案 2:热点数据永不过期

与缓存雪崩类似,缓存穿透问题在一定程度上也源于缓存过期机制。因此,我们可以通过将热点数据的缓存设置为永不过期,来有效规避这一问题。如此一来,针对热点数据的请求便始终能够从缓存中获取数据,从而避免请求直接穿透到数据库,减轻数据库压力。

缓存击穿

定义

缓存击穿指的是针对某一热点数据,其缓存突然失效,而此时大量并发请求同时访问该数据,由于缓存中没有,这些请求便会直接涌向数据库。需要注意的是,这里并非指数据既不在缓存又不在数据库中(这其实更符合缓存穿透的部分特征),而是强调热点数据缓存失效引发大量请求冲击数据库,致使数据库承受巨大压力。

正常情况下,遵循标准页面操作流程,不太容易出现这种状况。比如在电商平台,用户从首页或列表页点击的商品通常是真实存在的。然而,不排除有人恶意伪造请求,比如将商品 ID(goodsId)篡改为 0 后发起大量请求,意图破坏系统的稳定性,因此我们必须提前做好防范措施。

解决方案

解决方案 1:参数验证

对客户端发送的请求进行严格的参数校验,一旦发现参数不满足预先设定的校验条件,就立即进行拦截。例如,商品 ID 通常具有实际意义,不会小于 0,通过这种校验可以阻止部分异常请求到达下游数据库。

不过,这种解决方案存在一定局限性,并不能完全杜绝缓存击穿问题。因为如果传递的是看似正常的数字,比如 goodsId = 666666,但实际数据库中并不存在该商品,此类请求依然无法被完全拦截。

解决方案 2:缓存空值

鉴于请求的是数据库中不存在的数据,我们可以采取在缓存中设置一个特殊值(比如“null”)的方式,且要确保这个值与正常数据有所区别。后续查询时,若缓存中对应的值为“null”,就直接返回 null

但需要注意的是,最好为这些缓存的空值设置一个合理的过期时间。因为虽然当前查询的数据不存在,但随着业务的发展,该 key 对应的数据不一定始终为 null,同时,长期占用缓存空间也会造成资源浪费。

解决方案 3:布隆过滤器

在之前的文章中,我们曾介绍过布隆过滤器,其原理相对容易理解。它是一种利用极少内存空间,就能判断大量数据“肯定不存在还是可能存在”的数据结构。

我们可以将可能导致缓存穿透的查询数据条件,通过哈希算法映射到足够大的布隆过滤器上。当请求到来时,首先由布隆过滤器进行拦截判断。对于那些肯定不存在的数据,布隆过滤器会直接拦截并返回,从而避免这些请求进一步给数据库带来压力。而对于那些可能存在的数据,虽然仍需到数据库进行实际查询,但由于这种情况出现的概率较小,所以对数据库造成的影响也不大。

备份解决方案:数据库速率限制和降级

追根溯源,缓存雪崩、穿透以及击穿这三个问题,本质上都源于数据库压力过大。所以,核心要点在于如何保障数据库不会因过载而无法正常运行。

从数据库自身的角度出发,它无法精准控制接收到的请求数量。它不会毫无保留地信赖我们所做的优化措施,毕竟,代码编写过程中难免会出现错误。最稳妥的做法是为数据库自身设置保护机制,这个机制就是速率限制。

例如,我们设定每秒只允许1000个请求通过。倘若在某一时刻,突然涌入2000个请求,那么前1000个请求能够正常执行,而后1000个请求则会触发限速机制。此时,我们可以执行预先配置好的降级逻辑,比如返回一些既定的默认值,或者给出清晰友好的错误提示信息。并且,这种限速最好具备针对性,像是针对热门数据表的读请求,设置单独的限速配置,以此确保其他正常请求不会受到无端限制,保障系统整体的稳定运行。

热点数据及淘汰策略

在服务器中,缓存大多将数据存储于内存之中,目的是确保缓存具备极快的执行速度,从而高效响应各类请求。然而,受限于硬件条件以及成本因素,内存容量无法像磁盘那样能够近乎无限制地进行扩展。当实际产生的数据量极为庞大,无法全部存入缓存时,我们就需要保证缓存中有限的空间能够存储那些被请求频率最高的数据,也就是热点数据,以便尽可能多地命中请求。

在这种情况下,一个不可回避的问题便随之而来:当数据量巨大,而缓存容量却十分有限,一旦缓存空间被占满,该如何应对呢?

此时,就需要做出适当的取舍。

在构建缓存系统时,必须配备一种机制,用以确保内存中的数据量不会无限制地增长,这便是数据淘汰机制。数据淘汰机制是一个成熟的缓存系统不可或缺的基本功能。这里需要特别说明的是,数据淘汰策略与数据过期是两个截然不同的概念。

  • 数据过期:这是缓存系统遵循的标准逻辑,也是符合业务预期的数据删除方式。具体而言,对于设置了过期时间的缓存数据,一旦到达过期时间,就会自动从缓存中被删除。
  • 数据淘汰:这是缓存系统在面临特殊情况时采取的一种“有损自保”的降级策略,属于超出业务常规预期的数据删除手段。当存储的数据尚未达到过期时间,但缓存空间已满,而此时又有新数据需要添加到缓存中时,缓存模块就需要实施数据淘汰策略,以腾出空间存储新数据 。

我们可以把缓存想象成一个容器。那么,当这个容器已经满了,我们还想往里面放东西时,我们该怎么办呢?只有两个办法:

第一种方法是直接拒绝,因为容器已经满了,无法容纳更多的东西。

第二种方法是扔掉容器中的一些现有内容,为新内容腾出空间。

深入思考后会发现,当需要决定从容器中率先删除部分现有内容时,一个新的问题便应运而生:究竟该删除哪些内容呢?在实际应用中,常用的解决方案主要有以下几种:

  • 随机选择策略:此策略完全基于随机原则,即从容器已有的内容中随机挑选并移除一部分,这种方式不依赖任何数据特征,一切都交由概率决定。
  • LRU(最近最少使用)策略:该策略依据数据的使用频率和时间进行排序,核心在于保留那些经常被使用的项,而将最长时间未被使用的数据予以删除。通过这种方式,能够确保缓存中留存的是最有可能被再次访问的数据,有效提升缓存命中率。
  • 提前过期淘汰策略:针对那些设置了过期时间的记录,按照过期时间的先后顺序进行排序,优先剔除近期即将过期的数据。这就如同让这些数据提前过期,从而为新数据腾出空间。

除了上述几种常见的策略外,在实际实现缓存功能时,还可以紧密结合具体的业务场景,构建符合自身业务需求的自定义淘汰策略。比如,依据数据的创建日期、上次修改日期、设定的优先级或者访问次数等因素来决定数据的去留。

目前,一些主流的缓存中间件所采用的淘汰机制,大多都遵循上述这些方案。以Redis为例,它为用户提供了多达6种不同的数据淘汰机制,用户可以根据自身业务的实际需求进行灵活选择,从而充分利用有限的缓存空间,仅存储热点数据,将缓存的价值发挥到极致 。具体如下:

从上述图示能够清晰地看到,Redis在随机淘汰和LRU(最近最少使用)策略的实现上表现得更为精准。它支持将淘汰目标范围设定为所有数据,以及专门针对有过期时间的数据。这种策略设计具有较高的合理性。

对于有过期时间的数据而言,其本质上就已被标记为可移除状态,直接将其剔除,通常不会对业务逻辑造成实质性影响。这是因为这些数据在设定的过期时间到达后,本身就应该从缓存中清除,提前进行淘汰操作,不会破坏业务的正常运转。

反观没有过期时间的数据,它们通常需要长期驻留在内存中。这类数据大多是关键的配置数据,或是作为白名单存在的数据,例如用户信息。在许多业务场景中,用户信息若不在缓存里,极有可能被判定为该用户不存在。一旦强行删除这些无过期时间的数据,极有可能引发业务层面的逻辑异常,导致诸如用户认证失败、权限判断错误等问题,进而影响整个业务流程的连贯性和准确性。