Bootstrap

Redis双写一致性、缓冲穿透、缓存击穿、缓冲雪崩统一解决方案

1.自我介绍与前置

​ 不知名的IT打工人,梦想是成为一个优秀的架构师。我的网名是bcxj,喜欢学习技术,偶尔玩玩游戏,羽毛球,乒乓,热爱一切新事物。今天也是自己写技术笔记分享了。哈哈!

​ 这篇博客需要读者具备一定的redis基本的命令操作,最好对redis的缓存双写一致,缓冲击穿,缓冲穿透,缓存雪崩有一定的概念上的认识,以及Boot项目开发经验。

2.问题介绍

​ 代码放在https://gitee.com/xuanjin-code/redis-mysql-solution,包括md文档,有需自取。

​ 我们知道redis经常作为缓冲工具来提升请求的响应速度,毕竟基于内存很快嘛。好了,想必大家之前看过一些redis教程,他们会介绍缓存双写一致,缓冲击穿,缓冲穿透,缓存雪崩的这些概念以及解决方案,但是这些解决方案都是比较分散的,就是只针对一个问题进行解决。

​ 所以我想我们能不能有一个框架/工具类,这个工具类可以帮我们一并解决缓存双写一致,缓存雪崩,缓存击穿,缓冲穿透,缓存预热,缓存无效内容堆积等等全部的问题呢?

​ 据我所知,好像这类框架或工具类好像没有问世或者是没有普及。所以啊这几天我就自研了一个工具类,没错,就是那个解决了缓存双写一致,缓存雪崩,缓存击穿,缓冲穿透等等全部的问题的工具类。当然代码是借鉴了网上教程的,然后在此基础上进行扩展。

​ 小伙伴这里不妨先不着急看下面的内容,思考一个问题:假设这个任务给你,你会怎么做?

这里的Maven依赖配置我就不写了,都是些很常见的依赖
例如Redis的相关的,hutool工具类,lombok工具类等等...
依赖文件放在远程仓库,需要请自取

​ 想好了吗?哈哈,那就听听我的思路吧。要不咋先把代码直接放上来吧,其实逻辑很简单,当然看不懂也没关系,下面会慢慢道来

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

/**
 * ClassName: RedisData
 * Package: com.hmdp.bean
 * Description: 
 *
 * @Author BCXJ
 * @Create 2024/6/29 15:39
 * @Version 1.0
 * @Since 1.0
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RedisData<T> {
    private LocalDateTime expireTime;
    private T data;
}

这个RedisData我解释一下啊,因为解决缓存击穿的问题使用的是维护逻辑过期字段的方式。所以啊,我们需要封装一个咱们自己的类,类似前后端交互的Result工具类一样。

下面是一些数据的json格式展示,expireTime就是维护的逻辑过期字段

2-2

import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.hmdp.bean.RedisData;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

import static com.hmdp.utils.RedisConstants.*;
import static com.hmdp.utils.RedisConstants.LOCK_SHOP_KEY;

/**
 * ClassName: RedisCacheUtils
 * Package: com.hmdp.utils
 * Description: 工具类组件, 将缓冲穿透,击穿,雪崩的解决方案继承封装。涉及所有的相关的Reids缓存一致性问题,可以直接使用这个工具类
 *
 * @Author BCXJ
 * @Create 2024/6/29 21:06
 * @Version 1.0
 * @Since 1.0
 */
@Component // 这是组件工具类
public class RedisCacheUtils {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    //将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
    public <T> void set(String key, T value, int time, TimeUnit timeUnit){
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, timeUnit);
    }


    /**
     * 逻辑过期的数据-》redis的方法封装
     * 其实应该返回一个true/false
     * 不为null表示成功,反之表示失败
     * 超时时间以minute为单位
     * 这个方法适用于缓存穿透&击穿&雪崩,都可以使用这一套直接进行,这一个方法是三个缓存问题的综合解决方案
     * 1.0 版本存在缓存无效内容堆积问题
     * 2.0 版本解决缓存无效内容堆积问题
     */
    private <K,V> RedisData<V> saveBean2Redis(String keyPre, K id, Long expireTime, TimeUnit timeUnit,
                                              Function<K,V> function) {

        expireTime += RandomTime(timeUnit); // 随机时间,防止缓存击穿
        try {
            //0 获取key
            String key = keyPre + id.toString();

            // 1 查询数据库数据
            V bean = function.apply(id); // 查找数据库的内容

            // 1.1. 没查到,防止缓冲穿透,redis写入null数据
            if (null == bean) {
                // 防止缓存击穿, 使用RedisData封装数据
                RedisData<V> redisData = new RedisData<V>(LocalDateTime.now().plusSeconds(expireTime), null);
                // 在逻辑过期的基础上给key设置ttl,解决redis缓存无效内容堆积问题
                stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData), expireTime + RandomTime(timeUnit), timeUnit);
                return redisData;
            }

            //2 封装数据
            RedisData<V> redisData = new RedisData<V>(LocalDateTime.now().plusMinutes(expireTime), bean);

            //3 存入缓存
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData),expireTime + RandomTime(timeUnit), timeUnit);

            //4 返回结果
            return redisData;
        } catch (Exception e) {
            // 5 异常处理
            e.printStackTrace();
        }
        // 6 返回null表示上面业务有问题
        return null;
    }

    /**
     * 生成一个随机时间,用于缓冲雪崩
     * 代码不完善,只写了minutes 和 seconds的解决方案
     * @param timeUnit
     * @return
     */
    private Long RandomTime(TimeUnit timeUnit) {
        if(TimeUnit.MINUTES == timeUnit){
            return (long) Math.ceil(Math.random() * 10 + 1); // 随机时间,防止缓存击穿
        } else if(TimeUnit.SECONDS == timeUnit){
            return (long) Math.ceil(Math.random() * 600 + 1); // 随机时间,防止缓存击穿
        }

        // 这个return 是默认
        return 1L;
    }

    // 异步操作数据库线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 逻辑过期解决缓存击穿的问题
     * @param keyPre key前缀, key前缀需要加上:
     * @param id     key 后缀
     * @param type   bean的类型
     * @param time   存进redis的bean的生命长度
     * @param unit   bean的生命长度单位
     * @param lockTime 锁的时间
     * @param lockUnit  锁的时间单位
     * @param function  查询数据库的方法,利用函数式编程解决
     * @param nullTime  防止缓存穿透的空处理时间
     * @param nullUnit  防止缓存穿透的空处理时间单位
     * @return 返回结果
     * @param <T> 返回的类型
     * @param <K> Key后缀类型
     */
    public <T, K> T queryWithLogicalExpire(String keyPre, K id, Class<T> type,
                                             Long time, TimeUnit unit,
                                             Long lockTime, TimeUnit lockUnit,
                                             Long nullTime, TimeUnit nullUnit,
                                             Function<K, T> function) {
        try {
            // 0 确定key
            String key = keyPre + id;
            // 1 先查询缓存, 有则判断日期
            // 1.1 没有过期,直接返回
            String redisShopJSON = stringRedisTemplate.opsForValue().get(key);
            RedisData<T> bean = null;
            if (StrUtil.isNotBlank(redisShopJSON)) {
                bean = JSONUtil.toBean(redisShopJSON, RedisData.class); // 涉及到泛型,所以需要指定类型,这里的工具类会自动识别
                if (LocalDateTime.now().isBefore(bean.getExpireTime())) {
                    return beanByRedisData(bean);
                }
            }

            // 2. 过期/没有查到数据,获取锁
            if (!tryLock(keyPre, id, lockTime, lockUnit)) {
                // 没有获取到锁,直接返回脏数据
                return beanByRedisData(bean);
            }

            // 2.2 获取锁成功,调用数据库 -》缓存的封装解决方案的方法
            // 异步实现,采用线程池的方式
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                //重建缓存
                 if(null == this.saveBean2Redis(keyPre, id, time, unit, function)){
                     throw new RuntimeException("数据不存在");
                 }
            });

            //3 返回数据
            return beanByRedisData(bean);
        } catch (Exception e) {
            e.printStackTrace();
            unlock(keyPre, id);
        }
        throw new RuntimeException("未知错误,叫程序牛马员调bug~");
    }


    /**
     * 根据Bean返回对应的Result
     * @param bean
     * @return
     * @param <T>
     */
    private <T> T beanByRedisData(RedisData<T> bean) {
        // 2.1 获取锁失败,又脏数据返回脏数据,否则直接返回错误
        if (null == bean) {
            System.out.println("redis不存在数据");
            return null;
        }
        if (null == bean.getData()) {
            System.out.println("mysql不存在数据");
            return null;
        }
        return bean.getData();
    }

    /**
     * 尝试获取锁
     *
     * @param id
     * @return
     */
    private <K> boolean tryLock(String keyPre, K id, Long time, TimeUnit unit) {
        String key = keyPre + id;
        // 1 获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, "", time, unit);
        return BooleanUtil.isTrue(success); //防止自动拆箱出现空异常
    }

    /**
     * 释放锁
     */
    private <K> void unlock(String keyPre, K id) {
        String key = keyPre + id;
        stringRedisTemplate.delete(key);
    }


    /**
     *
     * @param bean 传进的修改信息的bean
     * @param key   redis对应的key
     * @param function  consumer 修改数据的操作
     * @param <T> 泛型-数据修改的bean的类型
     */
    public <T> Integer updateBeanById(T bean, String key, Function<T, Integer> function) {
        /**
         * 达到双写一致性,应该先操作数据库,然后删除缓存
         */
        //1 更新数据-mysql
        Integer apply = function.apply(bean);
        //2 删除缓存-redis
        stringRedisTemplate.delete(key);
        return apply;
    }


    /**
     * 根据id删除bean
     * @param key  redis对应的key
     * @param id  数据的主键
     * @param function 删除数据库的函数
     * @return DML 语句都会返回一个int
     * @param <T> 返回类型泛型
     */
    public <T> Integer deleteById(String key, T id, Function <T, Integer> function) {
        // 1. 先删除数据库
        Integer apply = function.apply(id);
        // 2. 再删除缓存
        stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
        return apply;
    }


    /**
     * 适用于条件删除
     * @param key
     * @param bean
     * @param function
     * @return
     * @param <T>
     */
    public <T> Integer deleteByBean(String key, T bean, Function <T, Integer> function) {
        // 1. 先删除数据库
        Integer apply = function.apply(bean);
        // 2. 再删除缓存
        stringRedisTemplate.delete(key);
        return apply;
    }
}

下面是工具类的简单使用教程:

Controller.java
		/**
     * 根据id查询商铺信息
     * @param id 商铺id
     * @return 商铺详情数据
     */
    @GetMapping("/{id}")
    public Result queryShopById(@PathVariable("id") Long id) {

        return shopService.queryByShopId(id);
    }
ServiceImpl.java
    /**
     * 根据id查询店铺, 需要加入redis缓存
     *
     * @param id
     * @return
     */
    @Override
    public Result queryByShopId(Long id) {
        return Result.ok(redisCacheUtils.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class,
                CACHE_SHOP_TTL, TimeUnit.MINUTES,
                LOCK_SHOP_TTL, TimeUnit.SECONDS,
                CACHE_NULL_TTL, TimeUnit.MINUTES, this::getById)); // 缓存击穿的解决方案-逻辑过期解决方案
    }
// this::getById 是方法引用,javaSe内容不再介绍
// 由于采用的是mysbatis-plus框架,所以service可以直接调用方法查询sql

下面是一些测试效果

Jietu20240703-180116-HD

3.问题解决

​ 首先啊,我还是要说,我写的这个工具类肯定还有很多不足的地方。毕竟只有我一个人嘛,大家要学习的是一种思维。我将我这个工具类形容为一个Utils带你了解缓存双写一致,缓存雪崩,缓存击穿,缓冲穿透,缓存预热,缓存无效内容堆积解决方案这些东西。东西很简单,也不是很高大尚。希望对你有帮助!

3.1.引出需求

​ 你的老板现在有个需求,就是有个商品搞个折扣活动。很简单的一个需求是吧,就是说啊,咋们这个商品活动,肯定是活动当天有很多人访问,我假设1亿用户访问这个活动。那么兄弟我问你,这个请求的业务适合从mysql查吗?1亿人每人查一次,mysql就需要处理1亿的sql查询语句,兄弟这个需求你要是直接查mysql怕是第二天要卷铺盖走人了。哈哈,开个玩笑!其实mysql并发能力有限,直接查mysql怕是会让服务器宕机了。

​ 所以缓存这个东西就很重要了。它就像一辆越野车的弹簧一样,起到缓冲的作用。这些东西其实很难跟初学者讲清楚,如果你有一定的开发经验,前面那句话你肯定有一定的理解。

​ 所以我们引出redis,它基于内存,肯定比mysql快,因为mysql是直接与磁盘交互的嘛。

​ 好,我当你有一定的redis基础了,下面的内容你没有redis基础,就去学习一下再来看。

3.2.缓冲穿透问题

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

​ 记住,咋们不要指望前端发的请求是符合你自己的预期的,你要知道一个东西,叫做攻击网站。假设人家就看你不爽,就发1亿条请求过来,叫你查询你mysql不存在的数据,那这些请求你还去查mysql。还是上面那个问题,mysql宕机,卷铺盖走人吧。所以缓冲穿透问题的解决很重要。

常见的解决方案有两种:

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

​ 我就实现了缓存null的实现,其实小伙伴可以结合缓存null和布隆过滤器使用。

​ 至于单个解决方案的代码我就不放了。网上一堆。那看看咱的代码如何解决的缓存穿透问题,完整的代码在上面,我只截这部分的重点Code

​ 先分析分析,用户查询请求过来,发现redis没有数据,去mysql查,查不到,返回查不到结果。这样的处理会造成缓存穿透的问题。

​ 所以取mysql查数据,查不到数据,就写入redis空数据。这样就可以了。

​ 其实还有一个问题,就是查mysql,重写如redis的这一过程会出现线程并发问题,意思是依旧可能有多个请求查询mysql,所以需要锁。那没有获取锁的请求怎么办,直接返回null就可以了。

​ 看看下面的代码,

// 既然是缓存穿透,那么肯定是查询了mysql没有的数据。
		queryWithLogicalExpire方法的部分内容如下		
            // 0 确定key
            String key = keyPre + id;
            // 1 先查询缓存, 有则判断日期
            // 1.1 没有过期,直接返回
            String redisShopJSON = stringRedisTemplate.opsForValue().get(key);
            RedisData<T> bean = null;
            if (StrUtil.isNotBlank(redisShopJSON)) {
                bean = JSONUtil.toBean(redisShopJSON, RedisData.class); // 涉及到泛型,所以需要指定类型,这里的工具类会自动识别
                if (LocalDateTime.now().isBefore(bean.getExpireTime())) {
                    return beanByRedisData(bean);
                }
            }

            // 2. 过期/没有查到数据,获取锁
            if (!tryLock(keyPre, id, lockTime, lockUnit)) {
                // 没有获取到锁,直接返回脏数据
                return beanByRedisData(bean);
            }

            // 2.2 获取锁成功,调用数据库 -》缓存的封装解决方案的方法
            // 异步实现,采用线程池的方式
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                //重建缓存
                 if(null == this.saveBean2Redis(keyPre, id, time, unit, function)){
                     throw new RuntimeException("数据不存在");
                 }
            });
            //3 返回数据
            return beanByRedisData(bean);

3.3. 缓冲无效内容过期处理

​ 这里前提说一下,如果redis没数据,就去mysql查然后写到redis。那sql语句如何执行,这里需要借助JavaSE的知识,就是方法引用+四个基本函数式接口

名字接口名对应的抽象方法
消费Consumervoid accept(T t);
生产SupplierT get();
转换Function<T, R>R apply(T t);
判断Predicateboolean test(T t);

以上的函数式接口都在java.util.function包中,通常函数接口出现的地方都可以使用Lambda表达式,所以不必记忆函数接口的名字,这些函数式接口及子接口在后续学习中很常用。

​ 其实函数式接口这个东西很难讲明白,需要自己多去练,多去体会。

​ 还有一个问题,既然我们维护的是逻辑字段,那如果这个维护的逻辑字段过期,但是这个数据是永远都会存在redis,如果这种数据越来越多,那么redis将消耗大量的内容存无效数据。

​ 哈哈,这个问题我的解决是给逻辑字段加上一个随机ttl时间,这个时间大于维护逻辑时间(我默认设置为1~10分钟随机一个时间段),所以虽然逻辑字段过期,但是可以保证在其过期后的10分钟内,这个无效数据是一定会被redis自动删除的。

​ 其实这个随机1-10分钟还可以解决缓存雪崩的问题,这个后面再说

saveBean2Redis方法部分内容如下:
        expireTime += RandomTime(timeUnit); // 随机时间,防止缓存击穿
        try {
            //0 获取key
            String key = keyPre + id.toString();

            // 1 查询数据库数据
            V bean = function.apply(id); // 查找数据库的内容

            // 1.1. 没查到,防止缓冲穿透,redis写入null数据
            if (null == bean) {
                // 防止缓存击穿, 使用RedisData封装数据
                RedisData<V> redisData = new RedisData<V>(LocalDateTime.now().plusSeconds(expireTime), null);
                // 在逻辑过期的基础上给key设置ttl,解决redis缓存无效内容堆积问题
                stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData), expireTime + RandomTime(timeUnit), timeUnit);
                return redisData;
            }
          
RandomTime方法如下:
     /**
     * 生成一个随机时间,用于缓冲雪崩
     * 代码不完善,只写了minutes 和 seconds的解决方案
     * @param timeUnit
     * @return
     */
    private Long RandomTime(TimeUnit timeUnit) {
        if(TimeUnit.MINUTES == timeUnit){
            return (long) Math.ceil(Math.random() * 10 + 1); // 随机时间,防止缓存击穿
        } else if(TimeUnit.SECONDS == timeUnit){
            return (long) Math.ceil(Math.random() * 600 + 1); // 随机时间,防止缓存击穿
        }
        // 这个return 是默认
        return 1L;
    }

3.4.缓存雪崩问题

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

解决方案:

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

​ 我想说的是对于redi集群的高可用在这我们就不多说了

对于缓存雪崩,本质就是redis现在已经存了大量经常访问的数据,但是如果此时redis出现宕机,或者这些数据同时失效(咋们设置redis-key的时间,小白会直接设置为一样的),对于宕机的情况,只能从redis的部署与配置上下功夫,不细说。主要说业务如何解决,很简单,假设我们的存入redis的那类数据统一为30分钟,那设置时间的时候,我们就给key的逻辑过期时间在30分钟基础上加上一个合适的随机事件即可。这样就避免出现大量的key同时失效。除此之外,上面的缓存内容堆积问题解决方案对这里的缓存雪崩也起到了二次的防范的效果。

saveBean2Redis方法部分内容如下:
						expireTime += RandomTime(timeUnit); // 随机时间,防止缓存击穿
            // 1 查询数据库数据
            V bean = function.apply(id); // 查找数据库的内容

            // 1.1. 没查到,防止缓冲穿透,redis写入null数据
            if (null == bean) {
                // 防止缓存击穿, 使用RedisData封装数据
                RedisData<V> redisData = new RedisData<V>(LocalDateTime.now().plusSeconds(expireTime), null);
                // 在逻辑过期的基础上给key设置ttl,解决redis缓存无效内容堆积问题
                stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData), expireTime + RandomTime(timeUnit), timeUnit);
                return redisData;
            }

3.5.缓存击穿问题及解决思路

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

常见的解决方案有两种:

  • 互斥锁
  • 逻辑过期

逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大

解决方案一、使用锁来解决:

因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。

假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。

3-4-1

解决方案二、逻辑过期方案

方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。

我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。

这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。

3-4-2

进行对比

**互斥锁方案:**由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响

逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦

3-4-3 ​ 互斥方案的效率太低,我们采用逻辑维护的方案
queryWithLogicalExpire部分方法如下:
            if (StrUtil.isNotBlank(redisShopJSON)) {
                bean = JSONUtil.toBean(redisShopJSON, RedisData.class); // 涉及到泛型,所以需要指定类型,这里的工具类会自动识别
                if (LocalDateTime.now().isBefore(bean.getExpireTime())) {
                    return beanByRedisData(bean);
                }
            }

//LocalDateTime.now().isBefore(bean.getExpireTime()
// 这一行表示了我们如何是使用逻辑时间
saveBean2Redis.java      
				expireTime += RandomTime(timeUnit); // 随机时间,防止缓存击穿
        try {
            //0 获取key
            String key = keyPre + id.toString();

            // 1 查询数据库数据
            V bean = function.apply(id); // 查找数据库的内容

            // 1.1. 没查到,防止缓冲穿透,redis写入null数据
            if (null == bean) {
                // 防止缓存击穿, 使用RedisData封装数据
                RedisData<V> redisData = new RedisData<V>(LocalDateTime.now().plusSeconds(expireTime), null);
                // 在逻辑过期的基础上给key设置ttl,解决redis缓存无效内容堆积问题
                stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData), expireTime + RandomTime(timeUnit), timeUnit);
                return redisData;
            }

            //2 封装数据
            RedisData<V> redisData = new RedisData<V>(LocalDateTime.now().plusMinutes(expireTime), bean);

            //3 存入缓存
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData),expireTime + RandomTime(timeUnit), timeUnit);

            //4 返回结果
            return redisData;

3.6.缓存预热问题

​ 一般对于热点数据,都是需要人为的去导入。我们的工具类依旧可以做到自动导入热点数据,只需调用方法,会自动完成。

​ 原理很简单,采用异步mysql交互,主线程无需等待,直接返回旧数据,没有旧数据直接返回null,只是预热的第一次,可能前端不会报错,但是对应的数据不会显示。等待十几ms再次发请求,数据响应的话表示热点数据预热成功。

​ 所以我可以说,我这个工具类解决了手动操作redis实现缓存预热的问题。代码如下:

    // 异步操作数据库线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
queryWithLogicalExpire方法部分内容如下:
						// 0 确定key
            String key = keyPre + id;
            // 1 先查询缓存, 有则判断日期
            // 1.1 没有过期,直接返回
            String redisShopJSON = stringRedisTemplate.opsForValue().get(key);
            RedisData<T> bean = null;
            if (StrUtil.isNotBlank(redisShopJSON)) {
                bean = JSONUtil.toBean(redisShopJSON, RedisData.class); // 涉及到泛型,所以需要指定类型,这里的工具类会自动识别
                if (LocalDateTime.now().isBefore(bean.getExpireTime())) {
                    return beanByRedisData(bean);
                }
            }

            // 2. 过期/没有查到数据,获取锁
            if (!tryLock(keyPre, id, lockTime, lockUnit)) {
                // 没有获取到锁,直接返回脏数据
                return beanByRedisData(bean);
            }

            // 2.2 获取锁成功,调用数据库 -》缓存的封装解决方案的方法
            // 异步实现,采用线程池的方式
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                //重建缓存
                 if(null == this.saveBean2Redis(keyPre, id, time, unit, function)){
                     throw new RuntimeException("数据不存在");
                 }
            });

            //3 返回数据
            return beanByRedisData(bean);

3.7.redis-mysql双写一致性问题

​ 很简单,是不是应该保证mysql与redis的数据是尽可能一致的。所以怎么操作,如果前端发请求对mysql数据进行了修改,我们就把redis对应的数据删除。

​ 很好redis删除后,redis不就没有数据了吗?没错,就是要redis没数据,因为下一次查询的时候就会走queryWithLogicalExpire方法,而这个方法我们前面分析过,这个方法可以实现缓存的自动预热效果,自动帮我们把数据从mysql写入redis。

​ 代码如下:

    /**
     * 适用于条件删除
     * @param key
     * @param bean
     * @param function
     * @return
     * @param <T>
     */
    public <T> Integer deleteByBean(String key, T bean, Function <T, Integer> function) {
        // 1. 先删除数据库
        Integer apply = function.apply(bean);
        // 2. 再删除缓存
        stringRedisTemplate.delete(key);
        return apply;
    }
    
    /**
     * 根据id删除bean
     * @param key  redis对应的key
     * @param id  数据的主键
     * @param function 删除数据库的函数
     * @return DML 语句都会返回一个int
     * @param <T> 返回类型泛型
     */
    public <T> Integer deleteById(String key, T id, Function <T, Integer> function) {
        // 1. 先删除数据库
        Integer apply = function.apply(id);
        // 2. 再删除缓存
        stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
        return apply;
    }
    
    /**
     *
     * @param bean 传进的修改信息的bean
     * @param key   redis对应的key
     * @param function  consumer 修改数据的操作
     * @param <T> 泛型-数据修改的bean的类型
     */
    public <T> Integer updateBeanById(T bean, String key, Function<T, Integer> function) {
        /**
         * 达到双写一致性,应该先操作数据库,然后删除缓存
         */
        //1 更新数据-mysql
        Integer apply = function.apply(bean);
        //2 删除缓存-redis
        stringRedisTemplate.delete(key);
        return apply;
    }

4. 总结

​ 其实这个工具类有很多缺点,上面的分析我通过文字是很难表达的,如果可以通过视频讲述,效果会更好,这里学习一个思路就好了,我想表达的观点其实是其实缓存击穿,雪崩,穿透,预热,无效数据处理等等这些问题,其实并不矛盾,我们完全有能力再造一个框架/工具类,将这些问题全部一起解决且性能还很好。我觉的大家学东西的时候,需要动脑子,现在在框架流行的时代,虽然我们很少重复造轮子,但是也任然需要知道轮子应该怎么去造的。

​ 任何东西,如果不对底层有深刻的理解,就犹如空中楼阁,虚无缥缈。程序员其实也是手艺人,敲代码就是一门手艺,你练的怎么样呢?

;