Redis三剑客:缓存雪崩、缓存穿透、缓存击穿
文章目录
- 缓存雪崩
- 缓存穿透
- 缓存击穿
缓存雪崩
缓存雪崩产生原因:由于缓存在同一时间大面积失效或者Redis宕机导致大量请求落入数据库,给数据库造成巨大的压力
解决方案:
1、当将数据添加到缓存中时,给缓存时间添加随机的过期时间。可以防止缓存在同一时间大面积失效。
2、使用Redis集群。当有节点宕机时使用其他节点恢复数据。
3、做好限级限流的策略
4、使用多级缓存
缓存穿透
缓存穿透产生原因:被恶意者攻击,发送大量不存在的数据请求,导致请求不会命中缓存,直接落到数据库,给数据库带来巨大的压力
解决方案:
1、缓存空值键到缓存中,适用于大量请求所带的id相同。为了避免内存的浪费,可以给空值键设置一个过期时间
优点:实现简单
缺点:当大量请求所带的id不同时,需要缓存多个空值键,占用额外的内存空间
保证空键值的实现:
@Autowired
private StringRedisTemplate redisTemplate;
public void set(String key, Object o, Long time, TimeUnit unit) {
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(o), time, unit);
}
/**
* 缓存空值键(解决缓存穿透)
*/
public <R, Id> R querySaveNull(String prefixKey, Id id, Class<R> type, Function<Id, R> dbFunction, Long time, TimeUnit unit) {
System.out.println("缓存空值");
String cacheKey = prefixKey + id;
String objectStr = redisTemplate.opsForValue().get(cacheKey);
if (StrUtil.isNotBlank(objectStr)) {
return JSONUtil.toBean(objectStr, type);
}
// shopStr不为null说明为'',即命中空值键的缓存
if (objectStr != null) {
return null;
}
R r = dbFunction.apply(id);
// 当查出来的数据为null时,往redis中添加空值的键
if (r == null) {
redisTemplate.opsForValue().set(cacheKey, "", CACHE_NULL_TTL,TimeUnit.SECONDS);
return null;
}
set(cacheKey,r,time,unit);
return r;
}
2、适用布隆过滤器。将数据库所有数据的id放入布隆过滤器中,当有请求到达时,先经过布隆过滤器,判断布隆过滤器中是否存在请求id,如果存在则通过,如果不存在则直接返回。
缺点:实现复杂,存在误差
3、可以增加id的复杂性,添加请求所带id的检验,可以防止被攻击者猜到id并发出攻击
缓存击穿
缓存击穿产生原因:存在高并发的热点键,并且其重建缓存业务复杂,重建缓存耗时长,当热点键过期失效时,大量线程进入重建缓存,查询数据库,导致数据库压力暴涨
解决方案:
1、基于互斥锁的实现。
基于Redis的setnx命令实现互斥锁,当数据不存在时可以添加数据成功,数据存在时添加数据失败,当线程适用setnx命令添加成功数据时则成功抢到了锁,添加失败则获取锁失败,线程执行结束后需要及时释放锁,为了防止因为其他原因锁没有被释放,可以给锁添加一个过期时间。
/**
* 获取互斥锁
*/
private Boolean getLock(String lockKey) {
// 返回的布尔类型使包装型,可能为null值,调用布尔工具类,当包装类为true时才返回true,否则返回false
Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(aBoolean);
}
/**
* 释放互斥锁
*/
private void releaseLock(String lockKey) {
redisTemplate.delete(lockKey);
}
当多线程到达时,首先查看能否命中缓存,如果缓存失效我们尝试让这些线程竞争锁,竞争到锁的线程负责进行缓存重建的业务,其他的线程则使其休眠一段时间后重新执行业务。以下是基于Shop对象的实现。
private Shop queryWithMutex(Long id) {
// 首先从缓存中查询数据
String cacheKey = CACHE_SHOP_KEY + id;
String shopStr = redisTemplate.opsForValue().get(cacheKey);
if (StrUtil.isNotBlank(shopStr)) {
return JSONUtil.toBean(shopStr, Shop.class);
}
String lockKey = LOCK_SHOP_KEY + id;
Boolean aBoolean = getLock(lockKey);
Shop shop = null;
// 如果获取锁失败,则进行递归重新执行
try {
if (!aBoolean) {
Thread.sleep(50);
return queryWithMutex(id);
}
Thread.sleep(500);
shop = getById(id);
// 当查出来的数据为null时,往redis中添加空值的键
if (shop == null) {
redisTemplate.opsForValue().set(cacheKey, "", CACHE_NULL_TTL, TimeUnit.SECONDS);
return null;
}
redisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 当抛出异常或者业务执行完毕之后释放锁
releaseLock(lockKey);
}
return shop;
}
优点:保证了数据的高一致性,所有请求都能够返回最新的数据
缺点:重建缓存耗时较长,其他线程都需要等待缓存重建消耗的时间
2、基于逻辑过期实现。我们不给键添加过期时间,让其一直存在缓存中,但是我们在将数据其添加进缓存中时,会给其添加一个逻辑过期的字段,当多线程到达时,我们首先命中缓存,取出逻辑过期的字段,判断数据是否过期,如果数据过期,让线程竞争锁,获取到锁的线程新创建一个线程来进行缓存重建的业务,然后主线程和其他未竞争到锁的线程直接返回过期的数据。以下时基于Shop对象的实现
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
private Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
String redisDataStr = redisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(redisDataStr)) {
return null;
}
RedisData redisData = JSONUtil.toBean(redisDataStr, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
// 逻辑时间未过期
LocalDateTime expireTime = redisData.getExpireTime();
System.out.println(expireTime);
System.out.println(LocalDateTime.now());
if (expireTime.isAfter(LocalDateTime.now())) {
return shop;
}
// 逻辑时间过期后,竞争锁
String lockKey = LOCK_SHOP_KEY + id;
// 竞争成功锁的线程new线程来进行缓存重建
Boolean isLock = getLock(lockKey);
if (isLock){
executorService.execute(()->{
try {
Thread.sleep(500);
System.out.println("缓存重建");
saveShopRedis(id,CACHE_SHOP_TTL);
} catch (Exception e) {
e.printStackTrace();
} finally {
releaseLock(lockKey);
}
});
}
return shop;
}
/**
逻辑过期进行缓存重建
*/
public void saveShopRedis(Long id, Long time) {
String key = CACHE_SHOP_KEY + id;
Shop shop = getById(id);
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(time));
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
优点:线程无需等待,直接返回数据
缺点:不保证数据的高一致性,部分线程可能返回以及过期的数据