【黑马点评 | 第二篇】Redis 缓存更新策略与商铺缓存实现

前言

这次实现的功能是使用 Redis 缓存商铺详情和商铺类型,减少重复查询数据库的次数。代码需要完成三件事:查询时优先读取缓存,缓存未命中时查询数据库并回填;更新商铺时先修改数据库,再删除缓存;将商铺查询的通用逻辑从 ServiceImpl 提取到泛型 CacheClient 中。

一、缓存查询的基本流程

缓存位于应用和数据库之间。查询请求到达后,应用先从 Redis 读取数据:

  1. 缓存命中,直接把缓存数据返回给调用方。
  2. 缓存未命中,查询数据库。
  3. 数据库存在对应记录,将记录写入 Redis,并设置过期时间。
  4. 返回数据库查询结果。

在当前项目中,商铺对象会先转换为 JSON 字符串,再通过 StringRedisTemplate 写入 Redis。读取时执行相反的转换,把 JSON 还原为 Shop 对象。

这套流程把高频读请求拦在 Redis 中。第一次查询需要访问数据库,后续查询在缓存有效期内可以直接读取 Redis。

二、缓存更新的三种策略

缓存中的数据来自数据库。数据库发生变化后,缓存也需要在合适的时机失效或更新,否则用户可能读到旧数据。

常见的缓存更新策略有三种:

对比项 内存淘汰 超时剔除 主动更新
实现方式 Redis 内存不足时,根据淘汰策略自动移除部分数据 写入缓存时设置 TTL,到期后自动删除 修改数据库时,由业务代码同步处理缓存
一致性 较差 一般 较好
维护成本 较低 较高

不同业务对一致性的要求不同:

  • 商铺类型变化频率低,可以使用 Redis 内存淘汰,并增加超时剔除作为更新兜底。
  • 商铺详情可能被后台修改,需要主动处理缓存,同时设置过期时间,避免缓存长期保留旧数据。

超时剔除不能保证数据库修改后缓存立即更新。在 TTL 到期前,请求仍可能读到旧数据。因此,一致性要求较高的数据还要配合主动更新。

三、使用 Cache Aside Pattern 维护一致性

Cache Aside Pattern 由业务代码负责缓存读取和失效处理。实现时需要确定三个问题:缓存删除还是缓存更新、数据库与缓存如何保持操作边界、两个操作按照什么顺序执行。

3.1 删除缓存还是更新缓存

修改数据库后,可以直接更新缓存,也可以删除缓存。

直接更新缓存会产生额外写操作。如果一条数据在很短时间内连续修改十次,而期间没有任何查询,那么前九次缓存更新没有带来实际收益。

删除缓存更适合当前场景。数据库修改完成后让缓存失效,下次查询再从数据库加载最新数据并重新写入缓存。这种方式实现简单,也能减少无效写操作。

3.2 两种执行顺序

先删除缓存,再更新数据库时,容易出现下面的并发过程:

  1. 线程一删除旧缓存。
  2. 线程一开始更新数据库,数据库写入耗时相对较长。
  3. 线程二查询缓存,发现缓存不存在。
  4. 线程二从数据库读到旧值,并把旧值重新写入缓存。
  5. 线程一完成数据库更新。

此时数据库已经是新值,缓存却保存了旧值,而且旧值会一直保留到缓存再次失效。

先更新数据库,再删除缓存时,也存在一个较小的并发窗口:查询线程先从数据库读出旧值,更新线程随后修改数据库并删除缓存,查询线程最后把旧值写回缓存。这个过程要求数据库查询、数据库更新、缓存删除和缓存写入恰好交错,出现概率相对较低。

因此,当前项目采用下面的顺序:

  • 读操作:查询缓存,未命中时查询数据库,随后写入缓存并设置 TTL。
  • 写操作:先更新数据库,再删除缓存。

数据库事务可以约束数据库操作的提交和回滚。当前代码中的 Spring 事务不会自动让 MySQL 与 Redis 形成跨资源原子事务,如果业务要求严格保证两个操作同时成功或失败,还需要单独设计一致性方案。本文只实现当前项目使用的 Cache Aside 基本流程。

四、方式一:在 ServiceImpl 中直接实现缓存

第一种方式把 Redis 查询、数据库查询、JSON 转换和缓存写入都放在业务实现类中。代码路径直观,适合先理解缓存处理的完整步骤。

4.1 直接实现商铺详情查询

ShopServiceImpl 中的查询方法如下:

java 复制代码
//封装缓存穿透方法-缓存穿透问题
public Shop querywithpassthrough(Long id) {
    String key=CACHE_SHOP_KEY+id;
    //1.从redis 查询店铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //2.1判断是否存在
    if(StringUtils.isNotBlank(shopJson)){
        //2.1.1.如果存在,返回店铺信息
        Shop shop= JSONUtil.toBean(shopJson, Shop.class);
        return shop;
    }
    //2.2判断是否为空值
    if(shopJson != null){
        //2.2.1返回错误信息
        return null;
    }

    //4.如果不存在,查询数据库
    Shop shop=getById(id);
    //4.1数据库不存在,返回错误信息
    if(shop==null){
        //4.1.1将空值写入Redis
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        //4.1.2返回错误信息
        return null;
    }
    //4.2数据库存在,返回店铺信息,写入Redis
    stringRedisTemplate.opsForValue().
            set(key, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
    //5.返回
    return shop;
}

缓存 key 由固定前缀和商铺 id 组成,例如 cache:shop:1。同一个商铺始终对应同一个 key。

这里需要区分三种查询结果:

  1. shopJson 有实际内容,说明缓存命中,反序列化后直接返回。
  2. shopJson 是空字符串,说明之前已经查询过数据库,并确认商铺不存在,直接返回 null。
  3. shopJson 为 null,说明 Redis 中没有这个 key,需要继续查询数据库。

数据库也查不到商铺时,代码会缓存一个空字符串,并使用 CACHE_NULL_TTL 设置较短的有效期。这样,同一个不存在的 id 在短时间内重复访问时,不会持续查询数据库。

数据库存在商铺时,JSON 数据会保存 30 分钟。写入值和设置 TTL 由一次 set 调用完成。

切换到 ServiceImpl 直接实现方式时,queryById 可以这样调用:

java 复制代码
@Override
public Result queryById(Long id) {
    Shop shop = querywithpassthrough(id);
    if (shop == null) {
        return Result.fail("店铺不存在");
    }
    //返回
    return Result.ok(shop);
}

querywithpassthrough 负责取得 Shop,queryById 负责把业务结果包装为统一的 Result。

4.2 更新商铺后删除缓存

商铺修改使用"先更新数据库,再删除缓存"的顺序:

java 复制代码
@Override
@Transactional(rollbackFor = Exception.class)
public Result updateshop(Shop shop) {
    //1.更新数据库
    updateById(shop);
    //2.删除Redis缓存
    stringRedisTemplate.delete(CACHE_SHOP_KEY+shop.getId());
    //3.返回
    return Result.ok();
}

updateById 根据商铺 id 更新数据库。数据库更新完成后,删除该商铺对应的缓存 key。下一次读取商铺详情时会发生缓存未命中,业务代码再从数据库读取最新数据并写回 Redis。

这里选择删除缓存,没有直接把传入的 Shop 写入缓存。原因是更新参数可能只包含部分字段,而数据库中的完整记录还包含其他字段;删除缓存后重新查询数据库,可以统一生成完整缓存数据。

4.3 商铺类型列表缓存

商铺类型需要按照 sort 字段排序,因此使用 Redis List 保存序列化后的元素,并通过 rightPushAll 保持数据库查询结果的顺序。

相关缓存常量如下:

java 复制代码
public static final String CACHE_SHOPTYPE_KEY = "cache:shoptype:list";
public static final Long CACHE_SHOPTYPE_TTL = 30L;

ShopTypeServiceImpl 的实现如下:

java 复制代码
@Override
public Result queryList() {
    // 1. 从 Redis 中获取完整的商铺类型列表
    List<String> cachedList = stringRedisTemplate.opsForList()
            .range(CACHE_SHOPTYPE_KEY, 0, -1);

    // 2. 缓存命中,将 JSON 列表转换为商铺类型列表后直接返回
    if (CollectionUtil.isNotEmpty(cachedList)) {
        List<ShopType> typeList = new ArrayList<>(cachedList.size());
        for (String typeJson : cachedList) {
            typeList.add(JSONUtil.toBean(typeJson, ShopType.class));
        }
        return Result.ok(typeList);
    }

    // 3. 缓存未命中,按照 sort 字段从数据库查询商铺类型列表
    List<ShopType> typeList = query().orderByAsc("sort").list();

    // 4. 数据库中也没有商铺类型,返回空列表
    if (CollectionUtil.isEmpty(typeList)) {
        return Result.ok(typeList);
    }

    // 5. 将商铺类型逐个序列化,保持 Redis List 与数据库查询结果顺序一致
    List<String> jsonList = new ArrayList<>(typeList.size());
    for (ShopType shopType : typeList) {
        jsonList.add(JSONUtil.toJsonStr(shopType));
    }

    // 6. 将完整列表写入 Redis,并设置过期时间作为缓存更新兜底
    stringRedisTemplate.opsForList().rightPushAll(CACHE_SHOPTYPE_KEY, jsonList);
    stringRedisTemplate.expire(CACHE_SHOPTYPE_KEY, CACHE_SHOPTYPE_TTL, TimeUnit.MINUTES);

    // 7. 返回数据库查询到的商铺类型列表
    return Result.ok(typeList);
}

读取缓存时,range 的起止下标为 0 和 -1,表示读取整个 Redis List。缓存命中后,代码逐个反序列化元素,并按照 List 中原有的顺序返回。

缓存未命中时,数据库查询通过 orderByAsc("sort") 保证排序。随后逐个序列化 ShopType,并使用 rightPushAll 按相同顺序写入 Redis。expire 为整个 key 设置 30 分钟有效期,作为类型数据更新的兜底策略。

如果数据库没有任何商铺类型,接口返回成功的空列表,并且不会创建 Redis key。这个分支符合列表接口的语义,但在数据库长期为空时,每次请求都会再次查询数据库。

当前写法适合商铺类型数据稳定、并发压力较低的课程项目。多个请求同时在冷缓存状态下执行 rightPushAll 时,可能重复追加列表;写入 List 和设置 TTL 也是两次独立操作。高并发环境可以增加缓存重建锁,或者把整个列表序列化后通过一次带 TTL 的 set 写入。

五、方式二:使用 CacheClient 泛型封装

直接实现能够清楚展示流程,但其他业务也需要重复编写"拼接 key、查询 Redis、查询数据库、写入缓存"的代码。CacheClient 使用泛型和函数式接口封装这条通用流程,ServiceImpl 只提供业务类型、缓存参数和数据库查询函数。

5.1 统一写入缓存

CacheClient 先封装一个普通缓存写入方法:

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

value 使用 Object 接收任意业务对象。方法统一完成 JSON 序列化,并通过 time 和 unit 设置过期时间。

5.2 泛型查询方法

通用查询方法如下:

java 复制代码
//缓存穿透问题
public <R,ID> R querywithpassthrough(String keyprefix, ID id, Class<R> type,
                                    Function<ID, R> function, Long time, TimeUnit unit) {
    String key=keyprefix+id;
    //1.从redis 查询店铺缓存
    String json = stringRedisTemplate.opsForValue().get(key);
    //2.1判断是否存在
    if(StringUtils.isNotBlank(json)){
        //2.1.1.如果存在,返回店铺信息
        return JSONUtil.toBean(json, type);
    }
    //2.2判断是否为空值
    if(json!=null){
        //2.2.1返回错误信息
        return null;
    }

    //4.如果不存在,查询数据库
    R r=function.apply(id);
    //4.1数据库不存在,返回错误信息
    if(r==null){
        //4.1.1将空值写入Redis
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        //4.1.2返回错误信息
        return null;
    }
    //4.2数据库存在,返回店铺信息,写入Redis
    this.set(key,r,time,unit);
    //5.返回
    return r;
}

各参数的职责如下:

参数 作用
R 缓存数据和方法返回值的类型,例如 Shop
ID 数据 id 的类型,例如 Long
keyprefix Redis key 前缀
id 当前要查询的数据 id
type 反序列化目标类型
function 根据 id 查询数据库的函数
time、unit 正常缓存的有效时间和时间单位

Function<ID, R> 接收一个 ID,返回一个 R。CacheClient 不需要知道数据库由 MyBatis-Plus、Mapper 还是其他方式访问,只需在缓存未命中时执行 function.apply(id)。

R 同时约束数据库查询结果、JSON 反序列化结果和方法返回值。这样,同一个方法既可以缓存 Shop,也可以复用于其他"根据 id 查询单个对象"的业务。

5.3 在 ShopServiceImpl 中调用

要演示本文的泛型透传方案时,可以把 queryById 中的缓存查询切换为下面的调用:

java 复制代码
@Override
public Result queryById(Long id) {
    Shop shop = cacheClient.querywithpassthrough(
            CACHE_SHOP_KEY,
            id,
            Shop.class,
            this::getById,
            CACHE_SHOP_TTL,
            TimeUnit.MINUTES
    );

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

this::getById 是方法引用,等价于 idValue -> this.getById(idValue)。CacheClient 在缓存未命中时调用它,从数据库查询对应商铺。

这次调用的类型可以推断为:

  • ID 是 Long,对应 queryById 的 id。
  • R 是 Shop,对应 Shop.class 和 getById 的返回值。
  • 缓存 key 是 CACHE_SHOP_KEY 与 id 的拼接结果。
  • 正常数据缓存 30 分钟,空值缓存 2 分钟。

完整调用链为:

text 复制代码
ShopServiceImpl.queryById
    -> CacheClient.querywithpassthrough
        -> 查询 Redis
        -> 缓存未命中时执行 this::getById
        -> 写入普通数据或空值
    -> 将 Shop 包装为 Result

六、两种实现方式的区别

对比项 ServiceImpl 直接实现 CacheClient 泛型封装
代码位置 Redis 与数据库逻辑都在业务实现类 通用缓存流程集中在 CacheClient
阅读难度 调用链短,适合第一次理解流程 需要理解泛型、Function 和方法引用
重复代码 每个业务都要重新编写缓存流程 多个业务可以复用同一个查询模板
业务耦合 方法直接依赖 Shop 和 getById CacheClient 只依赖泛型和查询函数
适用阶段 学习流程、业务较少时 缓存查询模式稳定并出现重复代码后

学习时可以先写 ServiceImpl 版本,逐步确认缓存命中、数据库回源和空值缓存的执行顺序。流程稳定后,再提取 CacheClient。泛型封装改变的是代码组织方式,缓存查询和回填规则保持一致。

七、验证缓存是否生效

商铺详情接口为:

text 复制代码
GET /shop/{id}

可以连续请求同一个有效 id。第一次请求完成后,在 Redis 中检查商铺 key:

bash 复制代码
get cache:shop:1
ttl cache:shop:1

更新商铺后,再检查对应 key。按照当前代码,更新接口成功后该 key 应被删除;下一次查询会重新访问数据库并建立缓存。

商铺类型接口为:

text 复制代码
GET /shop-type/list

请求完成后,可以查看完整列表及其剩余时间:

bash 复制代码
lrange cache:shoptype:list 0 -1
ttl cache:shoptype:list

如果数据库中没有商铺类型,接口应返回成功的空列表,Redis 中不会创建商铺类型 key。

总结

缓存查询遵循"先查缓存,未命中再查数据库并回填"的流程;商铺更新采用"先更新数据库,再删除缓存"的顺序。ServiceImpl 直接实现便于观察完整步骤,CacheClient 使用泛型和 Function 提取重复逻辑。商铺详情使用 String 保存单个 JSON 对象,商铺类型使用 Redis List 保持排序,并用 TTL 作为缓存更新兜底。

相关推荐
livemetee43 分钟前
MySQL联合唯一索引(code,deleted)与逻辑删除问题
java·数据库
MacroZheng1 小时前
Redis官方发布高颜值可视化工具,功能更是强的离谱!
java·redis·后端
科技小登1 小时前
业务层四道闸:DDoS+CC、API、Bot 与全球加速的分层防护设计与选型
网络·安全
wuyk5551 小时前
《WiFi 嵌入式物联网开发全套实战》| 第 12 章 Linux 系统 WiFi 常用命令全集:iw/iwlist/ip/wpa_cli 实战
linux·网络·stm32·物联网·tcp/ip
wuminyu1 小时前
纯轻量级锁体系下C2编译器锁粗化和消除机制剖析
java·linux·c语言·jvm·c++
IT小白杨1 小时前
跨境电商账号关联归因链路拆解与可落地环境配置
服务器·网络·经验分享·安全·指纹浏览器
ss2731 小时前
AI全栈实战 | 1.4-02 Spring AOP:@Transactional 失效的 N 种场景,根因其实只有一个
java·spring·ai编程
网硕互联的小客服1 小时前
可以把服务器当成DNS服务器使用吗?如何操作?
运维·服务器·网络
灯澜忆梦1 小时前
【Redis中间件】#4 | 进阶数据类型语法
redis·后端