我们可以在查询缓存未命中的时候添加一个互斥锁。这样一来,在面对高并发的情况下,只有第一个进来的线程才可以拿到锁然后操作数据库,待操作结束后释放锁,未拿到锁的用户则等待一段时间重新查询缓存,直到缓存重建完毕后拿到数据后方可结束。
关于互斥锁这一部分,我们可以使用Redis里的setnx命令来模拟实现。
setnx命令对应java里的setIfAbsent,代码如下:
这样一来,只有第一个进来的线程才可以添加key并返回true,后面来的线程无法完成添加且返回false。
我们在写一个释放锁的方法,在拿到锁的进程操作数据库结束后,把锁释放掉(防止死锁)
总体实现流程大致如下:
- 根据key查询Redis缓存
- 命中了数据直接返回即可
- 命中了空值直接返回错误信息(防止缓存穿透)
- 获取锁
- 获取锁失败则等待一段时间后重新查询缓存
- 获取锁成功则查询数据库
- 数据库查询为null则缓存一个空值到Redis里(防止缓存穿透),过期时间设置短一点
- 数据库查询成功则直接添加缓存到Redis里
- 释放锁
代码实现如下(带详细注释):
public Shop cacheBreakDown(Long id) {
//定义一个ID
String cacheID = CACHE_SHOP_KEY + id;
//1、查询Redis
String shopJson = stringRedisTemplate.opsForValue().get(cacheID);
//2、判断是否命中
if(StrUtil.isNotBlank(shopJson)){
//命中了直接返回
return BeanUtil.toBean(shopJson,Shop.class);
}
//3、判断是否命中空值
if(shopJson != null){
//命中了空值,直接返回
return null;
}
//提前定义一个shop,要不在try里跨作用域了最后return不了
Shop shop = null;
try{
//4、缓存未命中,获取锁,判断是否成功拿到锁
if(!getLock(id)){
//没有拿到锁,说明已有别的线程进来了,正在添加缓存
//休眠一会,重新查询缓存
Thread.sleep(200);
//一定要return!递归出口
return cacheBreakDown(id);
}
//5、只有拿到锁的线程才可以查询数据库
shop = getById(id);
//模拟缓存重建过程所需时间
Thread.sleep(500);
//5、判断是否查询到数据
if(shop == null){
//没数据,返回空值缓存,防止缓存击穿
stringRedisTemplate.opsForValue().set(cacheID,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//6、查询成功,添加缓存
stringRedisTemplate.opsForValue().set(cacheID,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL,TimeUnit.MINUTES);
}catch (Exception e){
//统一异常处理
throw new RuntimeException(e);
}finally {
//7、释放锁
unLock(id);
}
//8、返回
return shop;
}
总结一下互斥锁的优缺点:
优点:
- 不需要消耗额外内存
- 实现简单
- 保证了一致性
缺点:
- 没拿到锁的线程需要等待重试,效率不高
- 加锁和解锁的过程没有保证原子性,可能面临死锁的风险