前言
缓存
在我们日常的开发中,都要数据库来进行数据的存储,当系统的用户量上来之后,系统需要承受大量的并发操作,特别是对数据库的操作,是主页访问量瞬间较大的时候,单一使用数据库来保存数据的系统会因为面向磁盘,磁盘读/写速度比较慢的问题而存在严重的性能弊端,一瞬间成千上万的请求到来,需要系统在极短的时间内完成成千上万次的读/写操作,这个时候往往不是数据库能够承受的,极其容易造成数据库系统瘫痪,最终导致服务宕机的严重生产问题。
为了克服这些高并发的问题,系统通常会引入缓存技术,将一些经常访问的热点数据放在缓存中,
由于缓存是基于内存的数据库,能够承载大量的并发请求,并且提供一定的持久化功能。
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会导致性能大幅下降