Java利用互斥锁解决缓存击穿问题

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;

 

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

梅川库紫

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值