《商户查询缓存案例》使用案例学习Redis的缓存使用;缓存击穿、穿透、雪崩的原理的解决方式

一、什么是缓存

(1)介绍

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

  • 具体解释:在计算机领域和软件开发中,缓存(Cache)是一种用于临时存储高频访问数据的技术,核心目的是减少对 "低速数据源" 的直接访问,从而提升系统响应速度、降低底层资源压力

简单理解:缓存就像你书桌抽屉里的常用物品(如笔、笔记本)------ 你不需要每次用都去书房的柜子里找(对应 "低速数据源",如数据库、远程服务器),而是提前放在随手可及的地方(对应 "缓存介质",如内存),用的时候直接拿,效率大幅提升。

缓存场景十分丰富:

(2)作用

  • 缓存的作用:
  1. 降低后端负载

  2. 提高读写效率,降低响应时间

  • 缓存的成本:
  1. 数据一致性成本
  2. 代码维护成本
  3. 运维成本

二、添加Redis缓存

​ 接下来我们添加一个应用层缓存,利用Redis的高吞吐性,我们可以将Redis作为一个缓存区,客户端先请求Redis中的数据,只有当Redis中不存在该数据的时候才会到数据库中查找,并且数据库查到了数据后还需要将该数据写入缓存:

缓存作用的模型图:

(1)案例

改造:查询商铺

我们现在需要给ShopController的根据id查商铺,即给queryShopById()接口添加Redis缓存。本篇中的其他案例都将以改造该接口为主。

前端调用该接口的【步骤1】:

【步骤2】:

添加缓存之后 根据id查询商铺的流程图

关键点就是先从redis中查,未命中则在数据库查并添加到redis中

java 复制代码
@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public Result queryShopById(Long id) {
    String key = CACHE_SHOP_KEY + id;
    //1.从缓存中取数据
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)){
        //命中则返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    //3.未命中,从数据库中查
    Shop shop = getById(id);
    //4.数据库中也没有
    if (shop == null){
        return Result.fail("店铺不存在");
    }
    //5.数据库中查到,将数据存入缓存
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
    //6.返回数据
    return Result.ok(shop);
}

代码解读:
1.Redis中数据如何存放?

Redis中键使用的是"cache:shop:"+id的组合,然后为了不让业务里面出现常量字符串,我们使用一个自定义常量CACHE_SHOP_KEY来替代它。

2.如何从数据库中查的数据?

这里使用的是MyBatisPlus的getById(id)方法。

(2)【练习】给店铺类型添加缓存

修改ShopTypeControoller中的queryTypeList方法,添加查询缓存

该接口在首页,和其它多个页面都会用到,如图:

首先我们要知道redis存储数据是按照key-value存储,所以存储一个对象到redis的时候我们是需要考虑key如何选择,value如何选择?

现在,我们需要存储一个List<ShopType>类型的数据,这个数据是一个ShopType对象的数组

key和value的选择 :我们需要思考,一个key是否可以标识整个ShopType对象的数组,是可以的,我们可以直接将该对象数组转成一串JSON格式的字符串存储,这样一个key即可标识。

同样,我们如果需要修改数组中一个对象中的指定属性,那么一串JSON格式的字符串显然是不方便修改的,那么我们可以选用Hash结构标识一个ShopType对象,再用多个key标识该数组中的多个对象。

Hash结构为什么能够标识一个ShopType对象?

redis数据存储格式为key-value,hash结构的value又分为field-value,这样也就对应了一个对象的属性名-属性值

先将该接口的controller层修改成:

java 复制代码
@Resource
private IShopTypeService typeService;

@GetMapping("list")
public Result queryTypeList() {

      return typeService.getTypeList();
}

1.String类型实现:

​ 但是缓存中的数据是无需修改的,我们可以直接将List<ShopType>对象数组一次性全部转成一个json格式的字符串,全部存入到redis中,一个key即可标识该对象数组。

代码为:

java 复制代码
1.String类型的缓存存入Redis
@Override
public Result getTypeList() {
    //1.Redis中查询商铺类型
    String shopTypeListString =  stringRedisTemplate.opsForValue().get("cache:shop:type");

    //2.判断是否存在
    if (shopTypeListString != null){
        //3.命中,返回结果
        List<ShopType> shopTypeList = JSONUtil.toList(shopTypeListString, ShopType.class);
        log.debug("从Redis中查询商铺类型成功");
        return Result.ok(shopTypeList);
    }
    //4.未命中,查询数据库
    List<ShopType> shopTypeList = query().orderByAsc("sort").list();

    //5.数据库也不存在,返回错误
    if (shopTypeList == null){
        return Result.fail("未查询到商铺类型");
    }

    //6.存在,将命中数据存储到Redis中
    shopTypeListString = JSONUtil.toJsonStr(shopTypeList);
    stringRedisTemplate.opsForValue().set("cache:shop:type",shopTypeListString);

    log.debug("从数据库中查询商铺类型成功");
    return Result.ok(shopTypeList);
}

JSONUtil是hutool中的一个工具,用来Json格式的转换

2.Hash类型实现:

Hash类型也有多种选择:

①将单个ShopType对象转成JSON格式字符串作为value,然后各个对象的id作为field,最后一个key标识这个对象数组即可。

②将单个ShopType对象的属性名作为field,属性值作为value,然后一个key1标识对象1,key2标识对象2,最后这一群key组成一个对象数组。我们可以在这些key命名前缀用shop:type:key1这样就能表示出这个对象数组了。

①代码实现:

java 复制代码
//Hash类型存入,对象JSON字符串
@Override
public Result getTypeList() {
    //1.Redis中查询商铺类型
    Map<Object, Object> shopTypeListMap = stringRedisTemplate.opsForHash().entries("cache:shop:type");

    //2.判断是否存在
    if (!shopTypeListMap.isEmpty()) {
        //3.命中,返回结果
        List<ShopType> shopTypeList = new ArrayList<>();
        for (Object shopTypeString : shopTypeListMap.values()) {
            ShopType shopType = JSONUtil.toBean((String) shopTypeString, ShopType.class);
            shopTypeList.add(shopType);
        }
        //排序,因为redis中存储是乱序的
        shopTypeList.sort(Comparator.comparingInt(ShopType::getSort));

        log.debug("从Redis中查询商铺类型成功");
        return Result.ok(shopTypeList);
    }
    //4.未命中,查询数据库
    List<ShopType> shopTypeList = query().orderByAsc("sort").list();

    //5.数据库也不存在,返回错误
    if (shopTypeList == null) {
        return Result.fail("未查询到商铺类型");
    }

    //6.存在,将命中数据存储到Redis中
    Map<String, Object> valuesMap = new HashMap<>();
    for (ShopType shopType : shopTypeList) {
        String shopTypeString = JSONUtil.toJsonStr(shopType);
        valuesMap.put(shopType.getId().toString(), shopTypeString);
    }

    stringRedisTemplate.opsForHash().putAll("cache:shop:type", valuesMap);

    log.debug("从数据库中查询商铺类型成功");
    return Result.ok(shopTypeList);
}

代码解读:

1.java对象是如何存入redis的?

这里选择将单个的ShopType利用JSONUtil转成JSON格式作为value,然后取每一个ShopType的id作为field,最后键key就命名为"cache:shop:type",使用.opsForHash().putAll一次性存入多个map对象就完成了值的存入。

2.为什么从redis中取出数据到ShopTypeList之后还要排序?

因为redis的Hasp结构存储是无序的,无论存入前是有序还是乱序。

②代码实现

java 复制代码
//Hash类型存入,对象Hash
@Override
public Result getTypeList() {

    //1.Redis中查询商铺类型
    Set<String> shopTypeListKeys = stringRedisTemplate.keys("cache:shop:type:*");
    //2.判断是否存在
    if (!shopTypeListKeys.isEmpty()) {
        //3.命中,返回结果
        List<ShopType> shopTypeList = new ArrayList<>();
        for (int i = 0; i < shopTypeListKeys.size(); i++) {
            Map<Object, Object> shopTypeMap = stringRedisTemplate.opsForHash().entries("cache:shop:type:" + (i + 1));
            ShopType shopType = BeanUtil.fillBeanWithMap(shopTypeMap, new ShopType(), false);
            shopTypeList.add((ShopType) shopType);
        }
        log.debug("从Redis中查询商铺类型成功");
        return Result.ok(shopTypeList);
    }
    //4.未命中,查询数据库
    List<ShopType> shopTypeList = query().orderByAsc("sort").list();

    //5.数据库也不存在,返回错误
    if (shopTypeList == null) {
        return Result.fail("未查询到商铺类型");
    }

    //6.存在,将命中数据存储到Redis中
    Map<String, Object> valuesMap = new HashMap<>();
    for (ShopType shopType : shopTypeList) {
        //将单个对象转为map
        Map<String, Object> shopTypeMap = BeanUtil.beanToMap(shopType, new HashMap<>(),
                CopyOptions.create()
                        .setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())
        );
        //存入单个对象
        stringRedisTemplate.opsForHash().putAll("cache:shop:type:" + shopType.getId(), shopTypeMap);
    }

    log.debug("从数据库中查询商铺类型成功");
    return Result.ok(shopTypeList);
}

代码解读

1.java对象是如何存入redis的?

这里我将单个的ShopType利用BeanUtil工具转成Map<String, Object>类型作为field-value,这样就保证了属性名和属性值的一一对应关系,最后通过字符串拼接各个对象的id 构建成唯一标识的key,"cache:shop:type"+shopType.getid(),但是考虑到真正用来排序的时sort。

2.为什么取redis数据时使用 for (int i = 0; i < shopTypeListKeys.size(); i++)

​ 这里不直接for (String shopTypeListKey : shopTypeListKeys)的原因是,数据存入到redis中就是无序的 ,但是我们可以根据存入的命名"cache:shop:type"+shopType.getid()这个id来获取到一个有序的ShopType集合,具体做法为取出的时候使用.opsForHash().entries("cache:shop:type:" + (i + 1))

3.SortedSet类型实现:

Redis 的 SortedSet 是一个可排序的 set 集合,是key-(value-score)的结构,可以基于 score 属性对元素排序。

​ 如果我们需要利用这个score属性排序的话,那么我们就必须使得每一个ShopType对象都放在同一个set集合中,一个key即可,value选用将ShopType进行JSON格式后的字符串,score选用ShopType对象中的sort属性即可。

代码如下:

java 复制代码
//SortedSet类型缓存到redis
@Override
public Result getTypeList() {

    //1.Redis中查询商铺类型
    Set<String> shopTypeSet = stringRedisTemplate.opsForZSet().range("cache:shop:type", 0, -1);
    //2.判断是否存在
    if (!shopTypeSet.isEmpty()) {
        //3.命中,返回结果
        List<ShopType> shopTypeList = new ArrayList<>();
        for (String shopTypeString : shopTypeSet) {
            ShopType shopType = JSONUtil.toBean(shopTypeString, ShopType.class);
            shopTypeList.add(shopType);
        }
        log.debug("从Redis中查询商铺类型成功");
        return Result.ok(shopTypeList);
    }
    //4.未命中,查询数据库
    List<ShopType> shopTypeList = query().orderByAsc("sort").list();

    //5.数据库也不存在,返回错误
    if (shopTypeList == null) {
        return Result.fail("未查询到商铺类型");
    }

    //6.存在,将命中数据存储到Redis中
    for (ShopType shopType : shopTypeList) {
        //将单个对象转为JSON字符串
        String shopTypeJSONString = JSONUtil.toJsonStr(shopType);
        //将单个对象存入,且携带对应的score值
        stringRedisTemplate.opsForZSet().add("cache:shop:type", shopTypeJSONString, shopType.getSort());
    }

    log.debug("从数据库中查询商铺类型成功");
    return Result.ok(shopTypeList);
}

代码解读:

**1.如何获取SortedSet所有元素?range("cache:shop:type", 0, -1)**的意义:

  • 第一个参数 "cache:shop:type" 是有序集合的 key,表示要操作的集合名称。
  • 第二个参数 0 是起始索引(从 0 开始)。
  • 第三个参数 -1 是结束索引(-1 表示最后一个元素)。

三、缓存更新策略

​ 当数据库中数据更新时,缓存中的数据也需要更新,不然数据会不一致,这时候就有几种缓存更新的策略:

  • 缓存中的数据更新,只需要删除缓存即可。当缓存删除时,数据查询会发生未命中情况,此时会重新从数据库中查询,并载入到缓存中

(1)更新策略

我们会选择主动更新作为最佳的缓存更新策略

内存淘汰 超时剔除 主动更新
说明 不用自己维护,利用 Redis 的内存淘汰机制,当内存不足时自动淘汰部分数据。下次查询时更新缓存。 给缓存数据添加 TTL 时间,到期后自动删除缓存。下次查询时更新缓存。 编写业务逻辑,在修改数据库的同时,更新缓存。
一致性 一般
维护成本

这里的超时剔除虽然一致性一般,但是是最稳定的,所以编写主动更新时,我们会加上超时剔除作为保底策略。

(2)主动更新最佳实现

主动更新有以下三种实现:

1.Cache Aside Pattern(旁路缓存模式)

  • 核心逻辑:由缓存的调用者(即业务代码)负责维护缓存和数据库的一致性。当需要更新数据时,调用者要同时更新数据库和缓存;
  • 优势:实现简单直接,是应用最广泛的缓存更新策略之一,能让业务方灵活控制缓存和数据库的交互逻辑。
  • 适用场景:在很多场景下,这种模式因为其灵活性和易实现性,成为优先选择的缓存更新策略。

2.Read/Write Through Pattern(读写穿透模式)

  • 核心逻辑:把缓存和数据库整合为一个服务,调用者只与这个整合后的服务交互,无需关心缓存一致性问题。当调用者读取数据时,服务内部先查缓存,缓存未命中再查数据库并加载到缓存;当调用者写入数据时,服务内部先更新缓存,再由服务自己去更新数据库。
  • 优势:对调用者透明,调用者不用关注缓存和数据库的底层交互,降低了业务代码的复杂度。
  • 适用场景:适合希望将缓存和数据库的一致性管理封装起来,让业务逻辑更简洁的场景。

3.Write Behind Caching Pattern(写回缓存模式)

  • 核心逻辑:调用者只操作缓存,当有数据写入时,先写入缓存,然后由其他线程异步地一次将缓存中的全部数据持久化到数据库中。这样可以保证缓存和数据库最终达到一致,但在短时间内可能存在缓存与数据库数据不一致的情况。
  • 优势:写入操作的性能很高,因为不需要同步等待数据库的写入完成,适合对写入性能要求高,且能接受短期数据不一致的场景,比如日志系统、某些高并发的写操作场景。

其中最佳的实现模式就是:Cache Aside Pattern(旁路缓存模式),我们将以该模式为例。

(3)操作缓存和数据库时的三个问题

1.删除缓存还是更新缓存?

  • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
  • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存【更优解】

2.如何保证缓存与数据库的操作的同时成功或失败?

  • 单体系统,将缓存与数据库操作放在一个事务【本项目属于单体系统】
  • 分布式系统,利用 TCC 等分布式事务方案

3.先操作缓存还是先操作数据库?

  • 先删除缓存,再操作数据库
  • 先操作数据库,再删除缓存【更优解】

先操作数据库,再删除缓存是更优解的具体原因如下:

这两种实现方式:①先删除缓存,再操作数据库、②先操作数据库,再删除缓存。

两者都有出现缓存和数据库数据不一致的问题,我们需要考虑那种方案出现错误的可能性更低。

案例:假设我们需要将一个数据从10更新到20。r表示Redis中数据即缓存中数据,v表示数据库中的值。

流程如下:

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

如果线程1需要执行更新数据库操作,它会先删除缓存,但是删除完缓存之后,刚好线程2需要查询缓存,此时会出现缓存未命中的情况,线程2从数据库查到数据10,然后将10写入缓存,最后着线程1更新数据库到20;此时数据库为20,缓存为10。

查到的数据是更新前的数据,且缓存和数据库数据不一致

缓存的操作会比数据库操作要快不少,线程1需要进行数据库的更新操作,而线程2做的是数据库的查询操作,查询又比更新快,所以线程2的写入缓存操作在线程1更新数据库前发生的可能性是很大的。

也就是说这个错误的出现频率会比较高

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

​ 如果刚好缓存失效,线程1需要执行更新数据库操作,线程1会先进行查询缓存未命中而查询数据库的操作,此时线程1查询到10,刚好此时线程2执行更新数据库的操作,将数据库的数据更新为20,刚好删除缓存后,线程1又将原来的10写入到了缓存中;此时数据库为20,缓存为10。

缓存操作比数据库操作快,线程1大概率会在线程2执行更新操作时,将10写入到缓存中,而线程2执行完更新操作后又会将缓存删除,此时就不会发生不一致的情况。

也就是说这个错误的出现频率比较高

综上,在执行主动更新的时候,加上超时剔除会更加安全。

(2)案例

改造:更新商铺信息

给更新商铺信息的缓存添加主动更新 的策略,其余使用到缓存的功能将设置缓存超时时间

修改 ShopController 中的业务逻辑,满足下面的需求:

① 根据 id 查询 店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间

② 根据 id 修改店铺时,在一个事务中,先修改数据库,再删除缓存

①中设置超时时间实现超时剔除

②中在服务层中:【1.更新数据库】、【2.删除缓存】,实现主动更新

①设置超时时间

可以将超时时间这个常量,用一个自定义常量替代

就是如蓝框所示,只需要修改:存入信息是,将缓存增加一个超时时间即可。

代码:

java 复制代码
//5.数据库中查到,将数据存入缓存
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
  • CACHE_SHOP_TTL是在RedisConstants工具类中定义的一个常量,值为30L

②修改更新商铺代码:

Controller层:

java 复制代码
/**
* 更新商铺信息
* @param shop 商铺数据
* @return 无
*/
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
      // 写入数据库
      return shopService.update(shop);
}

接着在业务层实现这个update()方法即可

Service层:

更新的业务逻辑就两步:1.更新数据库,2.删除缓存。

java 复制代码
@Override
@Transactional
public Result update(Shop shop) {
      Long id = shop.getId();
      if (id == null){
            return Result.fail("店铺id不能为空");
      }
      //1.更新数据库
      updateById(shop);
      //2.删除缓存
      stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
      return Result.ok();
}
  • 如何控制更新数据库和删除缓存操作在一个事务中?

    使用Spring框架中的@Transactional 注解,它可以简化事务控制代码,通过注解方式替代传统的编程式事务管理(如手动编写 try-catch 并调用 commit()/rollback())。

    1. 适用范围
      • 只能用于 public 方法(非 public 方法注解可能不生效,因 Spring AOP 代理机制限制)。
      • 通常标注在 Service 层,避免在 Controller 或 Repository 层使用。
    2. 异常回滚规则
      • 默认只对 未检查异常(Unchecked Exception) 回滚(如 RuntimeException 及其子类)。
      • 已检查异常(Checked Exception) 不回滚(如 IOExceptionSQLException),需通过 rollbackFor 手动指定。

测试使用,在PostMan或者ApiFox中测试即可:

发送put请求到:localhost:8081/shop

携带以下数据:

JSON 复制代码
{
  "area": "大关",
  "openHours": "10:00-22:00",
  "sold": 4215,
  "address": "金华路锦昌文华苑29号",
  "comments": 3035,
  "avgPrice": 80,
  "score": 37,
  "name": "101茶餐厅",
  "typeId": 1,
  "id": 1
}

这里将name字段更新成 "101茶餐厅",如果调用更新接口,查看Redis中该条数据被删除,说明成功了。

四、问题1:缓存穿透

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

用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,会给数据库带来巨大压力

原理图:

(1)解决方案

有两种基本的解决方案:①缓存空对象、②布隆过滤

①缓存空对象

核心是将 "空结果" 这一结果也作为合法值存入缓存,其本质是通过缓存 "空结果" 避免对数据库等底层存储的重复无效查询,从而提升系统性能、降低后端服务压力

  • 优点:实现简单,维护方便
  • 缺点:
    • 额外的内存消耗
    • 可能造成短期的不一致

原理图:

②布隆过滤

缓存和客户端之间增加一层中间层:布隆过滤器(Bloom Filter),这是一种空间效率极高的概率型数据结构,用于快速判断一个元素是否 "可能存在" 于集合中。

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

原理图:

除去常见的两种方案,还有其余的方案:

  • 缓存 null 值
  • 布隆过滤
  • 增强 id 的复杂度,避免被猜测 id 规律
  • 做好数据的基础格式校验
  • 加强用户权限校验
  • 做好热点参数的限流

(2)案例:缓存空值

改造:查询商铺

缓存空值就是新增了:①将原本不存的商铺用空数据存储到Redis中,②并且从Redis取出数据时需要判断是否为空数据

缓存空值流程图:

代码:

java 复制代码
public Result queryShopById(Long id) {
      String key = CACHE_SHOP_KEY + id;
      //1.从缓存中取数据
      String shopJson = stringRedisTemplate.opsForValue().get(key);
      //2.判断是否存在该数据(该数据必须不为空,不为空数据)
      if (StrUtil.isNotBlank(shopJson)){
            //存在,返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
      }
      //判断命中是否为空数据
      if (shopJson != null){
            return Result.fail("店铺不存在");
      }
      //3.不存在,从数据库中查
      Shop shop = getById(id);
      //4.数据库中也没有,将该值以空数据存入Redis
      if (shop == null){
            //空数据写入redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return Result.fail("店铺不存在");
      }
      //5.数据库中查到,将数据存入缓存
      stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
      //6.返回数据
      return Result.ok(shop);
}

代码解读

1.第一次判断是否从Redis中取出数据使用的是isNotBlank()方法的目的是什么?

isNotBlank方法是Hutool工具包下StrUtil的一个方法,用来判断字符串是否是有数据的,如果该字符串为①NULL、②空数据、③\t\n等换行符,那么就会返回false。

底层是会遍历该字符串,只有当有值时才返回true。

2.后续再进行一次shopJson != null判断的目的是什么?

​ 由于我们将不存在的数据会缓存在Redis当中,也就是说,该值能被取出来,而且不是NULL,因为我们存入的是""空数据,那么先用isNotBlank筛选出有数据的,再用!= null筛选出空,那么剩下的就是""空数据了。

​ NULL是代表还要去数据库查的,所以必须先将""空数据拦截下来,完成拦截

3.第4步中的写入空数据的CACHE_NULL_TTL是什么意思?

在判断完数据库也不存在该数据后,就可以将该数据以空数据的形式写入Redis完成缓存空值,CACHE_NULL_TTL是在RedisConstans中定义的一个常量,值为2L

(3)封装:缓存空值

为了业务层面更好的理解,我们可以将缓存空值逻辑的代码,封装成一个方法,命名为queryWithPassThrough,表示解决缓存穿透的查询。

java 复制代码
private Shop queryWithPassThrough(Long id) {
      String key = CACHE_SHOP_KEY + id;
      //1.从缓存中取数据
      String shopJson = stringRedisTemplate.opsForValue().get(key);
      //2.判断是否存在该数据(该数据必须不为空,不为空数据)
      if (StrUtil.isNotBlank(shopJson)){
            //存在,返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
      }
      //判断命中是否是空数据
      if (shopJson != null){
            return null;
      }
      //3.不存在,从数据库中查
      Shop shop = getById(id);
      //4.数据库中也没有,将该值以空数据存入Redis
      if (shop == null){
            //空数据写入redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
      }
      //5.数据库中查到,将数据存入缓存
      stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
      //6.返回数据
      return shop;
}

这样查询业务逻辑就变成了:

java 复制代码
public Result queryShopById(Long id) {
      Shop shop = queryWithPassThrough(id);

      if (shop == null){
            return Result.fail("店铺不存在");
      }

      return Result.ok(shop);
}

好理解的多,并且缓存穿透是最基本的问题,只要使用到缓存,都会取解决缓存穿透的问题,我们之后的代码,也都可以在缓存穿透的基础上进行迭代和解决更多的缓存问题。

五、问题2:缓存雪崩

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

解决方案:

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

六、问题3:缓存击穿

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

(1)解决方案

常见的决绝方案有两种:①互斥锁、②逻辑过期

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

逻辑图:

①互斥锁:

②逻辑过期

手动添加一个字段expire记录过期时间。

KEY VALUE
heima:user:1 {name:"Jack", age:21, expire:152141223}

(2)案例:互斥锁

改造:查询商铺

​ 其实就是在原本逻辑上,在未命中查数据库时,加上了限制:①只有获取到了锁才能去数据库查询 ,然后写入缓存,减少数据库压力,②写入缓存后一定需要释放锁,才能结束逻辑。

流程图:

锁的选择:

首先我们要知道,任何东西可以表示唯一的锁。

redis中String类型的数据有个命令叫SETNX,只有当这个键不存在的时候才能够存入,也就是说,后续多个线程使用SETNX操作指定键值的话,只有第一个能够成功。

此处只做模拟,真实的情况可能不会使用SETNX,但是逻辑是通用的,只是选择的唯一锁不同。

代码如下:

1.加锁

在Service层中,加锁的操作封装为一个函数:

java 复制代码
private boolean tryLock(String key){
      Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10L, TimeUnit.SECONDS);
      return BooleanUtil.isTrue(flag);
}

加上过期时间可以进一步保障安全,避免出现死锁

代码解读:

1.返回值使用的BooleanUtil是什么?

直接返回flag的话,会做拆箱,拆箱过程中可能出现空指针。

我们也可以如下返回:

java 复制代码
return flag != null && flag;

2.拆箱是什么?

在 Java 中,数据类型分为两类:

  • 基本数据类型 :直接存储 "值",效率高,如 int(整数)、double(浮点数)、boolean(布尔值)等,共 8 种。
  • 引用数据类型 :存储 "对象的内存地址",如 StringList,以及基本类型对应的 "包装类"(如 Integer 对应 intDouble 对应 double)。

问题在于:基本数据类型不能直接参与 "面向对象" 的操作 。比如,你无法把 int 直接放进需要 "对象" 的集合(如 List<Object>)中,因为集合只接收引用类型。

此时,"装箱" 和 "拆箱" 就成了桥梁:

  • 装箱 :把 "基本数据类型的值" 包装成 "对应的包装类对象"(比如把 int 10 变成 Integer 对象),让基本类型能以 "对象" 身份参与面向对象操作。
  • 拆箱 :把 "包装类对象" 中的 "基本类型值" 提取出来(比如把 Integer 对象 变回 int 10),让包装类对象能像基本类型一样直接参与数值运算(如加减乘除)。

2.开锁

开锁的操作封装为一个函数:

java 复制代码
private void unLock(String key){
      stringRedisTemplate.delete(key);
}

3.业务逻辑

接着实现互斥锁解决缓存击穿的逻辑,先创建一个方法叫做queryWithMutex(),表示使用了互斥锁的查询,然后将上文缓存空值的代码粘贴过来改造即可:

java 复制代码
private Shop queryWithMutex(Long id) {
      String key = CACHE_SHOP_KEY + id;
      //1.从缓存中取数据
      String shopJson = stringRedisTemplate.opsForValue().get(key);
      //2.判断是否存在该数据(该数据必须不为空,不为空数据)
      if (StrUtil.isNotBlank(shopJson)){
            //3.存在,返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
      }
      //判断命中是否是空数据
      if (shopJson != null){
            return null;
      }
      //4.不存在,缓存重建
      //4.1.尝试获取锁
      String lock = LOCK_SHOP_KEY + id;
      boolean isLock = tryLock(lock);
      Shop shop = null;
      //4.2判断是否获取锁成功
      try {
            //4.3.获取失败,休眠,再次尝试
            if (!isLock){
            Thread.sleep(50);
            return queryWithMutex(id);
            }
            //4.4.获取锁成功,根据id查询数据库
            shop = getById(id);

            //5.数据库中也没有,将该值以空数据存入Redis
            if (shop == null){
            //空数据写入redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
            }
            //6.数据库中查到,将数据存入缓存
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
      } catch (InterruptedException e) {
            throw new RuntimeException(e);
      } finally {
            //6.释放锁
            unLock( lock);
      }
      //7.返回数据
      return shop;
}

代码解读

  1. 为什么需要使用finally释放锁?

    为了应对异常场景下锁无法正常释放的问题,避免程序陷入 "死锁" 。

  2. 锁获取失败,休眠后的递归是如何结束的?

    配合互斥锁流程图来看,获取锁失败表示有一个进程1正在进行缓存重构,等待该进程1重构完毕之后,本进程就能从缓存中查到数据,并且逐层地将这个数据返回出递归。

  • 获取锁成功应该再次检测redis缓存是否过期,做DoubleCheck。如果存在则无需重建缓存,这里就不进行二次检查了。

业务层代码:

java 复制代码
@Override
public Result queryShopById(Long id) {
     //互斥锁解决缓存击穿
      Shop shop = queryWithMutex(id);
      if (shop == null){
            return Result.fail("店铺不存在");
      }

      return Result.ok(shop);
}

测试:

我们人为将拿到锁的进程休眠200ms,这样就能模拟出并发多线程下,我们的锁能不能够保证就一次查询数据库。

将Redis中的缓存删除后,我们用测试软件开1000个线程访问查询接口,如果只有一次数据库查询,说明我们解决了缓存击穿问题。

java 复制代码
      try {
            //4.3.获取失败,休眠,再次尝试
            if (!isLock){
            Thread.sleep(50);
            return queryWithMutex(id);
            }
            //4.4.获取锁成功,根据id查询数据库
            shop = getById(id);
           
           //将线程休眠,模拟缓存重构延时
           Thread.sleep(200);

*JMeter安装和使用

如何多线程的访问接口?这里我们会使用到一个工具,叫做JMeter,下载链接:Apache JMeter - Download Apache JMeter,具体不做详细说明,因为只做测试用。

下载解压后,打开安装目录下该软件的bin目录,打开jmeter.bat文件即可打开软件。

按照如下操作可以设置中文:

接着我们右键TestPlan,创建一个线程组

接着配置线程组

第一个设置线程数为1000

第二个设置线程在5s内完成全部启动

接着右键线程组,添加一个HTTP请求

配置如下:

最后我们可以右键HTTP请求添加各种结果图

发送请求就点击上方的开始按钮

(3)案例:逻辑过期

逻辑过期一般是用于一些活动上,过期时间设置为活动结束时间。

理论上不会出现缓存未命中的情况 ,因为这些热点key都是会手动去添加的,而且不会过期,直到活动结束手动删除。

虽然不会出现未命中情况,但是为了代码的健壮性,可以保留进行一次缓存是否命中的判断,未命中直接返回空结束即可。

现在需要考虑的是:命中情况下,判断逻辑时间是否过期。

改造:查询商铺

如何存逻辑过期时间值得考究:我们可以创建一个类,存放逻辑过期时间,如何其他类继承它。

但是这样做耦合性太高,这里采用的是创建一个RedisData类,一个属性是过期时间,另外一个属性是数据

创建一个新对象RedisData,放在Utils包中

java 复制代码
@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

①写入RedisData

我们需要封装一个方法,用于装入数据到RedisData,封装逻辑时间

java 复制代码
private void saveShop2Redis(Long id, Long expireSeconds){
      //1.从数据库拿到店铺
      Shop shop = getById(id);
      //2.封装逻辑时间
      RedisData redisData = new RedisData();
      redisData.setData(shop);
      redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
      //3.写入Redis
      stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}

代码解读:

  1. LocalDateTime.now():获取当前的日期和时间(本地时间)
  2. .plusSeconds(expireSeconds):在当前时间基础上增加expireSeconds

②业务逻辑:

我们可以先复制缓存穿透案例的方法,然后改名为queryWithLogicalExpire,接着按流程图,改造代码:

java 复制代码
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

private Shop queryWithLogicalExpire(Long id) {
      String key = CACHE_SHOP_KEY + id;
      //1.从缓存中取数据
      String shopJson = stringRedisTemplate.opsForValue().get(key);
      //2.判断是否存在该数据(该数据必须不为空,不为空数据)
      if (StrUtil.isBlank(shopJson)){
            //3.不存在,返回
            return null;
      }

      //4.存在,先将json反序列化为对象
      RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
      Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);

      //5.判断逻辑时间是否过期,未过期即表示逻辑时间在当前时间之后

      if (redisData.getExpireTime().isAfter(LocalDateTime.now())){
            //5.1.未过期,之间返回商铺信息
            return shop;
      }
      //5.2.过期,需要缓存重建
      //6.缓存重建
      //6.1.获取互斥锁
      String lockKey = LOCK_SHOP_KEY + id;
      boolean isLock = tryLock(lockKey);
      //6.2.判断是否获取锁成功
      if (!isLock){
            //6.3.获取成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                  //重建缓存
                  this.saveShop2Redis(id, 20L);
            } catch (Exception e) {
                  throw new RuntimeException(e);
            }finally {
                  //释放锁
                  unLock(lockKey);
            }
            });

      }
      //6.4.返回过期的商铺数据
      return shop;
}

代码解读:

  1. 如何反序列化RedisData对象?

    RedisData中的Data对象是一个Object类型的,使用Hutool根据的JSONUtil会将这个Object类型的转换为Hutool工具包中的JSONObject类型,我们需要使用该工具包中的toBean方法来转换。

  2. 如何判断逻辑时间是否过期?

    我们设置的逻辑时间是一个期限;过期就表示当前时间在逻辑时间之后,未过期就表示逻辑时间在当前时间之后。

  3. 第一行是上面意思

    开启一个线程池,命名为CACHE_REBUILD_EXECUTOR,如果直接创建一个线程用来缓存重构的话,后续线程的销毁和再次重建需要消耗更多性能。

  4. 为什么逻辑过期无需考虑缓存穿透问题?

    逻辑过期默认数据都是存在的,不会再从数据库中查找新数据放入缓存中,如果缓存中没有查到该数据就直接结束了,不会再经过数据库查询。逻辑过期唯一需要考虑的是:更新数据,也就是根据逻辑时间判断是否需要从数据库中更新数据。

  • 获取锁成功应该再次检测redis缓存是否过期,做DoubleCheck。如果存在则无需重建缓存,这里就不进行二次检查了。

测试:

1.在缓存重构时加上延时,这样测试更容易出现线程安全问题

java 复制代码
private void saveShop2Redis(Long id, Long expireSeconds){
      //1.从数据库拿到店铺
      Shop shop = getById(id);
     //加上延时
     Thread.sleep(200);
     
      //2.封装逻辑时间
      RedisData redisData = new RedisData();
      redisData.setData(shop);
      redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
      //3.写入Redis
      stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}

2.需要我们先存入一个值,然后等待这个值的逻辑时间过期

这里会需要将saveShop2Redis方法的访问修饰符改成public才能调用

在测试区运行一次即可

java 复制代码
import com.hmdp.service.impl.ShopServiceImpl;
@SpringBootTest
class HmDianPingApplicationTests {
    @Resource
    ShopServiceImpl shopService;

    @Test
    void testSaveShop() {
        shopService.saveShop2Redis(1, 10L);

    }
}

3.接着使用JMeter多线程测试,然后在结果图中可以看到详细信息:

等待一会,我们设置的逻辑过期时间是10秒,逻辑过期时间过期了,我们在数据库修改tb_shop的id为1出的数据,将name改成123茶餐厅,执行代码时会开新线程执行缓存重建,缓存重建完成前 返回的都是更新前数据,我们将缓存重建休眠了200ms,所以会有不少线程返回的都是原来的结果,之后缓存重建完成后,返回结果才是123茶餐厅。

七、缓存工具封装

上述只是实现了Shop类型的对象应对不同缓存问题的策略,如果我们需要同时满足任意Java对象应对不同缓存问题呢?

我们可以分装一个工具类,叫做CacheClient

java 复制代码
@Slf4j
@Component
public class CacheClient {
    private StringRedisTemplate stringRedisTemplate;
    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }
}

@Component注释,使该工具类交给Spring管理。

使用的构造器注入,使得后续使用时需要先调用构造函数注入RedisStringTemplate:

java 复制代码
CacheClient cacheClient = new CacheClient(stringRedisTemplate);

(1)方法1:序列化存储

一共有两种序列化存储:①将Java对象序列化存储,可设置TTL时间、②将Java对象放入RedisData中序列化存储,设置逻辑过期时间

①将Java对象序列化存储,可设置TTL时间

java 复制代码
public void set(String key, Object value, Long time, TimeUnit unit) {
      stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}

②将Java对象放入RedisData中序列化存储,设置逻辑过期时间

java 复制代码
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
      //设置逻辑时间
      RedisData redisData = new RedisData();
      redisData.setData(value);
      redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
      //写入Redis
      stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}

(2)方法2:缓存空值

缓存空值解决缓存穿透问题

我们可以用泛型,来替代原来的在缓存穿透方案中的Shop,

java 复制代码
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.从缓存中取数据
      String RJson = stringRedisTemplate.opsForValue().get(key);
      //2.判断是否存在该数据(该数据必须不为空,不为空数据)
      if (StrUtil.isNotBlank(RJson)){
            //存在,返回
            R r = JSONUtil.toBean(RJson, type);
            return r;
      }
      //判断命中是否是空数据
      if (RJson != null){
            return null;
      }
      //3.不存在,从数据库中查
      R r = dbFallback.apply(id);
      //4.数据库中也没有,将该值以空数据存入Redis
      if (r == null){
            //空数据写入redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
      }
      //5.数据库中查到,将数据存入缓存
      this.set(key, r, time, unit);
      //6.返回数据
      return r;
}

代码解读:

  1. 我们如何获取到数据库的数据?

    当出现设计者得不到的数据时,我们可以让调用者传入数据库查询的函数,函数式编程,我们直接执行函数逻辑即可:

    调用者传入的函数可以是一个lambo表达式

  2. 第五步 this.set(key, r, time, unit);的作用

    因为该工具类中有一个方法,java对象序列化存储的方法,我们可以直接调用该方法使用

调用者使用如下:

红框中该代码等同于

java 复制代码
id2 -> getById(id2)

(3)方法3:逻辑过期

java 复制代码
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

public<R,ID> R queryWithLogicalExpire(String keyPrefix,ID id, Class<R> type,Function<ID, R> dbFallback, Long time, TimeUnit unit) {
      String key = keyPrefix + id;
      //1.从缓存中取数据
      String RJson = stringRedisTemplate.opsForValue().get(key);
      //2.判断是否存在该数据(该数据必须不为空,不为空数据)
      if (StrUtil.isBlank(RJson)){
            //3.不存在,返回
            return null;
      }

      //4.存在,先将json反序列化为对象
      RedisData redisData = JSONUtil.toBean(RJson, RedisData.class);
      R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);

      //5.判断逻辑时间是否过期,未过期即表示逻辑时间在当前时间之后
      if (redisData.getExpireTime().isAfter(LocalDateTime.now())){
            //5.1.未过期,返回信息
            return r;
      }
      //5.2.过期,需要缓存重建
      //6.缓存重建
      //6.1.获取互斥锁
      String lockKey = LOCK_KEY + id;
      boolean isLock = tryLock(lockKey);
      //6.2.判断是否获取锁成功
      if (!isLock){
            //6.3.获取成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                  //从数据库获取新数据
                  R newR = dbFallback.apply(id);
                  //重建缓存
                  this.setWithLogicalExpire(key, newR, time, unit);
            } catch (Exception e) {
                  throw new RuntimeException(e);
            }finally {
                  //释放锁
                  unLock(lockKey);
            }
            });

      }
      //6.4.返回过期的数据
      return r;
}

//取锁
private boolean tryLock(String key) {
      Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10L, TimeUnit.SECONDS);
      return BooleanUtil.isTrue(flag);
}
//开锁
private void unLock(String key){
      stringRedisTemplate.delete(key);
}

代码解读

  1. 原来的代码中缓存重载没有查询数据库操作,为什么这里需要?

    原来的代码,我们调用的时saveShop2Redis()方法,这个方法包含两个功能,一个就是从数据库中查数据,一个就是将数据和逻辑时间转入到RedisData中。

使用:

测试:

1.先存入一个值,等待逻辑时间过期,再去进行查找

相关推荐
郝学胜-神的一滴3 小时前
主成分分析(PCA)在计算机图形学中的深入解析与应用
开发语言·人工智能·算法·机器学习·1024程序员节
云边有个稻草人3 小时前
反爬克星还是效率神器?Browser-Use+cpolar重构Web自动化逻辑
cpolar·1024程序员节
weixin_436525073 小时前
若依 - idea集成docker一键部署springboot项目(docker-compose)
java·1024程序员节
超防局3 小时前
SQLMap 终极渗透手册(2025全功能版)
sql·web安全·1024程序员节
回忆是昨天里的海3 小时前
k8s部署容器化应用-tomcat
云原生·容器·kubernetes·1024程序员节
小马哥编程3 小时前
【软考架构】架构风格:RAG知识库是属于软件八大架构风格中的哪一个,黑板架构风格 ?规则系统体系风格?
大数据·计算机网络·架构·1024程序员节
離離原上譜3 小时前
python-docx 安装与快速入门
python·word·python-docx·自动化办公·1024程序员节
碧海银沙音频科技研究院3 小时前
ES7243E 模拟音频转I2S输入给BES I2S_Master数据运行流程分析
1024程序员节
FPGA_小田老师3 小时前
FPGA Debug:Vivado程序综合卡在了Run Synthesis
1024程序员节·vivado问题·run synth卡住·lut资源不足·fpga debug