《商户查询缓存案例》使用案例学习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.先存入一个值,等待逻辑时间过期,再去进行查找

相关推荐
卧室小白21 分钟前
redis-配置
数据库·redis·缓存
sthnyph2 小时前
docker compose安装redis
redis·docker·容器
KmSH8umpK3 小时前
Redis分布式锁从原生手写到Redisson高阶落地,附线上死锁复盘优化方案进阶第六篇
数据库·redis·分布式
开开心心就好4 小时前
近200个工具的电脑故障修复合集
安全·智能手机·pdf·电脑·consul·memcache·1024程序员节
KmSH8umpK5 小时前
Redis分布式锁从原生手写到Redisson高阶落地,附线上死锁复盘优化方案进阶第四篇
数据库·redis·分布式
KmSH8umpK6 小时前
Redis分布式锁从原生手写到Redisson高阶落地,附线上死锁复盘优化方案进阶第五篇
数据库·redis·分布式
贾红平7 小时前
Redis缓存策略深度解析2026
redis
yuweiade8 小时前
GO 快速升级Go版本
开发语言·redis·golang
运维全栈笔记20 小时前
K8S部署Redis高可用全攻略:1主2从3哨兵架构实战
redis·docker·云原生·容器·架构·kubernetes·bootstrap
凯瑟琳.奥古斯特1 天前
Redis是什么及核心特性
前端·css·redis·缓存