前言
这次实现的功能是使用 Redis 缓存商铺详情和商铺类型,减少重复查询数据库的次数。代码需要完成三件事:查询时优先读取缓存,缓存未命中时查询数据库并回填;更新商铺时先修改数据库,再删除缓存;将商铺查询的通用逻辑从 ServiceImpl 提取到泛型 CacheClient 中。
一、缓存查询的基本流程
缓存位于应用和数据库之间。查询请求到达后,应用先从 Redis 读取数据:
- 缓存命中,直接把缓存数据返回给调用方。
- 缓存未命中,查询数据库。
- 数据库存在对应记录,将记录写入 Redis,并设置过期时间。
- 返回数据库查询结果。
在当前项目中,商铺对象会先转换为 JSON 字符串,再通过 StringRedisTemplate 写入 Redis。读取时执行相反的转换,把 JSON 还原为 Shop 对象。
这套流程把高频读请求拦在 Redis 中。第一次查询需要访问数据库,后续查询在缓存有效期内可以直接读取 Redis。
二、缓存更新的三种策略
缓存中的数据来自数据库。数据库发生变化后,缓存也需要在合适的时机失效或更新,否则用户可能读到旧数据。
常见的缓存更新策略有三种:
| 对比项 | 内存淘汰 | 超时剔除 | 主动更新 |
|---|---|---|---|
| 实现方式 | Redis 内存不足时,根据淘汰策略自动移除部分数据 | 写入缓存时设置 TTL,到期后自动删除 | 修改数据库时,由业务代码同步处理缓存 |
| 一致性 | 较差 | 一般 | 较好 |
| 维护成本 | 无 | 较低 | 较高 |
不同业务对一致性的要求不同:
- 商铺类型变化频率低,可以使用 Redis 内存淘汰,并增加超时剔除作为更新兜底。
- 商铺详情可能被后台修改,需要主动处理缓存,同时设置过期时间,避免缓存长期保留旧数据。
超时剔除不能保证数据库修改后缓存立即更新。在 TTL 到期前,请求仍可能读到旧数据。因此,一致性要求较高的数据还要配合主动更新。
三、使用 Cache Aside Pattern 维护一致性
Cache Aside Pattern 由业务代码负责缓存读取和失效处理。实现时需要确定三个问题:缓存删除还是缓存更新、数据库与缓存如何保持操作边界、两个操作按照什么顺序执行。
3.1 删除缓存还是更新缓存
修改数据库后,可以直接更新缓存,也可以删除缓存。
直接更新缓存会产生额外写操作。如果一条数据在很短时间内连续修改十次,而期间没有任何查询,那么前九次缓存更新没有带来实际收益。
删除缓存更适合当前场景。数据库修改完成后让缓存失效,下次查询再从数据库加载最新数据并重新写入缓存。这种方式实现简单,也能减少无效写操作。
3.2 两种执行顺序
先删除缓存,再更新数据库时,容易出现下面的并发过程:
- 线程一删除旧缓存。
- 线程一开始更新数据库,数据库写入耗时相对较长。
- 线程二查询缓存,发现缓存不存在。
- 线程二从数据库读到旧值,并把旧值重新写入缓存。
- 线程一完成数据库更新。
此时数据库已经是新值,缓存却保存了旧值,而且旧值会一直保留到缓存再次失效。
先更新数据库,再删除缓存时,也存在一个较小的并发窗口:查询线程先从数据库读出旧值,更新线程随后修改数据库并删除缓存,查询线程最后把旧值写回缓存。这个过程要求数据库查询、数据库更新、缓存删除和缓存写入恰好交错,出现概率相对较低。
因此,当前项目采用下面的顺序:
- 读操作:查询缓存,未命中时查询数据库,随后写入缓存并设置 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。
这里需要区分三种查询结果:
- shopJson 有实际内容,说明缓存命中,反序列化后直接返回。
- shopJson 是空字符串,说明之前已经查询过数据库,并确认商铺不存在,直接返回 null。
- 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 作为缓存更新兜底。