预防缓存击穿的实践--互斥锁
持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第3天,点击查看活动详情
场景: 现在双十一即将开卖了!有一家很受欢迎的店铺。在0.00的的时刻,很多人就想点进店铺,这个时候前端就会向后端发送请求店铺的信息。之前我们说过,1000人同时想查看店铺信息的时候,在第一个人想从数据库里查到信息,放到redis中,撑起redis的保护伞去抵抗请求的时候,伞还没撑起来(未完成将数据库的内容存储到redis的任务),数据库就已经被请求到崩溃了。
为了保护我们的可怜的数据库,我们决定采用互斥锁的方式去保护它!
具体实现思路:
我们会给第一个请求到数据库的幸运线程,给它一个特殊奖励——一把锁,只有拥有了这把锁,他才能有权利去访问数据库!
那其他人呢?因为你来的不够及时!你只能不断的休眠去重试,重新想办法去获取锁。然而,你是永远都获取不到锁的,因为,在你休眠完再尝试获取锁的时候,你会发现,redis中已经有数据啦!那这时你就会想,你的目的本来也就是获取数据,redis中都有了,你干嘛还要拿锁去数据库拿呢?所以你放弃了获取锁,直接返回数据!
打个比方,就好像有很多志愿者,排队想为大家服务,第一个志愿者很幸运,进去了一个小屋子里,完成了任务,其它人看着任务已经完成了,就没必要去做了,乖乖离开了。
具体代码实现
@Override
public Result queryById(Long id) throws InterruptedException {
if(id == null || id < 0){
return Result.fail("商户为空");
}
//利用互斥锁
Shop shop = queryWithMutexLock(id);
if(shop == null){
return Result.fail("未查询到商户");
}
return Result.ok(shop);
}
private Shop queryWithMutexLock(Long id) throws InterruptedException {
String key = SHOP_CACHE_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(shopJson)){
return JSONUtil.toBean(shopJson, Shop.class);
}
if(shopJson != null){
return null;
}
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean mutexLock = tryLock(lockKey);
if(!mutexLock){
Thread.sleep(50);
queryWithPassThrough(id);
}
shop = getById(id);
if(shop == null){
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
}catch (InterruptedException e){
throw new InterruptedException();
}finally {
unLock();
}
return shop;
}
private boolean tryLock(String key){
Boolean ifAbsent = stringRedisTemplate.opsForValue().setIfAbsent(key, "1");
return BooleanUtil.isTrue(ifAbsent);
}
private void unLock(){
stringRedisTemplate.delete("MutexLock");
}
这里互斥锁的实现利用了stringRedisTemplate中的一个方法:setIfAbsent,见名知意,如果key已经存在了,就不去设置。不存在则设置。这不正好满足了,只有一个锁 能抢到key的条件吗?
当然,无论如何,抢完锁了以后,你得去释放资源,这也是unLock方法的作用
预防缓存击穿的实践--逻辑过期
由于互斥锁只有一个线程在完成任务,其他线程都得等他完成任务,这毫无疑问是很耗费性能的。
场景:为了防止一个热门店铺,在0:00时刻被集中访问。我们决定提前将该店铺的信息存入redis!
但是我们并不设置过期时间,而是在字段里面设置过期时间。
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
我们封装了一个这样的类用来存储。
这时候,有同学会好奇了。那这样redis的数据迟迟得不到有效的清理,存储空间不会满嘛?如果真的到了字段里的过期时间,返回的数据不会是旧的嘛?
让我细细为你讲解。
我们的策略是这样的:
我们来看看逻辑过期的实现。
@Override
public Result queryById(Long id) throws InterruptedException {
if(id == null || id < 0){
return Result.fail("商户为空");
}
//解决缓存穿透Shop shop = queryWithPassThrough(id);
//利用互斥锁
Shop shop = queryWithLogicExpire(id);
if(shop == null){
return Result.fail("未查询到商户");
}
return Result.ok(shop);
}
private Shop queryWithLogicExpire(Long id){
String key = SHOP_CACHE_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isBlank(shopJson)){
return null;
}
RedisData redisData = JSONUtil.toBean(shopJson,RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//没过期,直接返回
if(expireTime.isAfter(LocalDateTime.now())){
return shop;
}
//过期了,需要重建缓存
//缓存重建,获取互斥锁
String lockKey = "lock:shop:" + id;
boolean isLock = tryLock(lockKey);
if(isLock){
CACHE_REBUILD_EXECUTOR.submit(() ->{
try {
Shop newShop = this.getById(id);
RedisData data = new RedisData();
data.setData(newShop);
data.setExpireTime(LocalDateTime.now().plusSeconds(TimeUnit.SECONDS.toSeconds(10)));
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(data));
}catch (Exception exception){
throw new RuntimeException(exception);
}finally {
unLock(lockKey);
}
});
}
return shop;
}
private boolean tryLock(String key){
Boolean ifAbsent = stringRedisTemplate.opsForValue().setIfAbsent(key, "1",20,TimeUnit.MINUTES);
return BooleanUtil.isTrue(ifAbsent);
}
private void unLock(String key){
stringRedisTemplate.delete(key);
}
细心的小伙伴已经看出来了逻辑过期的最大优势。那就是线程不需要去等待!没有人会想着去抢锁了!每个人都过得很佛系。这就是逻辑过期的最大优势:不耗费性能!减少了内存的占用!
但是有利必有弊。
有同学会发现。当数据还没更新到缓存中,并且还没有拿到锁的线程,都返回的是老的,旧的数据,没错,这样做虽然性能好,但是它并不能保证数据一致性的问题!
所以说啊,我们要根据场景去斟酌:到底是一致性重要?还是性能重要呢?