1. 缓存穿透问题
1.1 什么是缓存穿透
缓存穿透是指客户端请求的数据在缓存和数据库中不存在,到这每次请求都会直接访问到数据库。导致数据库压力过载。
常见的解决方案有两种:
- 缓存空对象:
- 优点:实现简单,维护方便
- 缺点:增加内存消耗,可能造成短时的数据不一致
- 布隆过滤
- 优点:内存占用较少,没有多余key
- 缺点:实现复杂,存在误判
1.2 缓存空对象
1.2.1 思路分析
当客户端访问不存在的数据时,先访问redis缓存,数据不存在。访问数据库,仍然访问不到数据。此时可以返回一个空数据给redis进行缓存,这样接下来客户端再次访问时就可以直接从缓存中直接返回空,减少了数据库的访问压力。
1.2.2 代码
/**
* 缓存穿透
* @param keyPrefix key前缀
* @param id 查询id
* @param type 查询类型
* @param dbFallback 数据库函数逻辑
* @param time 有效期
* @param unit 时间单位
* @return 返回值
* @param <R> 数据泛型
* @param <ID> ID泛型
*/
public <R,ID> R queryWithPassThrough(String keyPrefix ,ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit) {
//构造key值
String key = keyPrefix + id;
//查询缓存
String json= stringRedisTemplate.opsForValue().get(key);
//是否命中
if (StrUtil.isNotBlank(json)){
//命中直接返回
return JSONUtil.toBean(json,type);
}
//判断是否为空字符串,若为空字符串拦截,若为null则是第一次查询,继续向数据库查询
if (json != null){
//拦截缓存穿透
return null;
}
//查询数据库
R r = dbFallback.apply(id);
//没查到,返回空字符串
if (r == null){
//缓存穿透
stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//查到添加缓存
this.set(key,r,time,unit);
return r;
}
2. 缓存雪崩
缓存雪崩是指在一段时间内大量缓存key同时过期或者redis服务停机,导致大量请求直接请求到数据库,给数据库带来巨大压力。
解决方案:
- 给不同的key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给业务添加降级限流策略
- 添加多级缓存
3. 缓存击穿
3.1 什么是缓存击穿
缓存击穿也叫热点key问题,就是一个被高并发访问的并且缓存重建较复杂的key突然无效,大量并发请求给数据库带来冲击。
常用解决办法:
- 互斥锁
- 逻辑过期
3.2 互斥锁
3.2.1 思路分析
并发请求到来后,必须先申请互斥锁,只有申请到锁的请求可以被响应,其他请求进行等待,并不断申请锁,直到申请成功才能执行业务。简单来说就是把并发变为串行,虽然保护了数据库,但极大降低了查询性能。
3.2.2 代码
申请锁和释放锁,利用redis的setnx方法来获取锁,如果redis中没有相应的key,则插入并返回1,如果有则不处理,返回0。
//申请锁
private Boolean tryLock(String key){
return stringRedisTemplate.opsForValue()
.setIfAbsent(key,"",RedisConstants.LOCK_SHOP_TTL,TimeUnit.SECONDS);
}
//释放锁
private void unLock(String key){
stringRedisTemplate.delete(key);
}
互斥锁解决缓存击穿
/**
* 缓存击穿
* @param keyPrefix key前缀
* @param lockKeyPrefix 锁key前缀
* @param id 查询id
* @param type 查询类型
* @param dbFallback 数据库函数回调
* @param time 缓存有效期
* @param unit 时间单位
* @return 返回泛型
* @param <R> 泛型
* @param <ID> ID泛型
*/
public <R,ID> R queryWithMutex(String keyPrefix, String lockKeyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit){
//构造key
String key = keyPrefix + id;
//查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
//查到直接返回
if (StrUtil.isNotBlank(json)){
return JSONUtil.toBean(json,type);
}
//查到空字符串,缓存穿透拦截
if (json != null){
//返回空
return null;
}
//构建锁key
String lockKey = lockKeyPrefix + id;
//申请锁
Boolean isLock = tryLock(lockKey);
//没锁,延迟重新尝试,直到申请到锁
if (!isLock){
try {
Thread.sleep(50);
return queryWithMutex(keyPrefix,lockKeyPrefix,id,type,dbFallback,time,unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
//查询数据库
R r = dbFallback.apply(id);
//数据库中没有,缓存穿透处理
if (r == null){
set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//查到,放入缓存
this.set(key,r,time,unit);
//释放锁
unLock(lockKey);
return r;
}
3.3 逻辑过期
3.3.1 思路分析
之所以会出现缓存击穿,主要原因是我们给key值设置了有效期,但如果不设置有效期又会持续占用内存,因此提出了逻辑过期。把过期时间设置在redis的value中,这个过期时间不会直接作用于redis,当一个线程去查询缓存时,需要从value中得到有效期,判断当前数据是否过期,如果没过期直接返回。过期了此时该线程申请互斥锁,申请成功后,开启一个新线程进行缓存重构(更新数据),该线程直到缓存重构完成后才释放锁。在此期间其他线程由于申请不到互斥锁,直接返回旧数据。也就是说在数据更新期间返回的数据均是旧数据。
(注意:这种方式需要提前进行缓存初始化,否则永远返回空数据)
3.3.2 代码
/**
* 逻辑失效解决缓存击穿
* @param keyPrefix 前缀
* @param lockKeyPrefix 锁前缀
* @param id 查询id
* @param type 查询类型
* @param dbFallback 数据库回调函数
* @param time 有效期
* @param unit 时间单位
* @return 返回泛型
* @param <R> 查询泛型
* @param <ID> id泛型
*/
public <R,ID> R queryWithLogical(String keyPrefix, String lockKeyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit){
String key = keyPrefix + id;
//查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
//没查到返回空
if (StrUtil.isBlank(json)){
return null;
}
//解析RedisData
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R bean = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expertTime = redisData.getExpertTime();
//判断是否过期,没有过期直接返回
if (expertTime.isAfter(LocalDateTime.now())){
return bean;
}
//过期,查询数据库进行更新
//申请锁
String lockKey = lockKeyPrefix + id;
Boolean isLock = tryLock(lockKey);
if (isLock){
//开启新线程,进行数据库查询,原线程直接返回旧数据
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
//查询数据更行缓存
R r = dbFallback.apply(id);
this.setWithLogicalExpire(key,r,time,unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unLock(lockKey);
}
});
}
3.4 分析对比
互斥锁方案:保证了互斥性,数据一致,实现简单,没有额外的内存消耗。缺点可能会发生死锁,只能串行执行,性能影响较大。
逻辑过期方案:性能好,缺点实现复杂,返回数据有可能是脏数据。
4. 封装的工具类
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 添加缓存
* @param key 缓存key
* @param value 缓存value
* @param time key有效期
* @param unit 时间单位
*/
public void set(String key, Object value, Long time, TimeUnit unit){
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
}
/**
* 构造redisData并加入缓存
* @param key key
* @param value value
* @param time 有效期
* @param unit 时间单位
*/
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpertTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 缓存穿透
* @param keyPrefix key前缀
* @param id 查询id
* @param type 查询类型
* @param dbFallback 数据库函数逻辑
* @param time 有效期
* @param unit 时间单位
* @return 返回值
* @param <R> 数据泛型
* @param <ID> ID泛型
*/
public <R,ID> R queryWithPassThrough(String keyPrefix ,ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit) {
//构造key值
String key = keyPrefix + id;
//查询缓存
String json= stringRedisTemplate.opsForValue().get(key);
//是否命中
if (StrUtil.isNotBlank(json)){
//命中直接返回
return JSONUtil.toBean(json,type);
}
//判断是否为空字符串,若为空字符串拦截,若为null则是第一次查询,继续向数据库查询
if (json != null){
//拦截缓存穿透
return null;
}
//查询数据库
R r = dbFallback.apply(id);
//没查到,返回空字符串
if (r == null){
//缓存穿透
stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//查到添加缓存
this.set(key,r,time,unit);
return r;
}
/**
* 缓存击穿
* @param keyPrefix key前缀
* @param lockKeyPrefix 锁key前缀
* @param id 查询id
* @param type 查询类型
* @param dbFallback 数据库函数回调
* @param time 缓存有效期
* @param unit 时间单位
* @return 返回泛型
* @param <R> 泛型
* @param <ID> ID泛型
*/
public <R,ID> R queryWithMutex(String keyPrefix, String lockKeyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit){
//构造key
String key = keyPrefix + id;
//查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
//查到直接返回
if (StrUtil.isNotBlank(json)){
return JSONUtil.toBean(json,type);
}
//查到空字符串
if (json != null){
//返回空
return null;
}
//构建锁key
String lockKey = lockKeyPrefix + id;
//申请锁
Boolean isLock = tryLock(lockKey);
//没锁,延迟重新尝试,直到申请到锁
if (!isLock){
try {
Thread.sleep(50);
return queryWithMutex(keyPrefix,lockKeyPrefix,id,type,dbFallback,time,unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
//查询数据库
R r = dbFallback.apply(id);
//数据库中没有,缓存穿透处理
if (r == null){
set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//查到,放入缓存
this.set(key,r,time,unit);
//释放锁
unLock(lockKey);
return r;
}
/**
* 逻辑失效解决缓存击穿
* @param keyPrefix 前缀
* @param lockKeyPrefix 锁前缀
* @param id 查询id
* @param type 查询类型
* @param dbFallback 数据库回调函数
* @param time 有效期
* @param unit 时间单位
* @return 返回泛型
* @param <R> 查询泛型
* @param <ID> id泛型
*/
public <R,ID> R queryWithLogical(String keyPrefix, String lockKeyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit){
String key = keyPrefix + id;
//查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
//没查到返回空
if (StrUtil.isBlank(json)){
return null;
}
//解析RedisData
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R bean = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expertTime = redisData.getExpertTime();
//判断是否过期,没有过期直接返回
if (expertTime.isAfter(LocalDateTime.now())){
return bean;
}
//过期,查询数据库进行更新
//申请锁
String lockKey = lockKeyPrefix + id;
Boolean isLock = tryLock(lockKey);
if (isLock){
//开启新线程,进行数据库查询,原线程直接返回旧数据
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
//查询数据更行缓存
R r = dbFallback.apply(id);
this.setWithLogicalExpire(key,r,time,unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unLock(lockKey);
}
});
}
return bean;
}
//申请锁
private Boolean tryLock(String key){
return stringRedisTemplate.opsForValue().setIfAbsent(key,"",RedisConstants.LOCK_SHOP_TTL,TimeUnit.SECONDS);
}
//释放锁
private void unLock(String key){
stringRedisTemplate.delete(key);
}
}