Redis缓存预热、 缓存雪崩、 缓存击穿和缓存穿透

一、缓存预热

1.1.缓存预热是什么?

缓存预热指的是系统上线后,提前将相关的缓存数据直接加载到缓存系统例如redis中。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户直接查询事先被预热的缓存数据即可。

1.2.缓存预热如何解决?

使用 @PostConstruct 初始化白名单数据。

二、缓存雪崩

2.1.缓存雪崩是什么?

缓存雪崩指的是redis 主机宕机, Redis全盘崩溃,或者redis 中有大量key同时过期大面积失效,导致redis中查不到数据,最终请求到数据库中,对数据库服务器造成压力,导致数据库直接挂了的情况。

2.2.发生缓存雪崩的情况

对于缓存雪崩主要存在一下两种情况:

  • redis 主机宕机, Redis全盘崩溃,这时候偏硬件运维;
  • redis 中有大量key 同时过期大面积失效,偏软件开发;

2.3.如何预防和解决缓存雪崩

预防和解决缓存雪崩主要从一下几点方式解决:

  • redis 中 key 设置为永不过期 or 过期时间错开
  • redis 缓存集群实现高可用(主从加哨兵、Redis 集群、开启Redis 持久化机制 aof / rdb尽快恢复缓存集群)
  • 多缓存结合预防雪崩(ehcache 本地缓存 + redis缓存)
  • 服务降级(Hystrix 或者 sentinel 限流 & 降级)

三、缓存穿透

3.1.缓存穿透指的是什么?

请求去查询一条数据,先查询redis,redis中不存在,在去查询mysql,如果mysql里面也是不存在该数据,redis和MySQL都查询不到该条记录,但是这样请求每次都会打到数据库上面去,导致后台数据库压力暴增,这时候redis并没有起到对于MySQL数据库的保护作用,这种现象就称之为缓存穿透。

3.2 如何解决缓存穿透呢?

方式一:空对象缓存或者设置初始值回写增强

如果发生了缓存穿透,我们可以针对要查询的数据,在Redis里存一个和业务部门商量后确定的缺省值(比如,零、负数、defaultNull等)。比如:

键uid:userxxx,值defaultNull作为案例的key和value

先去redis查键uid:userxxx没有,再去mysql查没有获得 ,这就发生了一次穿透现象。但是可以增强回写机制,MySQL也查不到的数据的时候,让Redis存入刚刚查不到的key并以达到保护MySQL的效果。方式如下:

  • 第一次来查询uid:userxxx,redis和mysql都没有数据,这时候返回null给调用者,但是增强回写后,将刚刚查询的key赋值一个defaultNull,写入到redis,第二次来查uid:userxxx,此时redis就有值了。
  • 后续可以直接从Redis中读取default缺省值返回给业务应用程序,避免了把大量请求发送给mysql处理,导致mysql宕机的风险。

但是,上述访问并不是无懈可击的,只能解决key相同的情况,如果有大量不同的key涌入后,redis会缓存大量的空对象缓存,久而久之会导致redis内存占满,出现问题,所以要设置redis中key的过期日期时间

架构图如下:

实现代码如下:修改之前CustomerService中findCustomerById方法,添加上述对应回写增强的代码即可

/**
     * 读取操作,通过双检加锁机制完成
     * @param customerId
     * @return
     */

    public Customer findCustomerById(Integer customerId){
        Customer customer = null;

        //缓存redis的key名称
        String key = CACHE_KEY_CUSTOMER + customerId;

        //1.先去redis查询
        customer = (Customer) redisTemplate.opsForValue().get(key);

        //2.如果redis存在数据,直接返回,如果不存在,就去MySQL中查询
        if(customer == null){
            // 3.对于高QPS的优化,进来就先加锁,保证一个请求操作,让外面的redis等待一下,避免击穿mysql
            synchronized (CustomerService.class){
                //3.1第二次查询redis 加锁
                customer = (Customer) redisTemplate.opsForValue().get(key);

                //4.在去查询MySQL
                customer = customerMapper.selectByPrimaryKey(customerId);

                //5.MySQL存在数据,redis不存在,先从MySQL查询出来的内容写入到redis中
                if(customer != null){
                    //6.把mysql查询到的数据会写到到redis, 保持双写一致性  7天过期
                    redisTemplate.opsForValue().set(key, customer, 7L, TimeUnit.DAYS);
                }else {
                    // defaultNull 规定为redis查询为空、MySQL查询也没有,缓存一个defaultNull标识为空,以防缓存穿透
                    redisTemplate.opsForValue().set(key,"defaultNull",7L, TimeUnit.DAYS);
                }
            }
        }
        return customer;
    }

方式二:Google布隆过滤器Guava解决缓存穿透

布隆过滤器作白名单使用,白名单里面有数据的请求才让通过,没有直接返回拒绝处理。但是存在误判,由于误判率很小,只有1%的请求打到MySQL,可以接受,需要注意全部合法的key都需要放入到Guava版布隆过滤器和redis中,不然数据就是返回null

Google过滤器Guava的参考网址如下:

https://github.com/google/guava/blob/master/guava/src/com/google/common/hash/BloomFilter.java

  • 还是使用的上一节布隆过滤器的模块,在pom中加入Guava过滤器的依赖:
 <!--guava Google 开源的 Guava 中自带的布隆过滤器-->
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>23.0</version>
        </dependency>
  • 在service包中创建 GuavaBloomFilterService,内容如下:
package com.redis.demo.service;

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.ArrayList;

@Service
@Slf4j
public class GuavaBloomFilterService {
    //1.定义常量
    public static final int _1w = 10000;

    //2.定位Guava布隆过滤器,设置初始容量
    public static final int size = 100*_1w;

    //3.误判率,误判率越小,误判的个数也是越少,但是需要的资源空间也是越大
    // 这个数越小所用的hash函数越多,bitmap占用的位越多,默认的就是0.03,5个hash函数,而设置为0.01,则需要7个函数
    public static double fpp = 0.03;

    //4.创建Guava布隆过滤器
    public static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(),size,fpp);

    public void GuavaBloomFilter(){
        //1.先让bloomfilter加入100w白名单数据
        for(int i=0;i<=size;i++){
            bloomFilter.put(i);
        }

        //2.故意取10w个不合法范围内的数据,来进行误判率的演示,这里的list用于保存误判的数据信息
        ArrayList<Integer> list = new ArrayList<>(10 * _1w);

        //3.验证,i从1000001取值
        for(int i=size+1;i<size+(10 * _1w);i++){
            //判断是否存在,这里取的大于100w的值,还存在布隆过滤器,则表示是误判的
            if(bloomFilter.mightContain(i)){
                log.info("被误判了:{}",i);
                list.add(i);
            }
        }

        log.info("误判的总数量::{}",list.size());
    }
}
  • 在controller包下创建  GuavaBloomFilterController 代码如下:
package com.redis.demo.controller;

import com.redis.demo.service.GuavaBloomFilterService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@Api(tags = "Google的Guava布隆过滤器")
@RestController
@Slf4j
public class GuavaBloomFilterController {
    @Resource
    private GuavaBloomFilterService guavaBloomFilterService;

    @ApiOperation("Guava布隆过滤器插入100万样本数据,额外10w(110w)测试是否存在")
    @RequestMapping(value = "/guavaFilter",method = RequestMethod.GET)
    public void guavaBloomFilter(){
        guavaBloomFilterService.GuavaBloomFilter();
    }

}
  • 测试

这里保证redis在启动状态下,然后启董项目模块,访问swagger,http://localhost:7070/swagger-ui.html#,执行接口:

查看控制台:

现在共有10w条数据,最终的误判数是3033,然后误判率是3.033%,约等于3%;

四、缓存击穿

4.1 缓存击穿是什么?

缓存击穿就是大量请求同时查询一个key时,此时这个key刚好失效了,就会导致大量的请求到数据库上面去,也就是热点key突然都失效了,MySQL承受高并发量,进而导致数据库宕机

4.2 缓存击穿如何解决?

缓存击穿的解决方式有一下几种:

  1. 差异失效时间,对于访问频繁的热点key,直接就不设置过期时间
  2. 互斥更新,采用双检加锁(多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个 互斥锁来锁住它。其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。)

4.2.缓存击穿案例演示

以聚划算功能为案例,演示缓存击穿,问题是热点key5突然失效导致了缓存击穿,在通过redis实现聚划算商品推荐之后,每次推荐一组商品,到期后需要更换另一组商品。这时候最危险的就是,到期的商品key删除了,而新的商品key并没有加载到redis中,就会导致缓存击穿。解决思路如下:

  • 功能实现肯定是需要使用 Redis,而不是MySQL实现
  • 先需要将MySQL里面参加活动的数据抽取进Redis,一般采用定时器扫描来决定上线活动还是下线取消。
  • 支持分页功能,一页20条记录
  • 在redis中List数据类型的Lpush即可实现

上述案例的实现还是在之前的功能模块实现:

在entities中创建实体类:Product类

package com.redis.demo.entities;

import io.swagger.annotations.ApiModel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@ApiModel(value = "聚划算活动product信息")
@AllArgsConstructor
@NoArgsConstructor
@Data
public class Product {
    // 产品id
    private Long id;
    // 产品名称
    private String name;
    // 产品价格
    private Integer price;
    // 产品详情
    private String detail;
}

在service中创建JHSTaskService类(采用定时器将参加活动的商品加入redis)

package com.redis.demo.service;

import com.redis.demo.entities.Product;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.TimeUnit;

@Service
@Slf4j
public class JHSTaskService {
    public static final String JHS_KEY = "jhs";
    public static final String JHS_KEY_A = "jhs:a";
    public static final String JHS_KEY_B = "jhs:b";

    @Resource
    private RedisTemplate redisTemplate;

    private List<Product> getProductFromMySql(){
        ArrayList<Product> list = new ArrayList<>();
        for(int i=0;i<=20;i++){
            Random random = new Random();
            int id = random.nextInt(1000);
            Product product = new Product((long) id, "product" + i, i, "detail");
            list.add(product);
        }
        return list;
    }

    /**
     * 单缓存
     */
    @PostConstruct
    public void initJHS(){
        log.info("启动定时器 天猫聚划算模拟开始 ===============");

        //1.使用线程模拟定时任务,后台任务定时将MySQL里面的特价商品刷新到redis
        new Thread(()->{
            while (true){
                //2.模拟从MySQL中查询到数据,加到redis中并返回给前端
                List<Product> productFromMySql = this.getProductFromMySql();
                //3.采用redis List数据结构的lpush命令来实现存储
                redisTemplate.delete(JHS_KEY); //删除已有的聚划算数据

                //4.加入最新的数据到redis中
                redisTemplate.opsForList().leftPushAll(JHS_KEY,productFromMySql);

                //5.暂停60s,间隔1分钟(60s)执行一次,模拟聚划算一天执行的参加活动的品牌
                try {
                    TimeUnit.SECONDS.sleep(60);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"t1").start();
    }

    @PostConstruct         // 测试双缓存
    public void initJHSAB(){
        log.info("启动AB的定时器 天猫聚划算模拟开始 ===============");
        // 1.用线程模拟定时任务,后台任务定时将mysql里面的特价商品刷新的redis
        new Thread(()->{
            //2.模拟从MySQL查到数据,加到redis并返回
            List<Product> productFromMySql = this.getProductFromMySql();

            //3.先更新缓存B,B的缓存过期时间超过了缓存A的时间,如果A突然失效,还有B在兜底,能有效防止击穿
            redisTemplate.delete(JHS_KEY_B);
            redisTemplate.opsForList().leftPushAll(JHS_KEY_B,productFromMySql);
            //设置缓存B的过期时间为1天多10s,这个是因为缓存A的过期是就为1天(1天是86400L),给10s中的时间差
            redisTemplate.expire(JHS_KEY_A, 86410L, TimeUnit.SECONDS);

            // 4.再更新A缓存
            redisTemplate.delete(JHS_KEY_A);
            redisTemplate.opsForList().leftPushAll(JHS_KEY_A,productFromMySql);
            //设置缓存A的过期时间为1天(1天是86400L)
            redisTemplate.expire(JHS_KEY_A, 86400L, TimeUnit.SECONDS);

            // 5.暂停1分钟,间隔1分钟执行一次,模拟聚划算一天执行的参加活动的品牌
            try {
                TimeUnit.MINUTES.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            },"t1").start();
    }
}

在controller中创建JHSProductController类

package com.redis.demo.controller;

import cn.hutool.core.collection.CollectionUtil;
import com.redis.demo.entities.Product;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.List;

@RestController
@Slf4j
@Api(tags = "聚划算商品列表接口")
public class JHSProductController {
    public static final String JHS_KEY = "jhs";
    public static final String JHS_KEY_A = "jhs:a";
    public static final String JHS_KEY_B = "jhs:b";

    @Resource
    private RedisTemplate redisTemplate;

    @RequestMapping(value = "/product/find", method = RequestMethod.GET)
    @ApiOperation("聚划算案例,每次1页每页5条显示")
    public List<Product> find(int page, int size) {
        List<Product> list=null;

        //设置读取读取的条数(如果是要读取第5页,那么应该知道前4页占了多少条数据)
        int start = (page - 1) * size;
        //这里计算开始读取减去1,是因为索引从0开始的
        int end = start + size - 1;

        try {
            //采用redis中List结构里面的range命令来实现加载和分页
            list = redisTemplate.opsForList().range(JHS_KEY, start, end);
            //CollectionUtils.isEmpty()作用:判断参数null或者其size0
            if(CollectionUtil.isEmpty(list)){
                //如果这时候为空就要查询MySQL
            }

            log.info("参加活动的商家: {}", list);

        }catch (Exception e){
            //出现了异常吗,一般redis宕机了或者redis由于网络抖动导致的timeout
            log.error("jhs  exception{}", e);
            e.printStackTrace();
            // 后续可以加入重试机制 再次查询mysql
        }

        return list;
    }

    @RequestMapping(value = "/product/findAB", method = RequestMethod.GET)
    @ApiOperation("AB双缓存架构,防止热点key突然消失")
    public List<Product> findAB(int page, int size){
        List<Product> list = null;

        long start = (page - 1) * size;
        long end = start + size - 1;

        try {
            //采用redis List结构里面的range命令来实现加载和分页
            list = redisTemplate.opsForList().range(JHS_KEY_A, start, end);
            //判断是否为空
            if(CollectionUtil.isEmpty(list)){
                log.info("-----A缓存已经过期或活动结束了,记得人工修补,B缓存继续顶着");
                // A没有缓存来找B
                list = redisTemplate.opsForList().range(JHS_KEY_B, start, end);

                if (CollectionUtils.isEmpty(list)){
                    //如果还是没有则到mysql查询
                }
            }
            log.info("参加活动的商家: {}", list);

        }catch (Exception e){
            //出现了异常吗,一般redis宕机了或者redis由于网络抖动导致的timeout
            log.error("jhs  exception{}", e);
            e.printStackTrace();
            // 后续可以加入重试机制 再次查询mysql
        }
        return list;
    }
}

测试

先启动redis在启动项目,查看redis中jhs:a和jsh:b数据是否加载完成

到期之后会换成另一组数据,模拟聚划算,活动时间到期了,换成下一批商品

posted @ 2023-04-10 14:36  酒剑仙*  阅读(428)  评论(0)    收藏  举报