5 redis实际应用:缓存穿透 雪崩 击穿问题

134 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第5天,点击查看活动详情

1、缓存穿透

缓存穿透是指客户端请求的数据 在缓存中和数据库中 都不存在,这样缓存永远不会生效,这些请求都会请求到数据库

解决方案:

  • 缓存空对象
    • 优点:实现简单,维护方便
    • 缺点:额外的内存消耗,可能造成短期的不一致问题
  • 布隆过滤:内存占用较少,没有多余key;实现复杂,存在误判可能

image (14).png

改进方案

对于前面查询商户信息的功能,现在做出改进

image (15).png

1. 若缓存没有命中,从数据库中查询获得店铺信息,若数据库中存在,则写入redis缓存中;若数据库中不存在,则将 null 空值写入 redis中,最后返回 2. 若缓存命中,此时也会分为两种情况,从缓存中查询到的是否为空值,若为空值,直接返回;若不为空值,返回店铺信息

@Override
    public Object findShopById(Long id) {

        // 先从redis中查询是否存在该商铺
        String shopValue = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
        if (StrUtil.isNotBlank(shopValue)) {
            JSON json = JSONUtil.parse(shopValue);
            return Result.ok(json);
        }

        if (shopValue != null) {
            // 表示是一个空值
            return Result.fail("店铺信息不存在");
        }

        // 若不存在,从mysql中查询,写入 redis 中,再返回
        Shop shop = this.getById(id);
        // 从数据库中查询得到空值,写入 空值 到redis中
        if (shop == null) {
            stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
            return Result.fail("店铺不存在");
        }

        String jsonStr = JSONUtil.toJsonStr(shop);
        stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, jsonStr, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);

        return Result.ok(shop);

    }

缓存穿透产生的原因是什么?

  • 用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力

缓存穿透的解决方案有哪些?

  • 缓存null值
  • 布隆过滤
  • 增强id的复杂度,避免被猜测id规律
  • 做好数据的基础格式校验
  • 加强用户权限校验
  • 做好热点参数的限流

2、缓存雪崩

缓存雪崩是指在同一时间段内大量的key同时失效或者redis服务宕机,导致大量的请求到达数据库

解决方案:

  • 给不同的Key的TTL添加随机值
  • 利用Redis集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

image (16).png

3. 缓存击穿

缓存击穿:也称为 热点key 问题,就是一个高并发访问并且缓存重建业务较复杂的key突然失效,无数的请求访问会瞬间请求数据库,给数据库造成压力

当多线程同时请求的时候,都会先去查询缓存,若都未命中缓存,都会去查询数据库

image (17).png

解决方案:

  • 互斥锁:当线程1请求缓存,未命中,给线程1加互斥锁,再去查询数据库,然后写入缓存,最后释放锁;那么当其他线程未命中缓存,那么也会去获取锁,由于锁是互斥的,那么会获取失败,然后然线程2休眠重试,重新查询缓存

image (18).png

  • 逻辑过期:在缓存中手动存入key过期的时间字段,如下格式
keyvalue
heima:user:1{name:"Jack", age:21, expire:152141223}

线程1查询缓存,未命中缓存,获取互斥锁,然后开启一个新的线程2,由线程2异步的执行查询数据库重建缓存数据,写入缓存,重置逻辑过期时间,最后线程2释放互斥锁;此时线程1开启新线程之后,直接返回过期数据;当由线程3来请求缓存的时候,未命中缓存或缓存已过期,去获取互斥锁,失败,返回过期的数据;可能存在线程4来请求缓存,这个时候就可能命中缓存,并且没有过期

image (19).png

image (20).png

由于加锁会影响性能,线程会等待,性能会受影响

3.1 缓存击穿解决方案:基于互斥锁

image (21).png

具体流程:若缓存未命中,先尝试获取互斥锁,若锁获取成功,在去数据库查询,通过将获取的数据写入redis,再释放锁,返回数据;若互斥锁获取失败,休眠一段时间,重新查询缓存

redis 加锁的命令: (String类型) setnx stringRedisTemplate 中方法 setIfAbsent

public Shop queryWithPassThrough(Long id) {
        // 先从redis中查询是否存在该商铺
        String shopValue = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
        if (StrUtil.isNotBlank(shopValue)) {
            return JSONUtil.toBean(shopValue, Shop.class);
        }

        if (shopValue != null) {
            // 表示是一个空值
            return null;
        }
        
        Shop shop = null;
        try {
            // 1. 从redis中未获取到缓存,则先获取互斥锁
            boolean flag = tryLock("lock:shop:" + id);
            if (!flag) {
                // 若获取互斥锁失败,则休眠,重新获取缓存
                Thread.sleep(50);
                queryWithPassThrough(id);
            }
            // 2. 再去缓存查询
            // 若不存在,从mysql中查询,写入 redis 中,再返回
            Shop shop = this.getById(id);
            // 从数据库中查询得到空值,写入 空值 到redis中
            if (shop == null) {
                stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }

            String jsonStr = JSONUtil.toJsonStr(shop);
            stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, jsonStr, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);

        } catch (Exception e) {

        } finally {
            // 3. 最后释放锁
            unLock("lock:shop:" + id);
        }
        return shop;
    }

注意:由于代码可能任何会获取锁失败,或者其他原因报错,那么最终都应该释放锁,所有释放锁的代码写在 finally 代码块中