redis缓存 | 更新策略、缓存穿透、缓存雪崩、缓存击穿

164 阅读6分钟

一. 缓存更新策略

1.1 常见更新策略

  • 内存淘汰(利用redis的内部淘汰机制)
  • 超时剔除(给缓存数据添加TTL时间)
  • 主动更新(编写业务逻辑,在修改数据库的同时,更新缓存) image.png

image.png

1.2 主动更新策略

image.png

一般会选择第一种方案

1.3 主动更新策略中的原子性问题和数据不一致问题

image.png

1.3.1 数据不一致问题(线程安全问题)

先写数据库,再删缓存的线程安全(缓存数据不一致问题)高一点点(比先删缓存,再删数据库)

解释:

  1. 若先删缓存,在删除完成到数据库更新的期间里,只要其中过来查询,就会将旧的数据重新写到缓存里,造成数据的不一致现象;
  2. 而如果先修改数据库,则必须要在一系列操作(更新数据库)之前恰好缓存失效,查询到旧数据后,要正好在删除缓存后写入(不然最后还是会被删除),发生概率较低

1.3.2 原子性问题(如何保证删除缓存和更新数据库的操作同时成功或失败)

确保数据库和缓存删除操作的原子性:

  1. 单体系统:将缓存与数据库操作放在一个事务里
  2. 分布式系统:利用TCC等分布式事务方案

e.g:

@Override
@Transactional
public Result updateShop(Shop shop) {
    Long id = shop.getId();
    if(id == null){
        return Result.fail("店铺id不能为空!");
    }
    //1. 更新数据库
    updateById(shop);
    //2.删除缓存
    stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY+id);
    return Result.ok();
}

二. 缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会达到数据库。 不断发起这样的请求,会给数据库带来巨大压力

解决方案:

  1. 缓存空对象
    • 优点:实现简单,维护方便
    • 缺点:
      • 额外的内存消耗 => 设置TTL
      • 可能造成短期的不一致
  2. 布隆过滤 是一种算法
    • 优点:内存占用较少,没有多余的key
    • 缺点:
      • 实现复杂 => redis自带的bitmap可以帮助简化开发
      • 存在误判可能
      image.png

布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。

//缓存空对象方法实现
public Result queryById(Long id) {
    String key = RedisConstants.CACHE_SHOP_KEY + id;
    // 1. 从redis中查询商铺缓存  (以json形式存储)
    String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
    // 2. 判断是否存在
    if (StrUtil.isNotBlank(cacheShopJson)){
        // 3. 存在,直接返回
        Shop shop = JSONUtil.toBean(cacheShopJson, Shop.class);
        return Result.ok(shop);
    }
    // 判断命中是否是空值
    if(cacheShopJson != null){
        //说明是缓存空值"" 返回错误
        return Result.fail("店铺不存在!");
    }


    // 4. 不存在,根据id查询数据库
    Shop shop = getById(id);
    // 5. 不存在,返回错误
    if(shop == null){
        // 将空值写入redis (避免缓存击穿问题)
        stringRedisTemplate.opsForValue().set(key,"", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
        // 返回错误信息
        return Result.fail("店铺不存在!");
    }
    // 6. 存在,写入redis
    stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
    // 7. 返回数据
    return Result.ok(shop);
}

👆被动方案

  1. 增强id的复杂度,避免被猜测id规律
  2. 做好数据的基础格式校验(如0)
  3. 加强用户权限校验
  4. 做好热点参数的限流

三. 缓存雪崩

缓存雪崩是指同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:

  1. 给不同的Key的TTL添加随机值
  2. 利用Redis集群提高服务的可用性(哨兵机制 主从)
  3. 给缓存业务添加降级限流策略
  4. 给业务增加多级缓存(web,nginx,redis,jvm)

四. 缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的Key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

(缓存雪崩:全部数据TTL过期;缓存击穿:少部分热点数据TTL过期) image.png

解决方案:

  1. 互斥锁(更看重 一致性)

    image.png

  2. 逻辑过期(更看重 可用性)

    在活动开始时将数据缓存到redis中(缓存预热),不设置TTL,而是将TTL作为缓存字段,通过程序来进行逻辑上的过期判断,在理论上缓存是一直存在的(旧数据),在活动结束后将热点数据的缓存删除。

    image.png


image.png

解决方案实现:

  1. 互斥锁

首先,因为要在获得锁后休眠、重试,所以不能使用synchronizedlock,因为这两个是会导致等待。所以我们要自定义锁,而redis的setnx命令是只在key不存在时才执行,所以可以以此为锁,释放锁的话只需要del这个锁的key即可。

// redis实现互斥锁机制

private boolean tryLock(String key){
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);   //转成基本类型返回,如果直接返回,拆箱的时候可能会造成空指针
}

private void unLock(String key){
    stringRedisTemplate.delete(key);
}
/**
*@Description: 互斥锁解决缓存击穿
*@Param: [id]
*/
public Shop queryWithMutex(Long id){
    String key = RedisConstants.CACHE_SHOP_KEY + id;
    boolean isWait = true;
    String lockKey = "lock:shop:"+id;
    Shop shop = null;
    try {
        while (isWait){
            // 1. 从redis中查询商铺缓存(命中)
            String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
            if (StrUtil.isNotBlank(cacheShopJson)){
                shop = JSONUtil.toBean(cacheShopJson, Shop.class);
                return shop;
            }

            //实现缓存重建
            //1. 获取互斥锁
            boolean isLock = tryLock(lockKey);
            //2.判断是否获取成功
            if(!isLock){
                //2.1 失败,则休眠并重试
                Thread.sleep(50);
            }else {
                isWait = false;
            }
        }
        //2.2 成功
        //2.2.1 再次检查缓存,做doublecheck,防止是刚好前一个更新完缓存后放了锁,然后被你拿到了
        String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(cacheShopJson)){
            shop = JSONUtil.toBean(cacheShopJson, Shop.class);
            unLock(lockKey);  //放锁
            return shop;
        }
        //2.2.2 根据id查询数据库
        shop = getById(id);
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        //释放互斥锁
        unLock(lockKey);
    }

    //返回
    return shop;
}
  1. 逻辑过期

因为实际上不存在缓存不命中的可能,所以我们只需要在命中后判断缓存是否过期

未过期,返回

过期,尝试获取互斥锁,成功了进行重建缓存(开启独立的线程执行,自己返回旧数据),失败了直接返回旧数据。

/**
*@Description: 逻辑过期 实体类
*@Param:
*@return:
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}
/**
*@Description: 活动热点数据缓存预热
*@Param: [id, expirSeconds]
*@return: void
*/
public void saveShop2Redis(Long id, Long expirSeconds){
    String key = RedisConstants.CACHE_SHOP_KEY+id;
    Shop shop = getById(id);
    RedisData redisData = new RedisData(LocalDateTime.now().plusSeconds(expirSeconds), shop);
    String jsonStr = JSONUtil.toJsonStr(redisData);
    stringRedisTemplate.opsForValue().set(key,jsonStr);
}
//线程池
private static final ExecutorService CACHE_REBUILD_EXXECUTOR = Executors.newFixedThreadPool(10); 

/**
*@Description: 逻辑过期解决缓存穿透问题
*@Param: [id]
*/
public Shop queryWithLogicExpire(Long id) {
    String key = RedisConstants.CACHE_SHOP_KEY + id;
    // 1. 从redis中查询
    String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
    // 2. 判断是否存在
    if (StrUtil.isBlank(cacheShopJson)) {
        //未命中 返回空
        return null;
    }
    // 3. 存在,反序列化为对象
    RedisData redisData = JSONUtil.toBean(cacheShopJson, RedisData.class);
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    // 4. 判断是否过期
   if(!expireTime.isAfter(LocalDateTime.now())){
       // 5.1 未过期 返回
       return shop;
   }
    // 5.2 过期,需要缓存重建
    // 6. 缓存重建
    // 6.1 获取互斥锁
    String lockKey = RedisConstants.LOCK_SHOP_KEY+id;
    boolean isLock = tryLock(lockKey);
    // 6.2 判断是否获取锁成功
    // 6.3 成功 开启线程执行重建过程
    if(isLock){
        //可以在次再次检测redis缓存是否过期,doublecheck
       CACHE_REBUILD_EXXECUTOR.submit(()->{
           // 重建缓存
           this.saveShop2Redis(id,20L);
           //假设长时间的数据更新
           //sleep(200);
           //释放锁
           unLock(lockKey);
       });
    }
    // 6.4 返回过期商品信息
    return shop;
}