玉树藏族自治州网站建设_网站建设公司_Redis_seo优化
2025/12/18 18:49:02 网站建设 项目流程

《Redis-day03-商户查询缓存》

0. 今日总结

  1. 了解了什么是缓存
  2. 完成了利用redis实现根据id查询商铺及商铺类型缓存
  3. 解决了缓存更新导致的数据不一致问题,解决方案如下:
    1. 读操作:缓存未命中则查询数据库,并将结果写入缓存,但是额外设置一个超时时间。
    2. 写操作:修改数据时,先修改数据库中的数据,再删除缓存。
  4. 通过缓存空对象解决了缓存穿透问题
  5. 分别实现了通过互斥锁和逻辑过期解决缓存击穿问题
  6. 通过泛型、函数式接口、方法引用将穿透、击穿等的解决方法抽象为了工具类

1. 什么是缓存

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

2. 添加Redis缓存

2.1 缓存模型

2.2 根据id查询商铺

  • controller

  • service

    1. 先创建key作为redis的key
    2. 操作String类型的redis,从redis中根据key查找是否存在对应的缓存
    3. 如果存在,则将缓存转化为shop类型的对象,然后返回给前端
    4. 如果不存在,则通过mybatis-plus从数据库中根据id查找对应的商铺
    5. 如果数据库中不存在,则返回店铺不存在
    6. 如果数据库中存在,则将数据写入redis中,注意,要将shop重新转化为Json类型才能写入
    7. 将数据库中查找到的shop对象返回给前端

2.3 店铺类型缓存

  • controller

  • service

    1. 从redis中查找是否存在缓存

    2. 如果存在,调用JSONUtil的toList方法将s转化为List集合并返回给前端

    3. 如果不存在,则查询数据库,用mybatis-plust的query方法及.list方法获得查询结果(this指代当前对象,继承自ServiceImpl,这里的泛型参数<ShopTypeMapper, ShopType>明确告知框架,该服务层操作的是ShopType实体,并使用ShopTypeMapper进行数据访问)

    4. 如果结果为空,说明列表不存在

    5. 如果不为空,将列表存入缓存,并将结果返回给前端

3. 缓存更新策略

3.1 实现一致性的三种方法

  • 如果不实现缓存和数据库的一致性可能会导致从缓存中查到的数据和数据库中实际存储的数据不一致的情况

3.2 主动更新策略

3.3 删除缓存和更新数据库的顺序

  • 先删除缓存,再操作数据库

    1. 情况1

    2. 情况2

  • 先操作数据库再删除缓存

    1. 情况1

    2. 情况2:发生可能性较低

3.4 缓存更新最佳方案

3.5 代码实现

  1. 超时时间设置

  2. 修改店铺时先修改数据库,再删除缓存

3.6 功能测试

用POSTMAN修改茶餐厅name为101茶餐厅

redis中数据被成功删除

数据库数据正常修改

刷新后用户界面正常显示101茶餐厅

redis中重新写入店铺缓存

4. 缓存穿透

4.1 概念和解决方案

  • 缓存穿透产生原因

    缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。如果有恶意攻击,发起大量的并发的空线程,疯狂查询不存在的缓存,可能会造成数据库崩溃

  • 缓存穿透的两种解决方案

    1. 缓存空对象

      不管查询结果存不存在,都将结果缓存到Redis,这样发起同样的请求查找redis时,就能获得一个结果,而不用去查询数据库

      1. **优点:**实现简单,维护方便
      2. 缺点
        1. 可能造成额外的内存消耗
        2. 可能造成短期的不一致(例如:用户请求了一个id,这个id是null,但是此时真的给该id添加了一条数据到数据库,那么就出现了数据库缓存不一致
  1. 布隆过滤

    1. **优点:**内存占用少,没有多余key
    2. 缺点:
      1. 实现复杂
      2. 存在误判可能

4.2 缓存空对象的实现

  1. 查询到结果时判断是否为空值

    StrUtil.isNotBlank只能判断是否存在实际值,如果shopJson是空值也会返回false

    因此这里要再判断一次:shopJson不为null,也就是确实查到了shopJson,值为shopJson = “”

  2. 数据库没查到时将空值写入缓存

4.3 功能测试

  • 第一次查询不存在的店铺,服务器会对数据库发起查询,查询结束后会将数据缓存到redis中

  • 第二次查询,不会再走数据库

5. 缓存雪崩

5.1 概念和解决方案

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

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

6. 缓存击穿

6.1 概念和解决方案

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

  • 解决方案

    1. 互斥锁

      思路:如果查找失败,去查找数据库前加一个锁,防止别的线程查找数据库

    2. 逻辑过期

      思路:提前缓存热点key,并且永远不清除,而是添加一个逻辑过期时间,因此如果查找失败,则说明确实不存在该数据,直接返回空即可。

      如果查找成功,都判断一下是否过期,没过期说明查找确实成功。过期了则尝试获取锁,去查找数据库并重置过期时间。如果获取锁失败,说明别的线程已经在这么做了,那就先不管,将旧的数据直接返回

    3. 两种方案对比

6.2 互斥锁的实现

概括:互斥锁,其实就相当于在缓存穿透的基础上,如果未命中缓存,即将要操作数据库前增加一个加锁过程,保证只要有线程发起了对数据库的请求,就保证在其操作数据库结束前,其他的线程在发送相同请求同样未命中缓存,即将操作数据库时拦截掉该数据库操作

6.2.1 代码实现

  • 按以下流程,配合redis的setnx key value(仅在key不存在时为key设置value)操作实现

  1. 获取锁和释放锁逻辑

    1. 利用redis String的setnx命令,如果设置成功,说明当前锁为空,返回ture,否则返回失败,说明当前key被上锁
    2. 删除对应key,这样再对该key进行setnx就能正常赋值,就能视为获取锁了
  2. 缓存击穿逻辑:基于缓存穿透

    /** * 缓存击穿+穿透 * * @param id * @return */publicShopqueryWithMutex(Longid){Stringkey=RedisConstants.CACHE_SHOP_KEY+id;//1.从redis里查询商铺缓存StringshopJson=stringRedisTemplate.opsForValue().get(key);//2.判断是否存在if(StrUtil.isNotBlank(shopJson)){//3.存在,直接返回returnJSONUtil.toBean(shopJson,Shop.class);}//判断命中的是否是空值//StrUtil.isNotBlank只能判断是否存在实际值,如果shopJson是空值也会返回false,因此这里要再判断一次if(shopJson!=null){// 返回错误信息returnnull;}//4.实现缓存重建//4.1 获取互斥锁StringlockKey=RedisConstants.LOCK_SHOP_KEY+id;Shopshop=null;try{//调用tryLock方法,如果设置成功,说明获取锁成功,否则说明锁被占用尚未释放booleanisLock=tryLock(lockKey);//4.2 判断是否获取成功if(!isLock){//4.3 获取锁失败,休眠并重试Thread.sleep(50);//递归调用returnqueryWithMutex(id);}//4.4 获取锁成功//再次判断redis缓存是否存在(因为有可能在等待获取锁的过程中别人已经将缓存写入了)StringshopJson2=stringRedisTemplate.opsForValue().get(key);if(StrUtil.isNotBlank(shopJson2)){//4.4.1 存在,直接返回returnJSONUtil.toBean(shopJson2,Shop.class);}//4.4.2 不存在,查询数据库shop=getById(id);//5.数据库不存在,返回错误if(shop==null){//将空值写入redisstringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);returnnull;}//6.数据库存在,写入redisstringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL,TimeUnit.MINUTES);}catch(InterruptedExceptione){thrownewRuntimeException(e);}finally{//7.释放互斥锁unlock(lockKey);}//8.返回returnshop;}
    1. 从redis查数据
      1. 如果查询成功,返回数据
      2. 如果查询失败,判断是否为空值,如果为空值,返回错误信息(缓存穿透解决方案),如果不为空值,而是确实没查到,进入下一步
    2. 获取锁,占用资源对数据库进行查询,并将结果写入缓存
      1. 获取互斥锁(用setnx写入key=lockKey的数据)
        1. 如果写入失败,说明当前key已经被占用且尚未删除,说明其他线程占用了锁,此时等待50毫秒,然后递归调用该方法,再次查询
        2. 如果写入成功,说明获取锁成功,再次判断redis缓存是否存在(因为有可能在等待获取锁的过程中别人已经将缓存写入了)
          1. 如果存在,直接返回
          2. 如果不存在,查询数据库
    3. 查询数据库
      1. 数据库不存在,将空值写入redis,防止缓存穿透
      2. 数据库存在,将值写入redis
    4. 释放互斥锁

6.2.2 功能测试

高并发场景下,数据库制备查找了一次

6.3 逻辑过期的实现

6.3.1 代码实现

  • 问题:原本的shop对象中没有维护过期时间字段作为逻辑过期时间

  • 解决方案

    1. 在Shop中增加一个字段(要修改Shop,不方便)

    2. 创建一个RedisData类,维护过期时间字段,再让Shop继承RedisData这个类(依旧要修改Shop类,不方便)

    3. 创建一个RedisData类,维护过期时间字段和Object data字段,data字段专门存储数据,可以是shop对象也可以是其他对象

  • 基于逻辑过期查找的实现

/** * 基于逻辑过期查找 * * @param id * @return */publicShopqueryWithLogicalExpire(Longid){Stringkey=RedisConstants.CACHE_SHOP_KEY+id;//1.从redis里查询商铺缓存StringshopJson=stringRedisTemplate.opsForValue().get(key);//2.判断是否命中if(StrUtil.isBlank(shopJson)){//3.未命中,返回空returnnull;}//4.命中,把json反序列化为对象RedisDataredisData=JSONUtil.toBean(shopJson,RedisData.class);JSONObjectdata=(JSONObject)redisData.getData();Shopshop=JSONUtil.toBean(data,Shop.class);LocalDateTimeexpireTime=redisData.getExpireTime();//简单写法:Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);//5.判断是否过期if(expireTime.isAfter(LocalDateTime.now())){//5.1未过期,直接返回returnshop;}//5.2已过期,需要缓存重建//6.缓存重建//6.1获取互斥锁StringlockKey=RedisConstants.LOCK_SHOP_KEY+id;booleanisLock=tryLock(lockKey);//6.2判断是否成功if(isLock){//6.2.1成功,再次检测redis缓存是否过期StringshopJson2=stringRedisTemplate.opsForValue().get(key);if(StrUtil.isNotBlank(shopJson2)){// 如果未过期,直接返回JSONUtil.toBean(shopJson2,Shop.class);}// 如果过期则开启独立线程,实现缓存重建CACHE_REBUILD_EXECUTOR.submit(()->{try{//重建this.saveShop2Redis(id,20L);}catch(Exceptione){thrownewRuntimeException(e);}finally{//释放锁unlock(lockKey);}});}//6.2.2失败,返回过期的商铺信息//7.返回returnshop;}
  1. 首先从redis里查询店铺缓存
    1. 如果未命中,直接返回空
    2. 如果命中,则反序列化获取Json中的RedisData对象
  2. 反序列化,获取RedisData对象(维护了过期时间,和Shop数据)
    1. 通过redisData获取维护的ExpireTime过期时间
    2. 通过redisData获取维护的data店铺数据,但是这个店铺数据由于在写入时被转化为了JSON对象,而其本身时Object对象,因此要将redisData.getData()转化为JSONObject才能正确的取出其中的JSON对象,然后再调用JSONUtil.toBean,将JSON对象转化为Shop对象
  3. 判断是否逻辑过期
    1. 未过期,直接返回shop
    2. 过期,则需要重建缓存
  4. 重建缓存
    1. 获取互斥锁
      1. 获取成功,为了避免在获取锁的过程中,别的线程已经将redis成功重建,获取锁成功后要重新查询一次redis,并判断是否过期
        1. 如果此时变成未过期了,说明别的线程已经重建缓存,则直接返回
        2. 如果已过期,说明别的线程未重建缓存,当前线程为第一个获取锁成功的线程,开始重建缓存
      2. 获取失败,线程池会安排一个空闲的线程来执行它,而调用submit的主线程(处理用户请求的线程)不会等待这个任务完成,会立即继续向下执行。
  • 将热点数据写入redis并设置逻辑过期时间
//数据预热,将热点数据写入redis并设置逻辑过期时间publicvoidsaveShop2Redis(Longid,LongexpireSeconds)throwsInterruptedException{//1.查询店铺数据Shopshop=getById(id);//2.封装逻辑过期时间RedisDataredisData=newRedisData();redisData.setData(shop);redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));//3.写入Redis,没有添加实际过期时间stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));}
  1. 根据id查询店铺数据
  2. 封装逻辑过期时间和店铺数据
  3. 将封装后的RedisData对象写入redis

7. 缓存工具封装

7.1 将java对象保存到redis并设置过期时间

由于是工具类,因此不知道要封装的对象是什么类,因此封装的对象是Object类,保存到redis时用JSONUtil的toJsonStr方法直接将对象转换即可。过期时间和时间单位也由调用者传入

7.2 将java对象保存到redis并设置逻辑过期时间

由于是工具类,因此不知道要封装的对象是什么类,又由于是逻辑过期,因此创建RedisData对象,并为对象设置data和expireTime两个字段的值

7.3 用缓存空值方式解决缓存穿透问题(重要)

  1. 函数头

    1. <R,ID>表示函数中出现的R和ID为泛型

    2. String keyPrefix表示函数的第一个参数为redis中key的前缀

    3. R表示返回值因为返回值类型不确定,因此用泛型指代,ID表示id,id类型也不确定,也用泛型指代

    4. Class<R>,函数第三个参数type为调用者传入的类型,该类型应该和返回值保持一致

    5. Function<ID, R> dbFallbcak,表示第四个参数为一个函数式接口,该函数式接口调用apply方法时参数为ID返回值为R

    6. 剩下两个参数表示过期时间和时间单位

  2. 函数体

    1. 设置key = keyPrefix + id,key为要查询的数据在redis中的key
    2. 调用stringRedisTemplate.opsForValue().get(key),如果存在(命中且不为空),则直接返回,返回类型为type,也就是R类的Class
    3. 如果命中且为空,则返回null表示查找失败
    4. 如果未命中,则根据id查询数据库,调用dbFallback的apply方法,将ID类型的对象id作为参数,并返回R类型的对象,用r接收
    5. 如果数据库不存在,也就是r为空,则将空值写入redis,并返回null表示查找失败
    6. 如果数据库存在,则调用set方法将数据库写入redis,并将数据返回

  1. 调用工具类的queryWithPassThrough方法,并传入以下参数queryWithPassThrough(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES)

    1. RedisConstants.CACHE_SHOP_KEY表示传入的redis中的key的前缀

    2. id表示传入的id

    3. Shop.class表示返回的数据类型时Shop类型,对应到工具类中表示type为Shop.Class

    4. this::getById,这是方法引用,相当于(Long)->(Shop)表示当前对象调用getById方法,返回值为getById的返回值类型Shop,参数为getById的参数类型Long。对应到工具类中表示函数式接口dbFallback被绑定为Shop getById(Long),因此在工具类中ID为getById的参数类型Long,R为getById的返回值类型Shop,并且在工具类中调用该回调方法R r = dbFallback.apply(id);时,id就是Long类型,返回值就是Shop类型

7.4 用逻辑过期问题方式解决缓存击穿问题

思路和7.3完全类似

public<R,ID>RqueryWithLogicalExpire(StringkeyPrefix,StringlockKeyPrefix,IDid,Class<R>type,Function<ID,R>dbFallback,Longtime){Stringkey=keyPrefix+id;//1.从redis里查询商铺缓存Stringjson=stringRedisTemplate.opsForValue().get(key);//2.判断是否命中if(StrUtil.isBlank(json)){//3.未命中,返回空returnnull;}//4.命中,把json反序列化为对象RedisDataredisData=JSONUtil.toBean(json,RedisData.class);JSONObjectdata=(JSONObject)redisData.getData();Rr=JSONUtil.toBean(data,type);LocalDateTimeexpireTime=redisData.getExpireTime();//简单写法:Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);//5.判断是否过期if(expireTime.isAfter(LocalDateTime.now())){//5.1未过期,直接返回returnr;}//5.2已过期,需要缓存重建//6.缓存重建//6.1获取互斥锁StringlockKey=lockKeyPrefix+id;booleanisLock=tryLock(lockKey);//6.2判断是否成功if(isLock){//6.2.1成功,再次检测redis缓存是否过期Stringjson2=stringRedisTemplate.opsForValue().get(key);RedisDataredisData2=JSONUtil.toBean(json2,RedisData.class);LocalDateTimeexpireTime2=redisData2.getExpireTime();JSONObjectdata2=(JSONObject)redisData2.getData();if(expireTime2.isAfter(LocalDateTime.now())){// 如果未过期,直接返回returnJSONUtil.toBean(data2,type);}// 如果过期则开启独立线程,实现缓存重建CACHE_REBUILD_EXECUTOR.submit(()->{try{//重建this.save2Redis(keyPrefix,id,time,dbFallback);}catch(Exceptione){thrownewRuntimeException(e);}finally{//释放锁unlock(lockKey);}});}//6.2.2失败,返回过期的商铺信息//7.返回returnr;}
/** * 尝试获得锁 * * @param key * @return */privatebooleantryLock(Stringkey){//利用redis String的setnx命令,如果设置成功,说明当前锁为空,返回ture,否则返回失败,说明当前key被上锁Booleanflag=stringRedisTemplate.opsForValue().setIfAbsent(key,"1",10,TimeUnit.SECONDS);returnBooleanUtil.isTrue(flag);}/** * 尝试释放锁 * * @param key */privatevoidunlock(Stringkey){//删除对应key,这样再对该key进行setnx就能正常赋值,就能视为获取锁了stringRedisTemplate.delete(key);}
//数据预热,将热点数据写入redis并设置逻辑过期时间public<R,ID>voidsave2Redis(StringkeyPrefix,IDid,LongexpireSeconds,Function<ID,R>dbFallback)throwsInterruptedException{//1.查询店铺数据Rr=dbFallback.apply(id);//2.封装逻辑过期时间RedisDataredisData=newRedisData();redisData.setData(r);redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));//3.写入Redis,没有添加实际过期时间stringRedisTemplate.opsForValue().set(keyPrefix+id,JSONUtil.toJsonStr(redisData));}

7.5 泛型、函数式接口、方法引用复习

7.5.1 泛型

7.5.2 函数式接口

核心概念:只有一个抽象方法的接口,因为只有一个抽象方法,如果将函数式接口作为方法参数,则该方法的调用者可以通过方法引用来简化Lambda表达式来对唯一的抽象方法进行重写

函数式接口的真正威力在于它允许我们将行为(而不仅仅是数据)作为参数传递。

7.5.3 方法引用

方法引用:把已经有的方法拿过来用,当作函数式接口中抽象方法的方法体。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询