跳转至

缓存

缓存穿透

定义

客户端请求的数据在缓存中和数据库中都不存在,使得请求全部打到数据库

解决方案

  1. 缓存空对象

    将不存在的键值对缓存到 redis 中(val 为 null)

    • 额外的内存消耗(通过加 TTL 缓解)
    • 可能存在短期的不一致性,当数据库中存在之后缓存中仍然为 null(通过控制 TTL 的时间或主动覆盖缓解)
  2. 布隆过滤

    在客户端和 Redis 之间加一个布隆过滤器

    布隆过滤器使用 bitmap 来实现,不是百分百准确:若判断不存在则一定不存在,若判断存在则可能不存在,依然有穿透的风险

    image-20250113190448300

实现

缓存空对象

image-20250113190806028

缓存雪崩

定义

同一时段:

  • 大量的缓存 key 同时失效
  • Redis 服务宕机

解决方案

  • 将不同的 key 的 TTL 设置为随机的
  • 利用 Redis 集群提高服务的可用性
  • 给缓存业务添加降级限流策略(快速失败,拒绝服务等)
  • 给业务添加多级缓存

缓存击穿

定义

即热点 key 问题,一个被高并发访问并且缓存重建业务较为复杂的 key 突然失效了,无数请求同时访问数据库带来巨大的冲击

  • 高并发访问:比如某个正在促销的商品
  • 缓存重建业务较复杂:重新从数据库查询时,需要进行多表查询或者进行表关联的运算

多个请求同时对数据库进行高成本操作:

image-20250113192351873

解决方案

注意这里的查询函数是对 shop 基于 id 的查询,这里的热 key 为 shop

互斥锁

  • 加锁是只让最先的请求去把数据从数据库中取到 redis 中

image-20250113192637842

逻辑过期

在插入数据之外还需要手动插入一个 expire_time,即插入一个逻辑过期的时间

  • 此时锁是用来防止重复创建查询线程的

image-20250113200758865

优缺点对比

  • 互斥锁选择了一致性
  • 逻辑过期使用了可用性

image-20250113201307042

实现

互斥锁

image-20250113211457042

通过 Redis 的 setnx 来实现互斥锁,当不同的 JVM 中不同的线程拿到锁之前都需要在同一个 redis 中执行 setnx,只有此命令执行成功才算获取锁成功。

setnx lock 1

自定义 tryLock 方法

private boolean tryLock(String key)
{
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS); // 方法的返回类型是 Boolean
    return BooleanUtil.isTrue(flag);
}

private void unlock(String key)
{
    stringRedisTemplate.delete(key);
}

业务逻辑

public Shop queryWithMutex(Long id)
{
    String key CACHE_SHOP_KEY + id;

    /* 从 redis 中查询 */
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    if(StrUtil.isNotBlank(shopJson)){ // 不是 null 或 "" 等,即命中了缓存
        return JSONUtil.toBean(shopJson, shop.class);
    }
    if(shopJson == null){
        return null // 是为了解决缓存穿透所缓存的空对象
    }

    /* 从数据库中查询 */
    String lockKey = "lock:shop:" + id;
    boolean isLock = tryLock(lockKey);
    // 获取锁失败,先休眠,再递归调用查询方法
    if(!isLock){
        Thread.sleep(50);
        queryWithMutex(id);
    }

    Shop shop = getById(id); // getById() 查询 MySQL
    if(shop == null){ // 没有在数据库中查到,缓存空对象以应对缓存穿透
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, T);
        retrun null;
    }
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MININTES);

    unlock(lockKey); // 放锁
    return shop;
}

逻辑过期

这里假设热 key 已经添加到了 redis 中,那么缓存理论上不会出现未命中的情况;但是为了健壮性,这里还是加上了一个未命中直接返回 null 的逻辑

image-20250113214220552

业务逻辑

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

public Shop queryWithLogicalExpire(Long id)
{
    String key CACHE_SHOP_KEY + id;

    /* 从 redis 中查询 */
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 缓存未命中
    if(StrUtil.isBlank(shopJson)){ 
        return null;
    }

    /* 缓存命中,判断逻辑过期 */
    RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class); // 把 json 反序列化为对象
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class); // 直接查旧数据
    LocalDataTime expireTime = redisData.getExpireTime();

    // 逻辑未过期
    if(expireTime.isAfter(LocalDataTime.now())){
        return shop
    }

    // 逻辑已过期
    String lockKey = "lock:shop:" + id;
    boolean isLock = tryLock(lockKey);
    // 成功获取锁,创建子线程从数据库中读取
    if(isLock){
        CACHE_REBUILD_SERVICE.submit(() -> {
            this.saveShop2Redis(id, 20L); // 重新从数据库读
            unlock(lockKey);
        })
    }

    // 直接返回旧数据
    return shop;
}