1. 缓存穿透
缓存穿透 是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常见的解决方案:
- 缓存空对象
优点: 实现简单,维护方便
缺点: 1. 额外的内存消耗 2. 可能造成短期的不一致 - 布隆过滤
优点: 内存占用较少,没有多余Key
缺点: 1. 实现复杂 2. 存在误判可能
基于 缓存空对象 解决实例:
//获取Redis操作对象
@Autowired
private StringRedisTemplate stringRedisTemplate;
//解决 缓存穿透
public Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
//1. 从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2. 判断是否为空-该方法判断shopJson是否有值
if (StrUtil.isNotBlank(shopJson)) {
//3. 存在,直接返回
Shop shop = JSONUtil.toBean(shopJson,Shop.class);
return shop;
}
//--------------------------------------------------------------------------------------
//TODO 判断命中的是否为空值-这时候只有当 shopJson==null 值才会被定义为首次访问redis没有,需要到数据库进行访问
if (shopJson != null) {
// 返回空对象-json==""
return null;
}
//--------------------------------------------------------------------------------------
//4. 不存在,根据id查询数据库
Shop shop = getById(id);
//5. 数据库不存在,空字符串存入Redis,并返回空对象
if (shop == null) {
//TODO 将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//6. 数据库存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonPrettyStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
//7. 返回
return shop;
}
2. 缓存击穿
缓存击穿问题 也叫热点key问题,就是一个被 高并发访问 并且 缓存重建业务较复杂 的key突然失效,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案:
-
互斥锁
当有多个并发同时访问的key失效时,给缓存业务重建这个过程上把锁,只允许一个线程建立缓存业务,其余线程等待Redis缓存的建立,当缓存建立成功后,直接从Redis缓存返回结果。
优点:没有额外的内存消耗;保证返回结果一致性;实现简单
缺点:线程需要等待,性能受影响;可能有死锁风险 -
逻辑过期
缓存中key的生命周期是永久的,需要人为清理,但是内部对key设置了一个逻辑时长,当线程访问该key时会对逻辑时长进行判断,若判断为过期,该线程会新建一个线程拿到锁进入数据库更新缓存内容和逻辑时长,而并发的线程在尝试获取锁失败后,会返回Redis中的旧数据
优点:线程无需等待,性能较好
缺点:不保证返回结果一致性;有额外内存消耗;实现复杂适用于新旧数据影响不大的情况,并且有个 前提:需要提前在Redis中缓存该key
解决实例:
不管是用 互斥锁 还是 逻辑过期 来解决缓存击穿,都需要获取锁和释放锁,因为判断是否对Redis的key进行操作,所以选用 基于Redis的分布式锁最为合适
基于Redis的分布式锁实现方法:
-
版本1.0----利用Redis中字符串命令:
SETNX
(只有在 key 不存在时设置 key 的值)
获取锁:
互斥:确保只能有一个线程获取锁
非阻塞:尝试一次,成功返回 true,失败返回 false# 添加锁,NX是互斥,EX是设置超时时间 SET lock thread1 NX EX 10
释放锁:
手动释放
超时释放:获取锁时添加一个超时时间# 释放锁,删除key DEL key
//获取Redis对象 @Autowired private StringRedisTemplate stringRedisTemplate; //TODO 利用radis中SETNX只能对Key添加一次value的特性 加锁 private boolean tryLock(String key) { Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"lock",10,TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } //TODO 删除键值 释放锁 private void unlock(String key) { stringRedisTemplate.delete(key); }
-
版本2.0----对于锁的值,存入线程唯一标识符
上面的锁大致看着合理,但是也有一些漏洞,对该漏洞进行分析:
如图,当执行的业务时间大于超时释放锁的时间时,线程1 获取了锁执行业务,但是业务遭到阻塞,导致锁被提前超时释放,这时在高并发情况下 线程2 获取了锁,又会对数据库再次执行业务,这时若 线程1 业务执行完毕则会释放锁,这时若用第一种方法,则会把 线程2 的锁给释放掉,那么 线程3 又会乘虚而入,后面 线程2 又会把线程3 的锁给释放掉,线程4 又会进来,那么在这种情况下,数据库的访问压力依然很大,锁也没有发挥它的作用,虽然是一把锁,但是在缓存没建立起来之前,被线程反复拿了又释放掉。所以改进方法为,在线程拿到锁后,Redis中存入的值为线程唯一标识,释放锁时判断Redis中的值和该线程唯一标识是否相等
代码实现:
//获取Redis对象 @Autowired private StringRedisTemplate stringRedisTemplate; //key前缀 private static final String KEY_PREFIX = "lock:"; //value前缀-随机数的目的在于防止多个Tomacat的情况下线程id相同 // final修饰的目的在于只初始化一次,防止获取的线程标识不唯一 private static final String ID_PREFIX = UUID.randomUUID().toString().replace("-","") + "-"; //获取锁 public boolean tryLock(String key) { //获取线程标识 String threadId = ID_PREFIX + Thread.currentThread().getId(); //获取锁 - SETNX Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent(KEY_PREFIX+key,threadId,10, TimeUnit.SECONDS); //自动拆箱,可能遇到空指针风险 // return success; return Boolean.TRUE.equals(success); } //释放锁 public void unlock(String key) { //获取线程标识 String threadId = ID_PREFIX + Thread.currentThread().getId(); //获取锁中的标识 String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + key); //判断标识是否一致 if (threadId.equals(id)) { // 释放锁 stringRedisTemplate.delete(KEY_PREFIX+key); } }
-
版本3.0----Redis操作原子性,执行lua脚本
对于版本2.0,改进了锁对线程标识识别的问题,但是在释放锁时,因为 要取出Redis中key锁的线程唯一标识,然后和该线程标识进行判断,最后 再删除key锁,对于Redis操作了两次,这其中,又会出现一些情况
当业务执行完毕,线程1 要释放锁时,常规顺序为 从Redis中key锁取出线程唯一标识进行判断,从Redis删除该key锁,但是若在这中间遇到了阻塞,提前超时释放锁,在高并发的情况下,那么 线程2 将会拿到锁执行业务,但是 线程1 因为已经判断完毕,所以直接释放了 线程2 的锁,那么 线程3 又会拿到锁执行业务。该锁不完善的地方在于,在释放锁时,对于Redis的两次操作,可能会被其它线程阻塞而不能连贯整体执行,所以解决办法为引用 lua 脚本,保证对Redis操作的原子性,即Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。
代码实现:
# 编写释放锁的lua脚本-文件名 unlock.lua -- 比较线程标识与锁标识是否一致 if(redis.call('get',KEYS[1]) == ARGV[1]) then -- 释放锁 del key return redis.call('del',KEYS[1]) end return 0
//获取Redis对象 @Autowired private StringRedisTemplate stringRedisTemplate; //key前缀 private static final String KEY_PREFIX = "lock:"; //value前缀-随机数的目的在于防止多个Tomacat的情况下线程id相同 // final修饰的目的在于只初始化一次,防止获取的线程标识不唯一 private static final String ID_PREFIX = UUID.randomUUID().toString().replace("-","") + "-"; //获取lua脚本文件 private static final DefaultRedisScript<Long> UNLOCK_SCRIPT; static { UNLOCK_SCRIPT = new DefaultRedisScript<>(); UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua")); UNLOCK_SCRIPT.setResultType(Long.class); } //获取锁 public boolean tryLock(String key) { //获取线程标识 String threadId = ID_PREFIX + Thread.currentThread().getId(); //获取锁 - SETNX Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent(KEY_PREFIX+key,threadId,10, TimeUnit.SECONDS); //自动拆箱,可能遇到空指针风险 // return success; return Boolean.TRUE.equals(success); } //释放锁 public void unlock(String key) { //调用lua脚本 //KEYS是集合,ARGV是任意个,取值下标从1开始 stringRedisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + key), ID_PREFIX + Thread.currentThread().getId()); }
解决缓存击穿----互斥锁
//获取Redis对象
@Autowired
private StringRedisTemplate stringRedisTemplate;
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
//1. 从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2. 判断是否存在字符不为空
if (StrUtil.isNotBlank(shopJson)) {
//3. 存在,直接返回
Shop shop = JSONUtil.toBean(shopJson,Shop.class);
return shop;
}
//TODO 判断命中的是否为空值
if (shopJson != null) {
// 返回空对象
return null;
}
//TODO 4.实现缓存重建
//锁名
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
//TODO 4.1 获取互斥锁
boolean isLock = tryLock(lockKey);
//TODO 4.2 判断是否获取成功
if (!isLock) {
//TODO 4.3 失败,则休眠并重试
Thread.sleep(50);
//利用递归,再从头开始执行,等待缓存的建立
return queryWithMutex(id);
}
//4.4. 拿锁成功,根据id查询数据库
shop = getById(id);
//5. 数据库不存在,空值存入Redis并返回空对象
if (shop == null) {
//TODO 将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//6. 数据库存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonPrettyStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//TODO 7. 释放互斥锁
unlock(lockKey);
}
//8. 返回
return shop;
}
该种方法是 Redis没有该缓存,就去数据库找,然后一个线程拿锁建立缓存,其余线程等待缓存的建立,但若数据库也没有该值,就 需要防止缓存穿透,所以实现方式为 互斥锁 + 存空值
解决缓存击穿----逻辑过期
首先封装一个逻辑过期对象-RedisData
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
再添加一个存储逻辑过期对象到Redis中的方法
//TODO 存储逻辑过期缓存方法-id锁标识,expireSeconds有效时间
public void saveShopRedis(Long id,Long expireSeconds) {
// 1. 根据id查询数据库
Shop shop = getById(id);
// 2. 封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
//基于当前日期时间添加秒数
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3. 写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}
逻辑过期方法
//线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
@Autowired
private StringRedisTemplate stringRedisTemplate;
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
//1. 从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2. 判断是否存命中-Redis中是否存在该对象
if (StrUtil.isBlank(shopJson)) {
//3. 直接返回
return null;
}
// 4. 命中,需要先把json反序列化对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
// JSONObject data = (JSONObject) redisData.getData();
// Shop shop = JSONUtil.toBean(data,Shop.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(),Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5. 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1 未过期,直接返回店铺信息
return shop;
}
// 5.2 已过期,需要缓存重建
// 6. 缓存重建
// 锁名
String lockKey = LOCK_SHOP_KEY + id;
// 6.1 获取互斥锁
boolean isLock = tryLock(lockKey);
// 6.2 判断是否获取成功
if (isLock) {
// 6.3 成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//重建缓存
this.saveShopRedis(id,20L);
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放锁
unlock(lockKey);
}
});
}
// 6.4 返回过期的商铺信息
return shop;
}
该方法有个前提,需要提前建立缓存,用于取出对象判断是否逻辑过期,不存在key失效问题,所以对于redis没有的key可以直接返回空,不需要另外解决缓存穿透
3. 缓存雪崩
缓存雪崩 是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的key的TTL添加随机值
- 利用 Redis 集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
4.自定义工具类整合
逻辑过期对象
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
释放锁的lua脚本----文件名 unlock.lua
-- 比较线程标识与锁标识是否一致
if(redis.call('get',KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del',KEYS[1])
end
return 0
工具类
@Component
@Slf4j
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// 存入Redis普通对象 方法
public void set(String key, Object value, Long time,TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
}
// 存入Redis逻辑过期对象 方法
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));
}
// 泛型方法
/**
* 解决 缓存穿透
* @param keyPrefix key的前缀,使相同类型的值放在一个类别中
* @param id key的后缀,在相同类型中区分,避免值被覆盖
* @param type R建模的类的类型,例如:String.class的类型是Class<String>,用于Bean的转换
* @param dbFallback 接受 参数ID 并产生 结果R 的函数,用于接收数据库调用的函数
* @param time 时间,用于设置失效时间
* @param unit 时间单位
* @param <R> 返回类型
* @param <ID> id的类型
* @return
*/
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查询相关key的缓存
String json = stringRedisTemplate.opsForValue().get(key);
//2. 判断是否存在对象
if (StrUtil.isNotBlank(json)) {
//3. 存在,直接返回
return JSONUtil.toBean(json,type);
}
//TODO 判断命中的是否为空值
if (json != null) {
// 返回空对象-json==""
return null;
}
//4. 不存在,根据id查询数据库
R r = dbFallback.apply(id);
//5. 不存在,返回错误
if (r == null) {
//TODO 将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//6. 存在,写入redis
this.set(key,r,time,unit);
//7. 返回
return r;
}
//线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//key前缀
private static final String KEY_PREFIX = "lock:";
//value前缀-随机数的目的在于防止多个Tomacat的情况下线程id相同
// final修饰的目的在于只初始化一次,防止获取的线程标识不唯一
private static final String ID_PREFIX = UUID.randomUUID().toString().replace("-","") + "-";
//获取lua脚本文件
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
//获取锁
public boolean tryLock(String key) {
//获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
//获取锁 - SETNX
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX+key,threadId,10, TimeUnit.SECONDS);
//自动拆箱,可能遇到空指针风险
// return success;
return Boolean.TRUE.equals(success);
}
//释放锁
public void unlock(String key) {
//调用lua脚本
//KEYS是集合,ARGV是任意个,取值下标从1开始
stringRedisTemplate.execute(
UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + key),
ID_PREFIX + Thread.currentThread().getId());
}
/*
* 解决 缓存击穿+缓存穿透 方法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 json = stringRedisTemplate.opsForValue().get(key);
//2. 判断是否存在对象
if (StrUtil.isNotBlank(json)) {
//3. 存在,直接返回
R r = JSONUtil.toBean(json,type);
return r;
}
//TODO 判断命中的是否为空值
if (json != null) {
// 返回空对象-json==""
return null;
}
//TODO 4.实现缓存重建
// 锁名
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
//TODO 4.1 获取互斥锁
boolean isLock = tryLock(lockKey);
//TODO 4.2 判断是否获取成功
if (!isLock) {
//TODO 4.3 失败,则休眠并重试
Thread.sleep(50);
return queryWithMutex(keyPrefix,id,type,dbFallback,time,unit);
}
//4.4. 成功,根据id查询数据库
r = dbFallback.apply(id);
//5. 数据库不存在,空值写入并返回空对象
if (r == null) {
//TODO 将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//6. 数据库存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonPrettyStr(r),time, unit);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//TODO 7. 释放互斥锁
unlock(lockKey);
}
//8. 返回
return r;
}
/*
* 解决 缓存击穿+缓存穿透 方法2-逻辑过期
* 前提:需要有缓存才行!
* 参数同上
* */
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. 判断Redis缓存中是否存在该key对象,不存在直接返回空对象
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) {
// 6.3 成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//查询数据库
R apply = dbFallback.apply(id);
//重建缓存
this.setWithLogicalExpire(key,apply,time,unit);
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放锁
unlock(lockKey);
}
});
}
// 6.4 获取失败,返回过期的商铺信息
return r;
}
}
引用该工具类示例:
@Autowired
private CacheClient cacheClient;
...
Shop shop = cacheClient.
queryWithMutex(CACHE_SHOP_KEY,id,Shop.class,this::getById,CACHE_SHOP_TTL,TimeUnit.MINUTES);
...