天天看点

如何解决缓存穿透,缓存击穿,缓存雪崩前言

前言

缓存

在我们日常的开发中,都要数据库来进行数据的存储,当系统的用户量上来之后,系统需要承受大量的并发操作,特别是对数据库的操作,是主页访问量瞬间较大的时候,单一使用数据库来保存数据的系统会因为面向磁盘,磁盘读/写速度比较慢的问题而存在严重的性能弊端,一瞬间成千上万的请求到来,需要系统在极短的时间内完成成千上万次的读/写操作,这个时候往往不是数据库能够承受的,极其容易造成数据库系统瘫痪,最终导致服务宕机的严重生产问题。

为了克服这些高并发的问题,系统通常会引入缓存技术,将一些经常访问的热点数据放在缓存中,

由于缓存是基于内存的数据库,能够承载大量的并发请求,并且提供一定的持久化功能。

Redis非关系型数据库就是缓存技术中的一种

如何解决缓存穿透,缓存击穿,缓存雪崩前言

引入缓存后的系统架构图

  • 请求数据时,先去查看缓存中有没有需要的数据
  • 如果缓存中有(缓存命中),就直接返回
  • 如果缓存中没有,就去请求数据库,并将结果缓存,然后返回
    如何解决缓存穿透,缓存击穿,缓存雪崩前言

但是引入Redis缓存技术又有可能出现缓存穿透,缓存击穿,缓存雪崩等问题。

缓存穿透

定义

缓存穿透是指请求一个一定不存在的数据,由于缓存中没有,系统就会去查询数据库,而数据库也没有,从数据库查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询

恶意的攻击者可能利用这个这个漏洞,不断高并发的请求这个没有的数据,导致数据库无法承载,甚至宕机。

如何解决缓存穿透,缓存击穿,缓存雪崩前言

解决方案

空结果缓存

  • 即使数据库中没有这个数据,系统也将这个这个结果进行缓存,并设置短暂的过期时间
  • 当下一个请求进来是,就可以在缓存中名字这个数据,并将空结果返回
  • 如果后续数据更新,这个数据存在数据库中了,由于外面设置了过期时间,缓存中很快就会有这个数据了
public ResponseVo<List<CategoryVo>> selectAll() {
        String categoryJson = stringRedisTemplate.opsForValue().get("category");
        //如果缓存中没有,查数据库
        if (StringUtils.isEmpty(categoryJson)) {
        	//查询数据库
            List<CategoryVo> categoryVoList = selectAllFromDb();
            if (StringUtils.isEmpty(categoryVoList)) {
                //库中没有此数据,存入一个空值,过期时间为5分钟,解决缓存穿透问题
                stringRedisTemplate.opsForValue().set("category","",5, TimeUnit.MINUTES);
            }
            return ResponseVo.success(categoryVoList);
        }
        List<CategoryVo> categoryVoList = gson.fromJson(categoryJson,new TypeToken<List<CategoryVo>>(){}.getType());
        return ResponseVo.success(categoryVoList);
    }
           

缓存雪崩

定义

  • 缓存雪崩是指在我们设置缓存默认采用了相同的过期时间,导致缓存在某一时刻全部失效
  • 大量的请求全部转发到数据库,数据库的瞬时流量过大,导致数据库无法承载而宕机。

解决方案

将数据放入缓存时,设置随机过期时间,避免缓存的数据同时失效

如何解决缓存穿透,缓存击穿,缓存雪崩前言

缓存击穿

定义

  • 对于一些设置了过期时间的数据,在某些时间节点被超高并发地访问,是一种非常“热点”的数据。
  • 这个时候,缓存数据突然过期,大量的请求高并发的查询数据库,导致数据库瞬时流量过大
  • 这个和缓存雪崩的区别在于这里针对某一个数据缓存,而缓存雪崩是是很多很多数据同时失效。

解决方案

  • 加锁,给查询数据库的操作加锁,大量的并发请求同时需要查询数据库,同时竞争一个锁
  • 只有得到锁的请求,才能去查询数据库
  • 当请求查询数据库后,将数据缓存,其他请求就可以命中缓存
如何解决缓存穿透,缓存击穿,缓存雪崩前言

业务流程

  • 先去缓存中判断缓存中有没有
  • 没有就去查数据库,只有一个线程可以获得锁
  • 查数据库,获得结果
  • 将结果放入缓存
public List<CategoryVo> selectAllFromDb() {
        //加本地锁,解决缓存击穿
        synchronized (this){
            List<Category> categories = categoryMapper.selectList(null);
            List<CategoryVo> categoryVoList = new ArrayList<>();
            for(Category category : categories){
                if(category.getParentId().equals((ROOT_PARENT_ID))){
                    CategoryVo categoryVo = new CategoryVo();
                    BeanUtils.copyProperties(category,categoryVo);
                    categoryVoList.add(categoryVo);
                }
            }
            //查询子目录
            findSubCategory(categoryVoList,categories);
            //查到结果后将结果序列化,写入缓存,并设置一个随机的过期时间,解决缓存雪崩问题
            //生成5-15之间的一个随机数,设置缓存随机在5-15分钟内过期
            Random random = new Random();
            int randomNum = random.nextInt(10)+5;
            stringRedisTemplate.opsForValue().set("category",
            	gson.toJson(categoryVoList),randomNum,TimeUnit.MINUTES);

            return categoryVoList;
        }
    }
           

锁不住

  • 这种情况其实是锁不住的,我们可以想象这样一种场景:
  • 大量的请求都在竞争锁,只有一个线程获得了锁,去执行查询数据库的操作
  • 其他线程被阻塞,待到锁释放,这些线程还是会一一竞争锁,去查询数据库
  • 导致缓存并没有生效,所有我们应该再线程获得锁之后,再去缓存中判断
  • 缓存中确实没有,我们才去查数据库
    如何解决缓存穿透,缓存击穿,缓存雪崩前言

    尽管把判断缓存,和查数据库都放在一个同步代码块中

    仍然不能保证只查一次数据库,再来想象一种场景

  • 当第一个竞争到锁的线程查询数据库完成,释放锁
  • 还没来得及将结果放入缓存,第二个线程竞争到了锁
  • 判断缓存中没有(第一个线程没来得及放入缓存)
  • 第二个线程再查询了一次数据库,或者还有第三个,第四个
  • 所有我们应该把放入缓存的操作都放在同一个同步代码块中
  • 这样就可以保证只查了一次数据库
    如何解决缓存穿透,缓存击穿,缓存雪崩前言

总结

  • 以上只是业务较为简单的情况下的解决方案,而且使用synchronized会导致性能大幅下降