1、缓存击穿原理
缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存业务较复杂的key突然失效了,无数的请求访问在瞬间给数据库带来巨大的冲击。
2、互斥锁原理
互斥锁作用在查询缓存未命中之后,为了防止无数的请求访问突然爆发性地进入数据库,我们让多个线程的关系设立为互斥关系,即当有线程访问数据库时,另一个线程必须等待,当访问数据库的线程访问结束并写入缓存之后,另一个线程才允许去访问。
若查询缓存未命中,会获取互斥锁,获取成功则进入数据库查询并重建缓存数据;获取失败则会进入一段休眠等待时间(该时间取决于项目查询数据库时间的长短)。休眠结束后会重新查询缓存。
3、互斥锁的优点与缺点
优点:
没有额外的内存消耗。互斥锁在线程访问数据库结束之后就会关闭,因此不会有额外的内存消耗。
保证一致性。若线程一获取互斥锁失败,在一段时间后会重新查询缓存。当访问数据库的线程结束并将数据写入缓存之后,线程一再次查询缓存时,得到的数据就会和数据库一致。因此保证了缓存和数据库的数据一致性。
实现简单。仅需要进行判断即可实现,实现十分简单。
缺点:
线程需要等待。由于线程查询数据库需要时间,那么当其他线程访问互斥锁失败时必须要进行等待,因此需要消耗额外的时间。
性能受影响。
可能有死锁风险。
4、场景假设
先假设一个场景:我们需要根据商铺id查询商铺信息。
当提交商铺id之后,我们要先从Redis中查询商铺缓存。
若缓存命中,则直接返回数据;
若缓存没有命中,那么就需要尝试获取互斥锁,即判断此时是否有其他线程在访问数据库。若获取成功,说明没有线程在访问数据库,则根据id查询数据库,查询完后退出数据库,并将商铺信息写入Redis。在此之后释放互斥锁并返回数据。以下是业务逻辑流程图。
5、代码实现
首先根据业务逻辑自定义获取锁和关闭锁两个函数。这里使用Redis的SortedSet数据类型来实现。我们zset一个key到缓存中,若成功则获取到互斥锁。同时还要设置一个过期时间,在查询数据库时间过长或出现故障时能够及时删除key,即关闭锁,让其他线程访问数据库。
//获取锁
private boolean tryLock(String key) {
Boolean flag =
stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
//拆箱底层就是调用booleanValue()方法,如果flag为null的话就会空指针异常
return BooleanUtil.isTrue(flag);
}
//关闭锁
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
然后是实现业务流程。以下是完整实现代码,总共有八个步骤。
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 缓存穿透
// Shop shop = queryWithPassThrough(id);
// 互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if (shop == null) {
return Result.fail("店铺不存在");
}
// 返回
return Result.ok(shop);
}
//互斥锁
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1、从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2、判断是否存在,即是否有真实数据,isNotBlank会判断字符串是否有值,若为空字符串仍然返回false
if (StrUtil.isNotBlank(shopJson)) {
// 3、存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断命中的是否是空值,!=null 意思就是命中了空字符串,有值,但没内容(无真实数据)
if(shopJson != null){
// 返回一个错误信息
return null;
}
// 实现缓存重建
// 4.1、获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2、判断是否获取成功
if(!isLock){
// 4.3、失败,则休眠并重试
Thread.sleep(50);
// 重试从redis中查询缓存——递归
return queryWithMutex(id);
}
//注意: 获取锁成功应该再次检测redis缓存是否存在,做DoubleCheck。如果存在则无需重建缓存
// 4.4、成功,根据id查询数据库
shop = getById(id);
// 模拟重建的延时
Thread.sleep(200);
// 5、不存在,返回错误
if (shop == null) {
// TODO 缓存穿透:用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求给数据库带来巨大压力
// TODO 方案一:缓存null值,方案二:布隆过滤,方案三:增强id的复杂度,避免被猜测id规律,方案四:做好数据的基础格式校验,方案五:加强用户权限校验,方案六:做好热点参数的限流
//将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
//返回错误信息
return null;
}
// 6、存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
// 7、释放互斥锁
unlock(lockKey);
}
// 8、返回
return shop;
}
}
第一步:从redis中查询商铺缓存
String key = CACHE_SHOP_KEY + id;
// 1、从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
第二、三步:判断缓存是否存在,存在则返回。此处不能使用“== null”,因为可能会有空字符串的可能,应该判断是否有真实数据。isNotBlank会判断字符串是否有值,若为空字符串仍然返回false。
// 2、判断是否存在,即是否有真实数据,isNotBlank会判断字符串是否有值,若为空字符串仍然返回false
if (StrUtil.isNotBlank(shopJson)) {
// 3、存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
此处还应该判断以下是否为空字符串,这种情况可能发生,但是不合理,因此返回错误信息。
// 判断命中的是否是空值,!=null 意思就是命中了空字符串,有值,但没内容(无真实数据)
if(shopJson != null){
// 返回一个错误信息
return null;
}
第四步:获取锁,成功后查询数据库。
// 实现缓存重建
// 4.1、获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2、判断是否获取成功
if(!isLock){
// 4.3、失败,则休眠并重试
Thread.sleep(50);
// 重试从redis中查询缓存——递归
return queryWithMutex(id);
}
//注意: 获取锁成功应该再次检测redis缓存是否存在,做DoubleCheck。如果存在则无需重建缓存
// 4.4、成功,根据id查询数据库
shop = getById(id);
// 模拟重建的延时
Thread.sleep(200);
第五步:不存在则返回错误。
// 5、不存在,返回错误
if (shop == null) {
// TODO 缓存穿透:用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求给数据库带来巨大压力
// TODO 方案一:缓存null值,方案二:布隆过滤,方案三:增强id的复杂度,避免被猜测id规律,方案四:做好数据的基础格式校验,方案五:加强用户权限校验,方案六:做好热点参数的限流
//将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
//返回错误信息
return null;
}
第六步:重建缓存。
// 6、存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
第七、八步:释放(关闭)互斥锁并返回。
finally {
// 7、释放互斥锁
unlock(lockKey);
}
// 8、返回
return shop;