一、什么是缓存
(1)介绍
-
**核心概念:**缓存就是数据交换的缓冲区(称作Cache),是存储数据的临时地方,一般读写性能较高。
-
具体解释:在计算机领域和软件开发中,缓存(Cache)是一种用于临时存储高频访问数据的技术,核心目的是减少对 "低速数据源" 的直接访问,从而提升系统响应速度、降低底层资源压力。
简单理解:缓存就像你书桌抽屉里的常用物品(如笔、笔记本)------ 你不需要每次用都去书房的柜子里找(对应 "低速数据源",如数据库、远程服务器),而是提前放在随手可及的地方(对应 "缓存介质",如内存),用的时候直接拿,效率大幅提升。
缓存场景十分丰富:

(2)作用
- 缓存的作用:
-
降低后端负载
-
提高读写效率,降低响应时间
- 缓存的成本:
- 数据一致性成本
- 代码维护成本
- 运维成本
二、添加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())。
- 适用范围 :
- 只能用于 public 方法(非 public 方法注解可能不生效,因 Spring AOP 代理机制限制)。
- 通常标注在 Service 层,避免在 Controller 或 Repository 层使用。
- 异常回滚规则 :
- 默认只对 未检查异常(Unchecked Exception) 回滚(如
RuntimeException及其子类)。- 对 已检查异常(Checked Exception) 不回滚(如
IOException、SQLException),需通过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的话,会做拆箱,拆箱过程中可能出现空指针。
我们也可以如下返回:
javareturn flag != null && flag;2.拆箱是什么?
在 Java 中,数据类型分为两类:
- 基本数据类型 :直接存储 "值",效率高,如
int(整数)、double(浮点数)、boolean(布尔值)等,共 8 种。- 引用数据类型 :存储 "对象的内存地址",如
String、List,以及基本类型对应的 "包装类"(如Integer对应int、Double对应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;
}
代码解读
为什么需要使用finally释放锁?
为了应对异常场景下锁无法正常释放的问题,避免程序陷入 "死锁" 。
锁获取失败,休眠后的递归是如何结束的?
配合互斥锁流程图来看,获取锁失败表示有一个进程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));
}
代码解读:
LocalDateTime.now():获取当前的日期和时间(本地时间).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;
}
代码解读:
如何反序列化RedisData对象?
RedisData中的Data对象是一个Object类型的,使用Hutool根据的JSONUtil会将这个Object类型的转换为Hutool工具包中的JSONObject类型,我们需要使用该工具包中的toBean方法来转换。
如何判断逻辑时间是否过期?
我们设置的逻辑时间是一个期限;过期就表示当前时间在逻辑时间之后,未过期就表示逻辑时间在当前时间之后。
第一行是上面意思?
开启一个线程池,命名为CACHE_REBUILD_EXECUTOR,如果直接创建一个线程用来缓存重构的话,后续线程的销毁和再次重建需要消耗更多性能。
为什么逻辑过期无需考虑缓存穿透问题?
逻辑过期默认数据都是存在的,不会再从数据库中查找新数据放入缓存中,如果缓存中没有查到该数据就直接结束了,不会再经过数据库查询。逻辑过期唯一需要考虑的是:更新数据,也就是根据逻辑时间判断是否需要从数据库中更新数据。
- 获取锁成功应该再次检测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;
}
代码解读:
我们如何获取到数据库的数据?
当出现设计者得不到的数据时,我们可以让调用者传入数据库查询的函数,函数式编程,我们直接执行函数逻辑即可:
调用者传入的函数可以是一个lambo表达式
第五步
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);
}
代码解读
原来的代码中缓存重载没有查询数据库操作,为什么这里需要?
原来的代码,我们调用的时
saveShop2Redis()方法,这个方法包含两个功能,一个就是从数据库中查数据,一个就是将数据和逻辑时间转入到RedisData中。
使用:

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

