什么缓存穿透、雪崩、击穿?如何去解决这些场景?
1. 缓存穿透
1.1. 定义
缓存穿透:指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库
1.2. 解决方案
常见的解决方案由两种
第一种方案:缓存空对象
当我们线上业务发现缓存穿透的现象时,可以针对一个查询的数据,在缓存中设置一个空值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗,可能造成短期的不一致
第二种方案:布隆过滤器
使用布隆过滤器快速判断数据是否存在,避免通过查询数据来判断数据是否存在
我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在
即使发生了缓存穿透,大量请求只会查询Redis和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis自身也是支持布隆过滤器的
布隆过滤器的工作过程:
布隆过滤器由初始值都为0的位图数组
和N个哈希函数
两部分组成,当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中
布隆过滤器会通过3个操作来完成标记:
- 第一步:使用N个哈希函数分别对数据做哈希计算,得到N个哈希值
- 第二步:将第一步得到的N个哈希函数值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置
- 第三步,将每个哈希值在位图数组的对应位置的值设置为1
举例:
在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中。
布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况。
所以,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据。
1.3. 案例
public Result queryShopById(Long id) {
//1.从Redis查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2.如果存在,直接返回
if(StrUtil.isNotBlank(shopJson)){
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//判断是否为空对象
if (shopJson != null) {
//为空对象
return Result.fail("店铺信息为空!");
}
//3.不存在,根据id查询数据库
Shop shop = getById(id);
if (shop == null) {
// 设置空对象
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在");
}
//4.将店铺信息存入Redis,并设置超时时间
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//5.返回店铺信息
return Result.ok(shop);
}
2.缓存雪崩
2.1. 定义
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力
2.2. 解决方案
通过上面可以看到,发生缓存雪崩有两个原因:
- 大量数据同时到期
- Redis故障宕机
不同的诱因,对应的策略也会不同
2.2.1. 大量数据到期
第一种:均匀设置过期时间
如果要给缓存数据设置过期时间,应该避免大量的数据设置成同一个时间,我们可以对缓存数据设置时间时,给这些数据的过期时间加上一个随机数,这样就保证数据不会在同一时间内国企
第二种:互斥锁
当业务线程在处理用户请求时,如果发现访问的数据不在Redis里, 就加个互斥锁,保证同一时间内只有一个请求来构建缓存,当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值
实现互斥锁的时间,最好设置超时时间,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象
第三种:后台更新缓存
业务线程不在负责更新缓存,缓存也不设置有效期,而是让缓存永久有效,并将更新缓存的工作交由后台线程定时更新
事实上,缓存数据不设置有效期,并不是意味着数据一直能在内存里,因为当系统内存紧张的时候,有些缓存数据会被淘汰,而在缓存被淘汰到下一次后台定时更新缓存的这段时间内,业务线程读取缓存失败就返回空值,业务的视角意外时数据丢失了
解决上卖弄的问题的方式有两种:
1)后台线程不仅负责定时更新缓存,而且也负责频繁地检测缓存是否有效,检测缓存失效,原因可能是系统紧张而被淘汰的,于是就要马上从数据库读取数据,并且更新到缓存;
这种方式的检测时间不能太长,太长会导致用户获取的数据是一个空值而不是一个真正的数据
2)在业务线程发现缓存数据失效后(缓存数据被淘汰),通过消息队列发送一条消息通知后台更新缓存,后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作;不存在就读取数据库数据,并将数据库数据加载到缓存。
在业务刚上线的时候,我们最好提前把数据缓起来,而不是等待用户访问才来触发缓存构建,这就是所谓的缓存预热,后台更新缓存的机制刚好也适合干这个事情
2.2.2. Redis故障宕机
第一种:服务熔断或请求限流机制
因为 Redis 故障宕机而导致缓存雪崩问题时,我们可以启动服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。
服务熔断机制是保护数据库的正常允许,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作
为了减少对业务的影响,我们可以启用请求限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。
第二种:构建 Redis 缓存高可靠集群
服务熔断或请求限流机制是缓存雪崩发生后的应对方案,我们最好通过主从节点的方式构建 Redis 缓存高可靠集群。
如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题
3.缓存击穿
3.1. 定义
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问在瞬间给数据库带来巨大的冲击力
3.2. 解决方法
两种解决方案:
第一种:互斥锁
保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值
优点:
- 没有额外的内存消耗
- 保证一致性
- 时间简单
缺点:
- 线程需要等待,性能受影响
- 可能由死锁风险
第二种:逻辑过期
查询缓存对象,拿出缓存对象的逻辑过期时间,如果没有过期,则直接返回,如果过期,则尝试获取互斥锁,如果获取互斥锁失败则直接返回过期对象,如果获取成功,则重构缓存
优点:
- 线程无需等待,性能较好
缺点:
- 不保证一致性
- 由额外内存消耗(需要存储过期时间)
- 实现复杂
3.3. 案例
3.3.1. 互斥锁
基于互斥锁方式解决缓存击穿问题
需求:修改根据id查询商铺的业务,基于互斥锁方式来解决缓存击穿问题
核心代码:
private Shop queryWithMutex(Long id){
String key = CACHE_SHOP_KEY + id;
Shop cacheShop = getCacheShop(key);
if (cacheShop != null) {
return cacheShop;
}
//4.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
// 4.1 获取锁失败
if (!getLock(lockKey)) {
// 4.1 获取锁失败,则休眠重试
Thread.sleep(50);
return queryWithMutex(id);
}
// 4.2 获取成功
// 4.3 重新check Redis缓存
cacheShop = getCacheShop(key);
if (cacheShop != null) {
return cacheShop;
}
//3.不存在,根据id查询数据库
shop = getById(id);
if (shop == null) {
// 设置空对象
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//4.将店铺信息存入Redis,并设置超时时间
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
}catch (Exception e){
e.printStackTrace();
}finally {
unLock(lockKey);
}
//5.返回店铺信息
return shop;
}
3.3.2. 逻辑过期
需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题
核心代码:
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 逻辑过期
* @param id
* @return
*/
private Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.获取缓存
String json = stringRedisTemplate.opsForValue().get(key);
//2.判断缓存是否存在
if(StrUtil.isBlank(json)){
//3.不存在,返回null
return null;
}
//4.命中判断缓存是否过期
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
if(redisData.getExpireTime().isAfter(LocalDateTime.now())){
//5.缓存未过期,直接返回
return shop;
}
//6.缓存过期重建
//6.1尝试获取锁
String lockKey = LOCK_SHOP_KEY + id;
if(getLock(lockKey)){
//6.2 获取锁成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit( ()->{
try{
//重建缓存
this.saveShop2Redis(id,20L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
unLock(lockKey);
}
});
}
//6.2 返回过期信息
return shop;
}
//可以做提前缓存预热
private void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
//1.从数据库查询shop
Shop shop = getById(id);
//2.写入redis
RedisData redisData = new RedisData();
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
redisData.setData(shop);
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData), expireSeconds, TimeUnit.SECONDS);
}
转载自:https://juejin.cn/post/7390413118889721896