经过缓存这篇文章的概述,已经对缓存有了初步的了解和认知。在本篇文章中,主要是通过代码来实现缓存的应用,以及在使用缓存过程中出现的经典问题。
简单应用
需求:根据菜品id来查询缓存
流程:① 从缓存中查询,如果菜品存在,那么直接返回;② 缓存中不存在,从数据库中查询;③ 如果存在,那么写入缓存并返回;④ 不存在就直接返回一个null。
@Service
public class DishService extends ServiceImpl<DishMapper, Dish> {
@Resource
private StringRedisTemplate stringRedisTemplate;
public Dish queryDishById(Long id) {
// TODO 在Redis中查询该菜品是否存在
String dishJson = this.stringRedisTemplate.opsForValue().get(Constants.DISH_KRY + id);
// TODO 判断是否存在
if(StrUtil.isNotBlank(dishJson)) { // StrUtil.isNotBlank() 表示不为null,不为空字符串,不为不可见字符
// 存在直接返回
return JSONUtil.toBean(dishJson, Dish.class);
}
// TODO 不存在查询数据库
Dish dish = this.getById(id);
// TODO 数据库也不存在就直接返回,看业务需求
if(dish == null) {
return null;
}
// TODO 数据库存在就写入缓存
this.stringRedisTemplate.opsForValue().set(Constants.DISH_KRY + id, JSONUtil.toJsonStr(dish));
return dish;
}
}
缓存预热
设想某个服务运行很长时间之后,为了优化该服务,将Redis接入服务之中。此时,Redis服务器之中是没有任何数据的。因此服务都会跳过Redis直接打入到MySQL之中。随着时间的积累,Redis上积累足够多的数据之后,MySQL的压力才会减少。
缓存预热就是把定期生产和实时生成两种更新策略进行结合,当Redis未接入系统之前,就先统计一些热点数据,将其导入到Redis中,然后再把Reids服务给接入到整个系统之中。通过这种方式,即使Redis之中的数据不是最热的,但是也可以减少很大一部分MySQL的压力。随着时间的推移,Redis之中旧的热点数据就会被淘汰,新的热点数据就会生成。
缓存穿透
客户端发来的查询请求,先会在Redis之中进行查询。如果发现没有,就会去MySQL之中进行查询。如果MySQL之中也没有,并且像这样的请求很多,那么就会给MySQL造成不小的影响。
产生原因
1. 业务设计不合理,比如缺少必要的参数验证,导致非法的key也被删除了。
2. 开发/运维操作错误,不小心把部分数据给删除了。
3. 黑客恶意攻击。
解决方案
1. 当客户端发送的请求在Redis和MySQL都不存在时,仍然写入Redis,但是把value设定成一个非法值。
优点是实现简单,维护方便。缺点是造成Redis额外的内存消耗(毕竟把没有用的数据给插入到了Reids之中),并且可能造成短期的不一致(例如在Redis中设置了一个非法值,但是此时MySQL更新了数据,这就导致了数据库和Redis的值不一致的情况出现。不过对于这种情况,可以给非法值设置一个过期时间,并且在更新数据库的同时把Reids之中的数据给删除,等到再次查询时再写入缓存)。
2. 引入布隆过滤器:每次查询Redis之前,都先判断一下key是否再布隆过滤器中存在。所谓的布隆过滤器,就是使用hash + bitmap的思想,因此可以使用比较小的空间开销,以及比较快的速度,来判断某个key是否存在于布隆过滤器之中。对于该业务背景来说,就是把所有的key插入布隆过滤器之中,然后进行查询时,先判断是否存在于布隆过滤器之中即可。
优点是内存占用较少,不存在多余的key。缺点则是实现复杂,并且存在误判的可能。
简单案例
该代码案例中,使用的是第一种解决方法,把key写入到缓存中,并给value设定一个非法值。
public Dish queryDishByIdOfCachePenetration(Long id) {
// TODO 在Redis之中查询该菜品是否存在
String dishJson = this.stringRedisTemplate.opsForValue().get(Constants.DISH_KRY + id);
// TODO 判断是否存在
if(StrUtil.isNotBlank(dishJson)) {
// 菜品存在,直接返回缓存信息
return JSONUtil.toBean(dishJson, Dish.class);
}
// TODO 判断获取到的是否是非法值,从而解决缓存穿透问题
if(dishJson != null) {
// 由于在isNotBlank方法之中已经判断其不为null,不为空字符串,不为不可见字符进而明白其是菜品信息
// 因此,当走到这一步并且不为null时,就可以判断,其是非法值
return null;
}
// TODO 从数据库之中获取菜品
Dish dish = this.getById(id);
// TODO 判断数据库返回的是否是空值
if(dish == null) {
// 数据库之中菜品也为空时,表示该id是一个非法值,存到Redis之中,并返回
this.stringRedisTemplate.opsForValue().set(Constants.DISH_KRY + id, "", 2, TimeUnit.MINUTES);
return null;
}
// 数据库不为空时,将其写入缓存之中,返回菜品内容
this.stringRedisTemplate.opsForValue().set(Constants.DISH_KRY + id, JSONUtil.toJsonStr(dish), 30, TimeUnit.SECONDS);
return dish;
}
缓存雪崩
缓存雪崩是指在短时间内,Redis服务上的key大规模失效或Redis服务宕机,导致缓存命中率陡然下降。这就肯定会使得MySQL压力陡然上升,甚至直接宕机。
产生原因
1. Redis服务宕机了
2. Redis服务中大量的key到达过期时间,然后失效了
解决方案
1. 加强监控报警,从而增加Redis服务的可用性。
2. 给key设置过期时间时添加随机因子,从而避免同一时期过期。
3. 利用Redis集群提高服务的可用性。
4. 给缓存业务添加降级限流策略。
5. 给业务添加多级缓存。
缓存击穿
缓存击穿问题也称作热点key问题,即一个被大量请求访问且缓存重建业务较复杂的key突然失效了,导致无数的请求直接打到数据库之中,在瞬间给与了数据巨大的冲击。
解决方案
1. 基于统计的方式发现热点key,并设置永不过期。
2. 使用互斥锁的方式来进行查询。即当查询缓存未命中时,先获取互斥锁,然后再去访问数据库进行重建缓存。这样就不会造成所有的请求都直接打到数据库之中,从而避免了数据库的宕机。
优点是没有额外的内存消耗,保证一致性,实现简单。缺点是线程需要额外等待,性能受到影响,同时存在死锁的情况。
3. 采用逻辑过期的方式来进行查询。即当查询缓存时,发现已经到了逻辑过期时间之后,这时就先获取互斥锁,然后开启新的线程去进行缓存重建并重新设置过期时间。在缓存重建的过程中,有其他请求来访问时,就返回已经过期的数据。
优点是线程无需等待,性能较好。缺点是不保证一致性,有额外的内存消耗,实现复杂。