1. 缓存穿透
缓存穿透就是指用户不断发起请求查询不存在的数据(如id为-1或者id非常非常大),这样子缓存永远不要生效,这些请求都会被打到数据库中,导致数据库中有很大压力。
解决方案:
- 缓存空对象
- 布隆过滤
1.1 缓存空对象
缓存空对象的实现思路:
当请求想要获取不存在的数据的时候,首先会先请求redis,redis没有命中,此时会访问数据库,在数据库中也没有查询到数据,这时候这个请求就穿透了缓存,直击redis。当这种请求的数量极其庞大的时候,redis的承受能力比数据库强,那么数据库肯定会因为承受不住那么多请求而出现宕机。于是,我们可以在查询数据库的时候查不到数据,往redis中存入一个空值,那么当用户再次请求这个不存在的数据的时候,就能在redis中找到,找到之后只需要判断是否是空值,如果是空值而返回不存在
缓存空对象有以下优点:
- 实现简单
- 维护方便
同时也存在以下缺点:
- 需要额外的内存消耗
- 每一次请求不存在的数据都会存一个空对象到redis中造成了内存消耗,解决方法就是给这些空值设置一个过期时间(有效期不长)
- 可能造成短期的不一致
- 当请求这个数据不存在的时候,redis设置成空对象,当此时新增了这条数据,那么redis还是空的,也就是代表不存在。因此会出现短期的不一致性。
为什么缓存空值,而不是缓存null呢?
首先先说说缓存不存在的时候,这时候查询缓存得到的数据为null。
由此可见,如果缓存了null,那么缓存击穿问题还是无法得到解决。
1.2 布隆过滤
当不存在的数据请求越来越多的时候,内存损耗也就越来越大,这时候用缓存空对象的办法,显然会造成很多内存的浪费。这时候,就可以考虑使用布尔过滤了。
布尔过滤地城使用的是bit数组存储数据,该数组的默认值为0
布尔过滤器第一次初始化的时候,会将数据库中的已经存在的key通过某种hash算法计算,每个key都会计算出多个位置,然后将该位置的元素值设置为1
当有用户发送请求过来的时候,用同样的hash算法计算位置
- 如果多个位置中的元素值都是1,则说明该key在数据库中已存在。这时允许继续往后面操作。
- 如果有1个以上的位置上的元素值是0,则说明该key在数据库中不存在。这时可以拒绝该请求,而直接返回。
布尔过滤有以下优点:
- 内存占用较少,没有多余key
也有以下缺点
- 实现起来比较复杂
- 可能存在误判的情况
- 因为有可能出现hash冲突的,也就是说不同的key,可能会计算出相同的位置
1.3 使用缓存空对象解决缓存穿透
原本的业务是,当在redis中查不到该key的时候,进入数据库查,数据库查不到则返回错误提示
使用缓存空对象之后,业务逻辑编程在redis中查不到该key的时候,进入数据库查,数据库中无论查得到还是查不到,都往redis中存,当查不到的时候往redis中存空对象
public <R,ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,直接返回
return JSONUtil.toBean(json, type);
}
// 判断命中的是否是空值
if (json != null) {
// 返回一个错误信息
return null;
}
// 4.不存在,根据id查询数据库
//交给调用者
R r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
return r;
}
这是一个封装好的方法
- String keyPrefix:存redis的key的前缀
- ID id:要查的id,因为不确定类型,有可能是String,也有可能是Long,所以用泛型
- Class< R > type:缓存命中时,需要将json转化为对象,这时候需要用到这个对象的class
- Function<ID, R> dbFallback:由于查数据库的时候,每个对象的查询调用的方法都不一样,一样的是传入一个参数,返回一个对象,所以使用Function封装
- Long time:缓存过期时间
- TimeUnit unit:过期时间指定的类型,比如秒、分
调用该方法如下
Shop shop = cacheClient
.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
1.4 总结
除了上面的解决方案,还有以下的方法
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
2. 缓存雪崩问题
缓存雪崩是指在某一个时刻,大量key同时失效或者redis宕机,导致请求直接到达数据库,给数据库带来了巨大的压力。
既然是这样,解决起来也很简单
- 给不同的key的TTL值添加随机值
- 设置TTL的时候,在后面加一个随机值
- 利用redis集群提高服务可用性
- 构建Redis集群,当某个redis宕机的时候,从节点切换到主节点,继续提供服务。
- 给缓存业务添加降级限流策略
- 控制每秒进入应用程序的请求数,避免过多的请求被发到数据库
- 给业务添加多级缓存
3. 缓存击穿
缓存击穿也成为热点key问题,是指缓存中没有但数据库中有的数据(一般是因为缓存过期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。
如下图,缓存刚好过期,线程1查缓存发现缓存中无数据,于是查数据库重构缓存数据。但在线程1未重构缓存数据的时候,大量线程如线程2、线程3、线程4也未命中,同时查询数据库。这样会导致数据库压力大,若超出数据库的承受能力,会使得数据库宕机,从而程序挂掉。
解决缓存击穿,无非就两个解决方案
- 互斥锁
- 逻辑过期
3.1 互斥锁
利用锁能实现互斥性。当线程过来的时候,只能一个个地访问数据库,造成线程串行,从而影响查询效率。
当线程1过来发现缓存没有命中,那么它将会获取锁,线程1将会查询数据库并重构缓存数据。在这一过程中,线程2进入,同样缓存没有命中,想要获取互斥锁,但是互斥锁被线程1持有,因此线程2获取互斥锁失败。这时候线程2将会休眠一会,休眠结束后重试。如果某一时间,重试发现缓存命中,说明线程1重建缓存成功。
使用互斥锁有以下优点:
- 没有额外地内存消耗
- 保证一致性
- 实现简单
同时也存在以下缺点:
- 线程需要等待,性能收影响
- 存在死锁风险
代码实现如下
public <R, ID> R queryWithMutex(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, type);
}
// 判断命中的是否是空值
if (shopJson != null) {
// 返回一个错误信息
return null;
}
// 4.实现缓存重建
// 4.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2.判断是否获取成功
if (!isLock) {
// 4.3.获取锁失败,休眠并重试
Thread.sleep(50);
//重试
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
// 4.4.获取锁成功,根据id查询数据库
r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
// 7.释放锁
unlock(lockKey);
}
// 8.返回
return r;
}
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//可能会出现拆箱
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
注意:
setIfAbsent 是java中的方法
setnx 是 redis命令中的方法两者是等同的
在 Redis 里,所谓 SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果。
如果setnx 返回ok 说明拿到了锁;如果setnx 返回 nil,说明拿锁失败,被其他线程占用。
换成客户端服务器则是如下:
客户端执行以上的命令:
- 如果服务器返回 OK ,那么这个客户端获得锁。
- 如果服务器返回 NIL ,那么客户端获取锁失败,可以在尝试稍后再重试。
3.2 逻辑过期
对于逻辑过期这种方案,需要在存在redis的数据中放入一个过期时间的数据,用来判断缓存数据是否逻辑过期。
同时该key的缓存设置为永不过期
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
整体思路是这样的:在redis查询,缓存命中后,当前时间与redis中存的逻辑过期时间比较,如果当前时间已经超过redis中存的过期时间,那么开启一个独立线程去重构数据,重构完成之后释放互斥锁。否则,返回redis中的数据。
注意:假如另外一个线程尝试获取锁失败,那么不会像第一种互斥锁的解决方案一样,这里会返回redis中的旧数据,不会等待互斥锁的释放,这样做大大提高了性能,但是却牺牲了数据一致性。
编码实现
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public <R, ID> R queryWithLogicalExpire(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return r;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
//获取锁成功之后应该再次检测redis是否过去,做doublecheck,如果存在则无需重建缓存
// 6.3.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R newR = dbFallback.apply(id);
// 重建缓存
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return r;
}
// 重建缓存
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
参考:黑马程序员Redis入门到实战教程,深度透析redis底层原理+redis分布式锁+企业解决方案+黑马点评实战项目
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/94971.html