什么是缓存击穿、怎么解决
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
因为将数据存入缓存的时候,需要先执行一系列的查询操作,如果该数据访问量非常大,那也会给数据库造成很大压力。
当然不是所有的数据都要做缓存击穿处理,热点数据才需要,所以一些地方的逻辑处理会不大一样。
解决缓存击穿有两种方式:
- 互斥锁
- 逻辑过期
两种分别是什么意思呢?
互斥锁
缓存过期后,第一个查询的线程,先获取互斥锁再去查询数据库,在这期间如果有别的线程来查询,会获取互斥锁失败。
逻辑过期
实质上是永不过期,只会被更新。存入缓存的数据会有一个固定的变量为过期时间
,查询缓存后将变量时间与当前时间做比较,如果发现已经过期,则获取互斥锁,然后开启一个新的额外线程去查询并更新缓存,主线程先直接返回就得缓存数据。在这期间如果有其他线程访问,获取锁失败,也直接先返回旧的缓存数据。
逻辑过期的好处就是不留恋不执着,拿不到新数据就先返回旧数据。
下面是两种方式的实例:
互斥锁
请求进来先要获取锁
互斥锁采用向redis中存入缓存,有个方法是如果key已经存在就无法存入,满足互斥锁的特性。要区分这里的锁的key是个单独的key。不是存入实际数据的key,不要混淆。
获取锁和解锁的方法如下:
/**
* 获取锁
*/
public boolean tryLock(String key){
Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtils.isTrue(flag);
}
/**
* 释放锁
*/
public void unlock(String key){
redisTemplate.delete(key);
}
如果获取锁成功,则查询数据库,将数据存入缓存,最后一定要解锁。
如果获取锁失败,则等待一会继续调用该方法,一般第二次进入时候,就会在缓存中拿到数据直接返回
/**
* 互斥锁解决缓存击穿
*/
private AjaxResult queryCompanyByIdByHcLock(Long id){
String ids = Constant.COMPANY_NAME + id;
Object o = redisTemplate.opsForValue().get(ids);
if (o != null) {
//判断是不是之前存入的空对象
if (o.equals("")){
return AjaxResult.error("公司不存在");
}
CompanyPo company = (CompanyPo) o;
return AjaxResult.success(company);
}
/**
* 解决缓存击穿
*/
//获取互斥锁
String key = "lock:company:" + id.toString();
try {
boolean isLock = tryLock(key);
//判断是否获取成功
if (!isLock){
//失败 --> 等待一阵继续尝试流程
Thread.sleep(50);
return queryCompanyById(id);
}
//成功 --> 查库,增加缓存
CompanyPo data = companyMapper.queryCompanyById(id);
//如果是空的,将""存入缓存
if (data == null) {
redisTemplate.opsForValue().set(ids, "",5, TimeUnit.SECONDS);
return AjaxResult.error("公司不存在!");
}
redisTemplate.opsForValue().set(ids, data,60, TimeUnit.SECONDS);
return AjaxResult.success(data);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
//释放锁
unlock(key);
}
}
逻辑过期
因为击穿显现是存在于一个热点数据,这些热点数据会事先存入redis中。
逻辑过期要给数据加上一个过期时间的字段:expireTime
,所以最好重新封装一下数据。
public class RedisData {
private LocalDateTime expireTime;
//实际存放数据
private Object data;
public LocalDateTime getExpireTime() {
return expireTime;
}
public void setExpireTime(LocalDateTime expireTime) {
this.expireTime = expireTime;
}
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
}
从缓存中获取数据,如果未过期则直接返回,如果过期先获取锁,如果获取锁成功创建一个新线程去将查库并更新缓存里的数据,如果获取锁失败,则不留恋直接返回旧数据。代码如下:
private static final ExecutorService CACHE_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 逻辑解决
* 因为增加一个字段存过期时间,不破坏原来结构的基础上,最好是新创建一个缓存变量
* @param id
* @return
*/
private AjaxResult queryCompanyByIdLogic(Long id) {
String ids = Constant.COMPANY_NAME + id;
//【1 查询缓存】
RedisData redisData = (RedisData) redisTemplate.opsForValue().get(ids);
if (redisData == null) {
return AjaxResult.error("不存在该热点数据");
}
CompanyPo companyPo = (CompanyPo) redisData.getData();
LocalDateTime expireTime = redisData.getExpireTime();
//【2 看是否过期】
if (expireTime.isAfter(LocalDateTime.now())){
//如果未过期,直接返回
return AjaxResult.success(companyPo);
}
//如果过期
//【3 现获取锁】
String key = Constant.LOCAK_COMPANY_KEY + id.toString();
boolean isLock = tryLock(key);
if (isLock) {
//【如果获取成功,查库,重新塞入缓存】
try {
CACHE_EXECUTOR.submit(() -> {
saveData2Redis(id,30L);
});
}catch (Exception e){
throw new RuntimeException(e);
}finally {
//【4 解锁】
unlock(key);
}
}
return AjaxResult.success(companyPo);
}
/**
* 新增热点数据
*/
public void saveData2Redis(Long id,Long expireSeconds){
CompanyPo companyPo = queryById(id);
//封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(companyPo);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//写入redis
String key = Constant.COMPANY_NAME + id.toString();
redisTemplate.opsForValue().set(key,redisData);
}