预防缓存击穿的两种最佳实践

784 阅读2分钟

预防缓存击穿的实践--互斥锁

持续创作,加速成长!这是我参与「掘金日新计划 · 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的数据迟迟得不到有效的清理,存储空间不会满嘛?如果真的到了字段里的过期时间,返回的数据不会是旧的嘛?

让我细细为你讲解。

我们的策略是这样的:

image-20221024160245027

我们来看看逻辑过期的实现。

@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);
    }

细心的小伙伴已经看出来了逻辑过期的最大优势。那就是线程不需要去等待!没有人会想着去抢锁了!每个人都过得很佛系。这就是逻辑过期的最大优势:不耗费性能!减少了内存的占用!

但是有利必有弊。

有同学会发现。当数据还没更新到缓存中,并且还没有拿到锁的线程,都返回的是老的,旧的数据,没错,这样做虽然性能好,但是它并不能保证数据一致性的问题!

所以说啊,我们要根据场景去斟酌:到底是一致性重要?还是性能重要呢?