Redis缓存预热+缓存雪崩+缓存击穿+缓存穿透

本文详细介绍了缓存预热、缓存雪崩、穿透、击穿的概念及解决方案,包括使用Guava布隆过滤器应对缓存穿透,以及Redis集群、服务降级等策略。通过实例讲解了SpringBoot中如何应用这些技术,确保系统性能和稳定性。
摘要由CSDN通过智能技术生成

一、面试题

1.缓存预热、雪崩、穿透和击穿分别是什么?你遇到过几种情况?
2. 缓存预热你是怎么做的?
3. 如何避免或者减少缓存雪崩?
4. 击穿和穿透有什么区别?他们是一个意思还是截然不同?
5. 穿透和击穿你有什么解决方案?如何避免?
6. 假如出现了缓存不一致,你有什么修补方案?

二、详解

1. 缓存预热

简介
在这里插入图片描述
Redis缓存预热是指在系统上线后,将相关的缓存数据直接加载到缓存系统,以避免在用户请求的时候,先查询数据库,然后再将数据回写到缓存。这样可以极大减少对数据库的压力,提高系统的响应速度和性能。缓存预热主要应用于秒杀场景,例如618购物节。

实现方案

  1. 数据入库后,程序员可以手动将这些数据从mysql中同步到redis中
  2. 通过中间件来完成,例如使用阿里巴巴的canal实现双写一致性,或者使用java的@PostConstruct注解

2. 缓存雪崩

  • 简介

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

在这里插入图片描述
给不同的Key的TTL添加随机值,比如将缓存失效时间分散开,可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

在这里插入图片描述
① 利用Redis集群提高服务的可用性,比如哨兵模式、集群模式;
② 给缓存业务添加降级限流策略,比如可以在ngxin或spring cloud gateway中处理;(注:降级可做为系统的保底策略,适用于穿透、击穿、雪崩)
③ 给业务添加多级缓存,比如使用Guava或Caffeine作为一级缓存,redis作为二级缓存等;

  • 解决方案
  1. redis中的key设置为永不过期或过期时间错开
  1. redis缓存集群实现高可用
  1. 多缓存结合预防雪崩:ehcache本地缓存+redis缓存
  1. 服务降级:服务降级是在面对系统负载过高、资源不足或外部依赖故障等异常情况下,通过临时屏蔽某些功能或改变服务行为,以保证核心功能的可用性和性能稳定性的一种策略。服务降级的目的是在极端或异常情况下提供有限但可靠的服务,而不是完全失败或导致系统崩溃。(Hystrix或者阿里sentinel限流或者降级)
  1. 人民币玩家:阿里云-云数据库redis版

3. 缓存穿透

请求去查询一条数据,先查redis无,后查mysql无,都查询不到数据。但是请求每次都打到mysql中了,导致后台数据库压力暴增,这种现象我们称为缓存穿透,redis在这种情况下就成了摆设。简单来说,就是一个数据既不在redis中也不再mysql中,数据库存在被多次暴击的风险。

对于缓存穿透的问题,最害怕的是恶意攻击。它的解决方案有两个:

  • 空对象缓存或者缺省值

这种解决方案叫做回写增强,如果发生了缓存穿透,我们可以针对查询的数据,在redis中存一个和业务部门商量后的缺省值。mysql查不到数据的话,也让redis存入刚刚查不到的key并保护起来。但是这种方法架不住黑客的恶意攻击,它只能解决key相同的情况,如果每次key不同,还是会发生缓存穿透且redis中的垃圾key后越来越多。

  • Google布隆过滤器Guava解决缓存穿透

在这里插入图片描述

4. Google布隆过滤器Guava

Guava中的布隆过滤器是比较权威的,实际项目中用的比较多,下面结合案例学习一下Guava的使用。

案例一

  • 案例架构

在这里插入图片描述

  • SpringBoot环境搭建:

Guava依赖

        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>30.1-jre</version>
        </dependency>

Pom.xml

spring:
  redis:
    host: 127.0.0.1
    port: 6379
    #password:
    lettuce:
      pool:
        max-active: 8
        max-wait: -1ms
        max-idle: 8
        min-idle: 0

redis配置类

@Configuration
public class RedisConfig {
    //这里先不配置,测试一下待会的错误
    @Bean
    public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(lettuceConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        redisTemplate.setHashValueSerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}
  • 使用:
@SpringBootTest
public class RedisTest {
    @Autowired
    private RedisTemplate redisTemplate;

    @Test
    public void testGUavaWithBloomFilter(){
        //1. 创建guava布隆过滤器,过滤数据类型是int,然后样本数据是100
        BloomFilter<Integer> integerBloomFilter = BloomFilter.create(Funnels.integerFunnel(), 100);
        //2 . 判断指定的元素是否存在
        System.out.println(integerBloomFilter.mightContain(1));
        System.out.println(integerBloomFilter.mightContain(2));
        //3. 将元素添加到布隆过滤器
        integerBloomFilter.put(1);
        integerBloomFilter.put(2);
        //4 . 再次判断指定的元素是否存在
        System.out.println(integerBloomFilter.mightContain(1));
        System.out.println(integerBloomFilter.mightContain(2));
    }
}

在这里插入图片描述

案例二

@SpringBootTest
public class RedisTest {
    @Autowired
    private RedisTemplate redisTemplate;

    //1. 定义一个常量
    public static final int _1W = 10000;
    //2. 定义Guava的初始容量
    public static final int SIZE = 100 * _1W;
    //3. 误判率,它越小误判的数量也就越少(思考:误判率是否是无限小?)
    public static double fpp = 0.03;
    //4. 创建布隆过滤器
    private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), SIZE, fpp);

    @Test
    public void testGUavaWithBloomFilter() {
        //1. 先让bloomfilter加入100万白名单数据
        for (int i = 0; i < SIZE; i++) {
            bloomFilter.put(i);
        }
        //2. 故意取10w个不在合法范围内的数据,进行误判率的演示
        ArrayList<Integer> list = new ArrayList<>(10 * _1W);
        //3. 验证
        for (int i = SIZE + 1; i <= SIZE + (10 * _1W); i++) {
            if(bloomFilter.mightContain(i)){
                System.out.println(i+"被误判了");
                list.add(i);
            }
        }
        System.out.println("误判总数量:"+list.size());
    }
}

在这里插入图片描述

发现误判数量和误判率fpp紧密联系,是不是我们把误判率设置的越低就误判数量就会越少?

为什么会出现上面问题,我们简单分析一下Guava的源码:

在这里插入图片描述
进入create方法

 public static <T> BloomFilter<T> create(Funnel<? super T> funnel, int expectedInsertions, double fpp) {
        return create(funnel, (long)expectedInsertions, fpp);
    }

调用了重载方法create

static <T> BloomFilter<T> create(Funnel<? super T> funnel, long expectedInsertions, double fpp, Strategy strategy) {
        //参数合法性校验
        Preconditions.checkNotNull(funnel);
        Preconditions.checkArgument(expectedInsertions >= 0L, "Expected insertions (%s) must be >= 0", expectedInsertions);
        Preconditions.checkArgument(fpp > 0.0, "False positive probability (%s) must be > 0.0", fpp);
        Preconditions.checkArgument(fpp < 1.0, "False positive probability (%s) must be < 1.0", fpp);
        Preconditions.checkNotNull(strategy);
        if (expectedInsertions == 0L) {
            expectedInsertions = 1L;
        }
        //布隆过滤器的底层是bit数组,这里就定义了bit数组的长度,是通过数据量和误判率求出来的
        long numBits = optimalNumOfBits(expectedInsertions, fpp);
        //定义hash函数的数量
        int numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, numBits);

        try {
            return new BloomFilter(new BloomFilterStrategies.LockFreeBitArray(numBits), numHashFunctions, funnel, strategy);
        } catch (IllegalArgumentException var10) {
            throw new IllegalArgumentException((new StringBuilder(57)).append("Could not create BloomFilter of ").append(numBits).append(" bits").toString(), var10);
        }
    }

在这里插入图片描述
上面说的numBits就是我们bit数组的长度,由于我们设置的误判率为0.03,数据量为100万,就可以算出numBits(即实际的容量并不是100万)

在这里插入图片描述
然后根据numBits算出需要的hash函数是五个。所以结论是,Guava底层使用的bit数组长度和hash函数的数量都是和我们设置的误判率紧密相关的

在这里插入图片描述

Guava是用java实现的,它实现了布隆过滤器和Redis的解耦

5. 缓存击穿

缓存击穿是指当某个热点数据(被大量并发访问)在Redis缓存中过期时,所有对该数据的访问都会直接导致数据库承受巨大的压力,从而可能造成数据库崩溃。(简单来说就是热点key失效,暴打mysql)

所以一般的技术部门需要知道热点key是哪些,做到心里有数防止击穿

在这里插入图片描述

缓存击穿的解决方案:

  • 差异失效时间,对于频繁访问的热点key,干脆就不设置过期时间(解决key有ttl失效导致缓存击穿的问题)
  • 互斥更新,采用双检加锁策略(解决更新数据时,删除redis缓存,但mysql数据库还没更新完,这段时间发生缓存击穿问题)

6. 缓存击穿案例—天猫聚划算

  • 场景

在商场促销的过程中,每个特价商品的促销是有时间规定的,假设现在某个商品在聚划算上面要促销三天,然后三天结束后的一个瞬间我们需要把这个商品替换成新的商品,而在替换的这一个瞬间,redis需要清掉当前商品的数据,若此时有不怀好意者取海量访问该商品,由于redis已经没有该商品的数据了,就会发生缓存击穿,那么如何解决这个问题?

步骤说明
1高并发场景,Mysql不适合
2先把mysql里面参加活动的数据抽进redis,一般采用定时任务扫描来决定上线活动还是下线活动
3支持分页功能,一页20条记录
  • 环境搭建

创建实体类

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {
    //产品id
    private long id;
    //产品名词
    private String name;
    //产品价格
    private Integer price;
    //产品详情
    private String detail;
}

创建业务类

采用定时起将参与聚划算活动的特价商品新增入redis中

@Service
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";

    @Autowired
    private RedisTemplate redisTemplate;

    //模拟数据库取数据
    private List<Product> getProductsFromMysql(){
        List<Product> list=new ArrayList<>();
        for(int i=0;i<=20;i++){
            Random random=new Random();
            int id= random.nextInt(10000);
            Product product=new Product((long)id,"product"+i,i,"detail");
            list.add(product);
        }
        return list;
    }
    
    @PostConstruct
    public void intiJHS(){
        System.out.println("启动定时器天猫聚划算模拟功能开始");
        //1. 用多线程模拟定时任务,后台任务定时将mysql里面的参加活动的商品刷新到redis中
        new Thread(()-> {
            while(true){
                //模拟mysql查询数据
                List<Product> products = this.getProductsFromMysql();
                //采用redis list数据结构的lpush命令来实现存储
                redisTemplate.delete(JHS_KEY);
                //加入最新的数据给redis
                redisTemplate.opsForList().leftPush(JHS_KEY,products);
                //暂停1分钟,间隔1分钟执行一次
                try{
                    TimeUnit.MINUTES.sleep(1);
                }catch (Exception e){
                    e.printStackTrace();
                }
               
            }
        },"thread1").start();
    }
}

实现Controller

@RestController
public class JHSProductController {
    @Autowired
    private RedisTemplate redisTemplate;

    public List<Product> find(int page,int size){
        List<Product> list=null;
        long start=(page-1)*size;
        long end=start+size-1;
        try {
            list=redisTemplate.opsForList().range(JHS_KEY,start,end);
            if(CollectionUtils.isEmpty(list)){
                //TODO mysql查询数据
            }
            System.out.println("参加或的的商家:"+list);
        }catch (Exception e){
            //出异常了,redis宕机或者redis网络抖动导致timeout
            e.printStackTrace();
        }finally {

        }
        return list;
    }
}

启动微服务

在这里插入图片描述

可以发现数据全部插入了

  • BUG和隐患说明

热点key失效导致可怕的缓存击穿:

两个黄色代码不是原子的,如果删除redis缓存后,第二条语句还没有结束,这个期间如果有请求就会把请求打到mysql

在这里插入图片描述

  • 解决方案

互斥更新、随机退避和差异失效时间

  1. 所谓的互斥更新就是前面介绍的双检加锁策略
  2. 所谓差异失效时间就是双缓存策略
    在这里插入图片描述
  • 修改代码(双缓存)
@PostConstruct
    public void initJHSAB(){
        System.out.println("启动AB定时器计划任务天猫聚划算功能模拟.......");
        //1. 用多线程模拟定时任务,后台任务定时将mysql里面的参加活动的商品刷新到redis中
        new Thread(()-> {
            while(true){
                //模拟mysql查询数据
                List<Product> products = this.getProductsFromMysql();
                //先更新B缓存且让B缓存过期时间超过A缓存,如果A失效了还有B
                redisTemplate.delete(JHS_KEY_B);  //先删除B
                redisTemplate.opsForList().leftPushAll(JHS_KEY_B,products);
                redisTemplate.expire(JHS_KEY_B,86410L,TimeUnit.SECONDS);
                //再更新A缓存
                redisTemplate.delete(JHS_KEY_A);
                redisTemplate.opsForList().leftPushAll(JHS_KEY_A,products);
                redisTemplate.expire(JHS_KEY_A,86400L,TimeUnit.SECONDS);
                //暂停1分钟,间隔1分钟执行一次
                try{
                    TimeUnit.MINUTES.sleep(1);
                }catch (Exception e){
                    e.printStackTrace();
                }
            }
        },"thread1").start();
        
    }
@RestController
public class JHSProductController {
    @Autowired
    private RedisTemplate redisTemplate;

    public List<Product> find(int page,int size){
        List<Product> list=null;
        long start=(page-1)*size;
        long end=start+size-1;
        try {
            list=redisTemplate.opsForList().range(JHS_KEY_A,start,end);
            if(CollectionUtils.isEmpty(list)){
                System.out.println("A缓存失效");
                list=redisTemplate.opsForList().range(JHS_KEY_B,start,end);
                if(CollectionUtils.isEmpty(list)){
                    //TODO mysql查询数据
                }
            }
            System.out.println("参加或的的商家:"+list);
        }catch (Exception e){
            //出异常了,redis宕机或者redis网络抖动导致timeout
            e.printStackTrace();
        }finally {

        }
        return list;
    }
}

在这里插入图片描述

三、总结

在这里插入图片描述

  • 19
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
缓存穿透缓存击穿缓存雪崩是常见的缓存问题,下面是关于Redis缓存穿透缓存击穿缓存雪崩的介绍: 1. 缓存穿透缓存穿透是指当一个请求查询一个不存在于缓存中的数据时,由于缓存无法命中,请求会直接访问数据库。这种情况下,如果有大量的请求查询不存在的数据,会导致数据库压力过大,影响系统性能。 2. 缓存击穿缓存击穿是指当一个热点数据的缓存过期或失效时,大量的请求同时访问该数据,导致缓存无法命中,请求会直接访问数据库。这种情况下,数据库会承受巨大的压力,可能导致数据库崩溃。 3. 缓存雪崩缓存雪崩是指当缓存中的大量数据同时过期或失效时,大量的请求会直接访问数据库,导致数据库压力剧增,性能下降甚至系统崩溃。缓存雪崩通常是由于缓存服务器故障、缓存设置不合理或者缓存数据过期时间设置不当等原因引起的。 为了避免缓存穿透缓存击穿缓存雪崩问题,可以采取以下措施: - 缓存穿透:可以在应用层对查询的数据进行校验,如果数据不存在,则不进行缓存操作,避免大量无效的请求访问数据库。 - 缓存击穿:可以互斥锁或分布式锁来保护热点数据的问,当缓存失效时,只允许一个请求访问数据库并更新缓存,其他请求等待缓存更新完成后再从缓存中获取数据。 - 缓存雪崩:可以采用多级缓存缓存预热、设置合理的缓存过期时间等策略来避免大量缓存同时失效,保证系统的稳定性和性能。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值