Redis项目:缓存

发布于:2025-03-19 ⋅ 阅读:(16) ⋅ 点赞:(0)

黑马点评缓存部分:

缓存的标准操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis

最初缓存版本

//最初源代码
    public Shop queryWithPassThrough(Long id) {
        //从redis中查商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(SHOP_ID + id);
        //判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            //存在 直接返回
            return null;
        }
        //判断是不是空
        if (shopJson != null) {
            //返回错误信息
            return null;
        }
        //不存在  查数据库 MybatisPlus
        Shop shop = getById(id);
        //数据库不存在 返回错误
        if (shop == null) {
            //将空值写入redis
            stringRedisTemplate.opsForValue().set(SHOP_ID + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        //数据库中存在  写入redis
        stringRedisTemplate.opsForValue().set(SHOP_ID + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
        //返回
        return null;
    }

解决数据不一致的办法:双写

由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在, 几种方案

Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案

Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理

Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致 

如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来

我们应当是先操作数据库,再删除缓存,原因在于,如果你选择先删除缓存再操作数据库,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。

缓存穿透问题:缓存空对象

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

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

  • 缓存null值

  • 布隆过滤

  • 增强id的复杂度,避免被猜测id规律

  • 做好数据的基础格式校验

  • 加强用户权限校验

  • 做好热点参数的限流 

这里使用缓存null值来完成缓存穿透

public <R,ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(json)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(json, type);
        }
        // 判断命中的是否是空值
        if (json != null) {
            // 返回一个错误信息
            return null;
        }
        // 4.不存在,根据id查询数据库
        R r = dbFallback.apply(id);
        // 5.不存在,返回错误
        if (r == null) {
            // 将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }
        // 6.存在,写入redis
        this.set(key, r, time, unit);
        return r;
    }

缓存雪崩

热点Key问题

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

解决方案:

  • 给不同的Key的TTL添加随机值

  • 利用Redis集群提高服务的可用性

  • 给缓存业务添加降级限流策略

  • 给业务添加多级缓存

缓存雪崩的解决方案:互斥锁

代码:

//互斥锁
    public Shop queryWithMutex(Long id) {
        //1.从redis中查商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(SHOP_ID + id);
        //判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            //存在 直接返回
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        //判断是不是空
        if (shopJson != null) {
            //返回错误信息
            return null;
        }
        //实现缓存重建
        // 获取互斥锁

        String lockKey = SHOP_LOCK_KEY + id;
        Shop shop = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 判断是否成功
            if (!isLock) {
                //失败->休眠并重试
                Thread.sleep(50);
                queryWithMutex(id);
            }
            //成功->查询数据库信息
            shop = getById(id);
            //模拟延迟
            //Thread.sleep(200);
            //数据库不存在 返回错误
            if (shop == null) {
                //将空值写入redis
                stringRedisTemplate.opsForValue().set(SHOP_ID + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            //数据库中存在  写入redis
            stringRedisTemplate.opsForValue().set(SHOP_ID + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
            //释放互斥锁

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            unLock(lockKey);
        }
        //返回
        return shop;
    }
    // 获取锁
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        //返回的啥时候拆箱 可能出现空指针
        return BooleanUtil.isTrue(flag);
    }

    //释放锁
    private void unLock(String key) {
        stringRedisTemplate.delete(key);
    }

互斥锁的大致逻辑:

缓存查询+缓存重建

先从 Redis 中查询缓存数据。

如果缓存命中(数据存在),直接返回缓存数据。

如果缓存未命中(数据不存在),尝试获取分布式锁,防止多个线程同时重建缓存。

如果没有得到锁,休眠 重试

如果得到了锁 查询数据库信息 

数据库信息不存在 将空值写入redis 

数据库中存在 写入redis

最后无论如何释放锁 返回数据

 DoubleCheck: 为了避免多个线程同时重建缓存,从而减少不必要的数据库查询和缓存写入操作

在这里需要加入这个doubleCheck

在获取锁之前,先检查一次缓存(第一次检查)。如果缓存已经命中,直接返回数据,无需获取锁。这样可以避免大量线程同时竞争锁,减少锁的开销。

在获取锁之后、查询数据库之前,再次检查缓存(第二次检查)。如果缓存已经命中,直接返回数据,无需查询数据库。这样可以确保在等待锁的过程中,其他线程没有已经重建缓存,从而避免重复操作。(上述代码没写,逻辑过期的写了)

第二次检查防止数据更新,即使锁了,其他线程或进程仍然可以 通过其他途径更新缓存,而不受当前锁的限制

缓存雪崩的解决方案:逻辑过期

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

//热点key 逻辑
    public Shop queryWithLogicalExpire(Long id) {
        String key = CACHE_SHOP_KEY + id;
        String json = stringRedisTemplate.opsForValue().get(key);
        //从redis中查商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(SHOP_ID + id);
        //判断是否存在
        if (StrUtil.isBlank(shopJson)) {
            //存在 直接返回
            return null;
        }
        //命中 需要把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        //redisData.getData()是一个Object对象 需要,但实际上是一个JSONObject
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        //判断过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            //没过期
            return shop;
        }
        //过期了需要缓存重建
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        //判断是否获取锁成功
        if (isLock) {
            //!!!做DoubleCheck
            String jsonAgain = stringRedisTemplate.opsForValue().get(key);
            if (StrUtil.isNotBlank(jsonAgain)) {
                RedisData redisDataAgain = JSONUtil.toBean(jsonAgain, RedisData.class);
                LocalDateTime expireTimeAgain = redisDataAgain.getExpireTime();
                if (expireTimeAgain.isAfter(LocalDateTime.now())) {
                    return JSONUtil.toBean((JSONObject) redisDataAgain.getData(), Shop.class);
                }
            }
            // 缓存仍需重建,异步执行
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    this.saveShop2Redis(id, 20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    unLock(lockKey);
                }
            });
        }
        return shop;
    }
//缓存重建
public void saveShop2Redis(Long id, Long expireSeconds) {
        //查询店铺信息
        Shop shop = getById(id);
        //封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        //写入Redis
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
    }

实现逻辑过期,需要创建一个逻辑过期数据类 记录过期时间的一个类

@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

逻辑:

和前面一样,先从redis中查缓存 存在就返回数据

在返回数据的时候 先将json反序列化成RedisData对象 再将RedisData 中的data转换成shop,真正需要的对象

然后先判断数据是否过期 没过期返回

过期了需要进行缓存重建

重建还是先拿到锁,再做DoubleCheck 如果不需要继续重建,返回对象

如果确实需要重建 这里采用的是异步执行 

        1、使用线程池 CACHE_REBUILD_EXECUTOR 提交一个异步任务。

        2、在异步任务中,调用 saveShop2Redis 方法重建缓存。

        3、无论缓存重建是否成功,最终都会释放锁。

最后返回数据

值得注意的是,在执行之前需要对缓存数据进行预热 将数据加载到缓存中。