一行代码解决Redis缓存击穿的问题

5,291 阅读7分钟

背景

为什么写这篇文章呢,目前网上流传的文章针对缓存击穿的解决方案落地性太差(什么布隆过滤器啊,布谷过滤器啊,等等诸如此类解决方案),其实这类方案并不适合在项目中直接落地。

其实我们在项目中使用的时候,只需要一个注解缓存击穿的这个问题就能得到解决了,并不需要搞那么复杂;什么布隆过滤器啊,真正会用到这个的可以说很少。

目前的方案

首先,为什么说目前网上流传的方案,落地性差呢,因为都缺乏一个可以和SpringBoot结合起来的真实场景,基本上都脱离了SpringBoot,只站在Java这个层级去分析。那问题就来了,现在还有不用SpringBoot的公司么?因此,本文尝试将该方案和SpringBoot结合起来,讲一个确实可行,可以落地的方案。

布隆过滤器

布隆过滤器原理其实就是一个过滤器,用于快速检索一个元素是否在一个集合中;那么当一个请求来的时候,快速判断这个请求的key是否在指定集合中!如果在,说明有效,则放行。如果不在,则无效拦截。

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>19.0</version>
</dependency>

谷歌实现的jar包,具体实现代码我就不贴了,百度一下就有。

该方案最大的一个问题是布隆过滤器不支持反向删除操作 ,例如你的项目里活跃的key的数量只有1000w个,但是全部key数量有5000w个,那这5000w个key会全部存在布隆过滤器里,而且会很吃内存。

直到某一天,你会发现这个过滤器太拥挤了,误判率太高,不得不进行重建!

布谷过滤器

那么,为了解决布隆过滤器查询性能弱、空间利用效率低、不支持反向操作等问题,又有一篇文章诞生了,主张用布谷过滤器来解决缓存击穿问题!

但是,神奇的事情来了,基本上所有的文章都在说布谷过滤器多么多么牛逼,却没有任何落地的方案。 网上随便搜下,都是在说这个过滤器如何牛逼如何厉害,缺根本没有实际落地方案,甚至连demo也没有;记住,我们平时写代码,一定是怎么方便怎么来。

真正的解决方案

假设,你此刻用的是springboot-2.x的版本,你为了能够连接redis,你在pom文件里加入如下依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

然后再修改修改application.yml:

spring:
  datasource:
    ...
  redis:
    database: ...
    host: ...
    port: ...
......

说到这里,就不得不说一下spring-cache了,Spring3.1之后,引入了注解缓存技术,其本质上不是一个具体的缓存实现方案,而是一个对缓存使用的抽象,通过在既有代码中添加少量自定义的各种annotation,即能够达到使用缓存对象和缓存方法的返回对象的效果。Spring的缓存技术具备相当的灵活性,不仅能够使用SpEL(Spring Expression Language)来定义缓存的key和各种condition,还提供开箱即用的缓存临时存储方案,也支持和主流的专业缓存集成。

例如:我们在代码中经常有这么一段逻辑,在目标方法执行前,会根据key先去缓存中查询看是否有数据,有就直接返回缓存中的key对应的value值,不再执行目标方法;没有则执行目标方法,去数据库查询出对应的value,并以键值对的形式存入缓存。

如果我们不使用例如spring-cache的注解框架,你的代码中会充斥着大量冗余代码,而用了该框架后,以@Cacheable注解为例, 该注解在方法上,表示该方法的返回结果是可以缓存的。

也就是说,该方法的返回结果会放在缓存中,以便于以后使用相同的参数调用该方法时,会返回缓存中的值,而不会实际执行该方法。

那么,你的代码只需要这么写

@Override
@Cacheable("menu")
public Menu findById(String id) {
    Menu menu = this.getById(id);
    if (menu != null){
        System.out.println("menu.name = " + menu.getName());
    }
    return menu;
}

在这个例子中,findById 方法与一个名为 menu 的缓存关联起来了。调用该方法时,会检查 menu 缓存,如果缓存中有结果,就不会去执行方法了。

说到这里,其实都是大家懂得东西!!接下来开始我们的主题:如何解决缓存击穿问题!顺便讲讲穿透和雪崩问题!

来来来,我们回忆一下缓存击穿,穿透以及缓存雪崩的概念!

缓存穿透

在高并发下,查询一个不存在的值时,缓存不会被命中,导致大量请求直接落到数据库上,如活动系统里面查询一个不存在的活动。(缓存穿透是指,请求的是缓存和数据库中都没有的数据。)

简单粗暴的解决方案

将缓存中查不到和数据库中都查不到的key缓存一个null,但是这样随之而来的一个问题就是有些恶意攻击者,for循环故意传很多查不到的key,那这样就会缓存很多空对象。

spring-cache

spring-cache中,有一个配置是这样的:

spring.cache.redis.cache-null-values=true

带上该配置后,就可以缓存null值了,需要注意的是,这个缓存时间要设的少一点,例如15秒就够,如果设置过长,会导致正常的缓存也无法使用。

缓存击穿

在高并发下,对一个特定的值进行查询,但是这个时候缓存正好过期了,缓存没有命中,导致大量请求直接落到数据库上,如活动系统里面查询活动信息,但是在活动进行过程中活动缓存突然过期了。(缓存击穿是指,请求的是缓存没有,而数据库中有的数据。) 解决击穿最简单的一个方法就是限流,至于怎么限,可以根据公司的架构来决定;甚至使用一些限流的组件也是可以的。 同时,这里就要说spring-cahce的另一个配置了。在缓存过期之后,如果多个线程同时请求对某个数据的访问,会同时去到数据库,导致数据库瞬间负荷增高。Spring4.3为@Cacheable注解提供了一个新的参数“sync”(boolean类型,缺省为false),当设置它为true时,只有一个线程的请求会去到数据库,其他线程都会等待直到缓存可用。这个设置可以减少对数据库的瞬间并发访问。看到这里,这不就是一个限流方案吗,Spring 都帮我们做了这个事。 所以解决方法就是,加一个属性sync=true,就行。代码就像下面这样(一个注解搞定了):

@Cacheable(cacheNames="menu", sync="true")

分布式系统解决方案

springaop有套路的,比如@TransactionalAdviceTransactionInterceptor,那么cache也对应对一个CacheInterceptor,我们只要去改CacheInterceptor,这个切面就能解决。在里头做一个分布式锁!伪代码如下(这边只是提供一个思路):

flag := 取分布式锁
if flag {
    //走数据库查询,并缓存结果
}{
    //睡眠一段时间,再次尝试获取key的值
}

缓存雪崩

在高并发下,大量的缓存key在同一时间失效,导致大量的请求落到数据库上,如活动系统里面同时进行着非常多的活动,但是在某个时间点所有的活动缓存全部过期。 那么针对该问题,最简单的解决方法就是,过期时间加随机值。 但是很麻烦的是,我们在使用@Cacheable注解的时候,原生功能没法直接设置随机过期时间的。 这个老实说,真没啥好方法,只能自己继承RedisCache,对其增强,改写其中的put方法,带上随机时间。