缓存

1.什么是缓存

缓存就是数据交换的缓冲区(称作Cache),是存储数据的临时地方,一般读写性能较高。

缓存的作用:

  • 降低后端负载
  • 提高读写效率,降低响应时间

缓存和成本

  • 数据一致性成本
  • 代码维护成本
  • 运维成本

2.添加Redis缓存

缓存作用模型
根据id查询商铺缓存的流程
代码实现:

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    StringRedisTemplate stringRedisTemplate;
    @Override
    public Result queryById(Long id) {
        String key = CACHE_SHOP_KEY + id;
        //1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        //2.判断是否存在
        if(!StrUtil.isBlank((shopJson))){
            //3.存在,直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        }
        //4.不存在,根据id查询数据库
        Shop shop = getById(id);
        //5.不存在,返回错误
        if(shop == null){
            return Result.fail("店铺不存在");
        }
        //6.存在,写入redis中
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
        //7.返回
        return Result.ok(shop);
    }
}

3.缓存的更新策略

3.1 业务场景:

  • 低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存

redis内存淘汰策略,具体如下:

  1. no-envicition:
  • 该策略对于写请求不再提供服务,会直接返回错误,当然排除del等特殊操作,redis默认是no-envicition策略。
  1. allkeys-random:
  • 从redis中随机选取key进行淘汰
  1. allkeys-lru:
  • 使用LRU(Least Recently Used,最近最少使用)算法,从redis中选取使用最少的key进行淘汰
  1. volatile-random:
  • 从redis中设置过过期时间的key,进行随机淘汰
  1. volatile-ttl:
  • 从redis中选取即将过期的key,进行淘汰
  1. volatile-lru:
  • 使用LRU(Least Recently Used,最近最少使用)算法,从redis中设置过过期时间的key中,选取最少使用的进行淘汰
  1. volatile-lfu:
  • 使用LFU(Least Frequently Used,最不经常使用),从设置了过期时间的键中选择某段时间之内使用频次最小的键值对清除掉
  1. allkeys-lfu:

3.2 主动更新策略(三种)

  1. 由缓存的调用者,在更新数据的同时更新缓存(胜出)

  2. 缓存和数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。

  3. 调用者只操作缓存,由其他线程异步的将缓存数据持久化到数据库,保证最终一致。

操作缓存和数据库有三个问题需要考虑:

  1. 删除缓存还是更新缓存?
    • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
    • 删除缓存:更新数据库时让缓存失败,查询时再更新缓存(正确)
  2. 如何保证缓存与数据库的操作的同时成功或失败?
    • 单体系统,将缓存与数据库操作放在一个事务
    • 分布式系统,利用TCC等分布式事物方案
  3. 先操作缓存还是数据库?
    • 先删除缓存,再操作数据库
      正常情况
      异常情况
      在更新数据库未完成时,Redis查询到数据库更新前的旧值,将其写入Redis,写入完成后,数据库的值才更新完毕,会导致Redis存储的缓存与数据库中的数据不一致的异常情况。
    • 先操作数据库,再删除缓存(正确)
      正常情况
      异常情况(异常机率相较于第一种方式较小)

4.缓存穿透

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

4.1 解决方案

常见的解决方案有两种:

  1. 缓存空对象:
  • 优点:实现简单,维护方便
  • 缺点:
    • 额外的内存消耗 (设置TTL缓解)
    • 可能造成短期的不一致——当给数据库天添加该id的数据时,缓存中的TTL还未过期,用户查询到的依旧是缓存中的空数据(可以通过给数据库新增该数据时,用该数据覆盖缓存中的数据解决)
  1. 布隆过滤
    • 优点: 内存占用少,没有多余的key
    • 缺点:
      • 实现复杂
      • 存在误判的可能
        解决方案

4.2 缓存空对象解决方案的业务实现

实现流程图
代码实现

@Resource
StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
    String key = CACHE_SHOP_KEY + id;
    //1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //2.判断是否存在
    if(!StrUtil.isNotBlank((shopJson))){
        //3.存在,直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    //为防止缓存穿透,判断是否命中空值
    if (shopJson!=null){ //不为NULL则为空字符串
        return Result.fail("店铺信息不存在!");
    }
    //4.不存在,根据id查询数据库
    Shop shop = getById(id);
    //5.不存在,返回错误
    if(shop == null){
        //防止缓存穿透将空值写入Redis
        stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
        return Result.fail("店铺不存在");
    }
    //6.存在,写入redis中
    stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
    //7.返回
    return Result.ok(shop);
}

5.缓存雪崩

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

5.1 解决方案

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

6.缓存击穿

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

6.1 解决方案

  1. 互斥锁
    互斥锁
  • 优点:
    • 没有额外内存消耗
    • 保证一致性
    • 实现简单
  • 缺点:
    • 线程需要等待,性能受影响
    • 可能有死锁的风险
  1. 逻辑过期
    逻辑过期
  • 优点:
    • 线程无需等待,性能较好
  • 缺点:
    • 不保证一致性
    • 有额外内存消耗
    • 实现复杂

6.2 案例实现

需求:根据id查询商铺的业务,基于互斥锁方式来解决缓存击穿的问题

需求:根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿的问题