2022黑马Redis跟学笔记.实战篇 三
- 4.2.商家查询的缓存功能
- 4.3.1.认识缓存
- 4.3.1.1.什么是缓存
- 4.3.1.2.缓存的作用
- 1.为什么要使用缓存
- 2.如何使用缓存
- 3. 添加商户缓存
- 4. 缓存模型和思路
- 4.3.1.3.缓存的成本
- 4.3.2.添加redis缓存
- 4.3.3.缓存更新策略
- 4.3.3.1.三种策略
- (1).内存淘汰:Redis自带的内存淘汰机制
- (2).过期淘汰:利用expire命令给数据设置过期时间
- (3).主动更新:主动完成数据库与缓存的同时更新
- 4.3.3.2.策略选择
- 4.3.3.2.1.低一致性需求
- 4.3.3.3.2.高一致性需求
- 4.3.3.3.主动更新的方案
- 4.3.3.3.1.Cache Aside
- 1.缓存调用者在更新数据库的同时完成对完成的更新
- 4.3.3.3.2.Read/Write Through
- 4.3.3.3.3.Write Back
- 4.3.3.4.Cache Aside的模式选择
- 4.3.3.4.1.更新缓存还是删除缓存?
- 4.3.3.4.2.先操作数据库还是缓存?
- 4.3.3.4.3.如何确保数据库与缓存操作原子性?
- 4.3.3.4.3.1.单体系统
- 4.3.3.4.3.2.分布式系统
- 4.3.3.5.最佳实践
- 4.3.3.5.1. 实现商铺和缓存与数据库双写一致
- 4.3.3.5.2. 查询数据时
- 1.先查询缓存
- 2.如果缓存命中,直接返回
- 3.如果缓存未命中,则查询数据库
- 4.将数据库数据写入缓存
- 5.返回结果
- 4.3.3.5.3. 修改数据库时
- 1.先修改数据库
- 2.然后删除缓存
- 3.确保两者的原子性
- 4.3.4.缓存穿透
- 4.3.4.1.产生原因(定义)
- 4.3.4.2.缓存穿透问题的解决方案
- 4.3.4.2.1.⭐方案一:缓存空对象
- 4.3.4.2.2.方案二:布隆过滤
- 4.3.4.2.2.其它
- 4.3.5.缓存雪崩
- 4.3.5.1 产生原因(定义)
- 4.3.5.2 缓存穿透的解决方案
- 4.3.6.缓存击穿(热点Key)
- 4.3.6.1.产生原因
- 4.3.6.1.1.热点Key
- (1).在某一时段被高并发访问
- (2).缓存重建耗时较长
- 4.3.6.2.解决方案
- 4.3.6.2.1.方案一:互斥锁
- 1. 思路
- 2. 优点
- 3. 缺点
- 4.利用互斥锁解决缓存击穿问题
- 4.3.6.2.2.方案二:逻辑过期
- 1.思路
- 2.优点
- 3.缺点
- 4.利用逻辑过期解决缓存击穿问题
- 4.3.6.2.3.方案对比
- 4.3.7.Redis缓存工具封装
4.2.商家查询的缓存功能
4.3.1.认识缓存
4.3.1.1.什么是缓存
就像自行车,越野车的避震器
举个例子:越野车,山地自行车,都拥有"避震器",防止车体加速后因惯性,在酷似"U"字母的地形上飞跃,硬着陆导致的损害,像个弹簧一样;
同样,实际开发中,系统也需要"避震器",防止过高的数据访问猛冲系统,导致其操作线程无法及时处理信息而瘫痪;
这在实际开发中对企业讲,对产品口碑,用户评价都是致命的;所以企业非常重视缓存技术;
缓存(Cache),就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地代码(例如:
例1:Static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>(); 本地用于高并发
例2:static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build(); 用于redis等缓存
例3:Static final Map<K,V> map = new HashMap(); 本地缓存
由于其被Static修饰,所以随着类的加载而被加载到内存之中,作为本地缓存,由于其又被final修饰,所以其引用(例3:map)和对象(例3:new HashMap())之间的关系是固定的,不能改变,因此不用担心赋值(=)导致缓存失效;
缓存定义:
- 一种具备高效读写能力的数据暂存区域。
4.3.1.2.缓存的作用
1.为什么要使用缓存
一句话:因为速度快,好用
缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力。
实际开发过程中,企业的数据量,少则几十万,多则几千万,这么大数据量,如果没有缓存来作为"避震器",系统是几乎撑不住的,所以企业会大量运用到缓存技术;
但是缓存也会增加代码复杂度和运营的成本:
总结来说,缓存的作用:
- 1.降低后端负载
- 2.提高服务读写响应速度
2.如何使用缓存
实际开发中,会构筑多级缓存来使系统运行速度进一步提升,例如:本地缓存与redis中的缓存并发使用。
浏览器缓存:主要是存在于浏览器端的缓存。
**应用层缓存:**可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存。
**数据库缓存:**在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中。
**CPU缓存:**当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存。
3. 添加商户缓存
在我们查询商户信息时,我们是直接操作从数据库中去进行查询的,大致逻辑是这样,直接查询数据库那肯定慢,所以我们需要增加缓存。
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
//这里是直接查询数据库
return shopService.queryById(id);
}
4. 缓存模型和思路
标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis。
代码如下:
代码思路:如果缓存有,则直接返回,如果缓存不存在,则查询数据库,然后存入redis。
修改ShopController.java
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
// select * from tb_shop where id = ?
return shopService.queryById(id);
}
修改接口IShopService.java
public interface IShopService extends IService<Shop> {
/**
* @param
* @return com.hmdp.dto.Result
* @description //通过id定位商户
* @param: id
* @date 2023/2/12 22:43
* @author wty
**/
Result queryById(Long id);
}
修改ShopMapper.java,增加注解
@Mapper
public interface ShopMapper extends BaseMapper<Shop> {
}
修改ShopServiceImpl.java
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Autowired
private ShopMapper shopMapper;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 1.从Redis中查询商铺缓存 cache:shop: + id
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
// 2.判断缓存是否命中
if (StrUtil.isNotBlank(shopJson)) {
// 2.1如果存在直接返回商铺信息
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
} else {
// 2.2如果不存在
// 2.2.1根据id查询数据库的商铺信息
LambdaQueryWrapper<Shop> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Shop::getId, id);
Shop shop = shopMapper.selectOne(wrapper);
// 2.2.2 如果不存在,直接返回错误
if (null == shop) {
return Result.fail("您要查询的店铺不存在!");
} else {
// 2.2.2 如果存在,先写入Redis然后返回
String jsonShop = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, jsonShop);
return Result.ok(shop);
}
}
修改RedisConstants.java
public static final Long CACHE_NULL_TTL = 2L;
public static final Long CACHE_SHOP_TTL = 30L;
public static final String CACHE_SHOP_KEY = "cache:shop:";
重启项目
点击101茶餐厅
查看Redis
再刷新一下界面,发现缓存生效
给店铺的类型做一个缓存
修改ShopTypeController.java
@RestController
@RequestMapping("/shop-type")
public class ShopTypeController {
@Resource
private IShopTypeService typeService;
@GetMapping("list")
public Result queryTypeList() {
return typeService.queryTypeList();
}
}
修改IShopTypeService.java
public interface IShopTypeService extends IService<ShopType> {
/**
* @param
* @return com.hmdp.dto.Result
* @description //查询店铺类别
* @date 2023/2/13 0:11
* @author wty
**/
Result queryTypeList();
}
修改ShopTypeServiceImpl.java
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryTypeList() {
// 1.从Redis中查询商铺种类
Long size = stringRedisTemplate.opsForList().size(RedisConstants.CACHE_SHOP_TYPE_KEY);
//特殊用法:通过stringRedisTemplate.opsForList().range(key, 0, -1)可以查询索引第一个到索引倒数第一个(即所有数据)
List<String> lists = stringRedisTemplate.opsForList().range(RedisConstants.CACHE_SHOP_TYPE_KEY, 0, size - 1);
// 2.查询商铺种类是否存在
if (!lists.isEmpty()) {
// 2.1商铺种类对象如果存在,则返回该对象
ArrayList<ShopType> typeList = new ArrayList<>();
for (String list : lists) {
typeList.add(JSONUtil.toBean(list, ShopType.class));
}
return Result.ok(typeList);
} else {
// 2.2商铺种类对象如果不存在,则查询数据库
List<ShopType> typeList = query().orderByAsc("sort").list();
if (typeList.isEmpty()) {
return Result.fail("店铺分类不存在!");
} else {
// 3.如果存在就保存到redis中
for (ShopType shopType : typeList) {
stringRedisTemplate.opsForList().rightPushAll(RedisConstants.CACHE_SHOP_TYPE_KEY, JSONUtil.toJsonStr(shopType));
}
// 4.返回店铺种类
return Result.ok(typeList);
}
}
}
}
重启应用,发现启动时间如下
查看redis中数据如下
再刷新界面,时间明显减少
4.3.1.3.缓存的成本
- 1.开发成本
- 2.运维成本
- 3.一致性问题
4.3.2.添加redis缓存
4.3.3.缓存更新策略
缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。
4.3.3.1.三种策略
(1).内存淘汰:Redis自带的内存淘汰机制
redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)。
(2).过期淘汰:利用expire命令给数据设置过期时间
当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存。
(3).主动更新:主动完成数据库与缓存的同时更新
我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题。
4.3.3.2.策略选择
4.3.3.2.1.低一致性需求
使用内存淘汰机制,例如店铺类型的查询缓存。
4.3.3.3.2.高一致性需求
主动更新为主,超时剔除作为兜底方案。例如店铺详情查询的缓存。
4.3.3.3.主动更新的方案
由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在,其后果是:
用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等;怎么解决呢?主动更新有如下几种方案:
-
方案一:Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案。
-
方案二:Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理。
-
方案三:Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致。
4.3.3.3.1.Cache Aside
1.缓存调用者在更新数据库的同时完成对完成的更新
- 一致性良好
- 实现难度一般
4.3.3.3.2.Read/Write Through
缓存与数据库集成为一个服务,服务保证两者的一致性,对外暴露API接口。调用者调用API,无需知道自己操作的是数据库还是缓存,不关心一致性。
- 一致性优秀
- 实现复杂
- 性能一般
4.3.3.3.3.Write Back
缓存调用者的CRUD都针对缓存完成。由独立线程异步的将缓存数据写到数据库,实现最终一致。
- 一致性差
- 性能好
- 实现复杂
4.3.3.4.Cache Aside的模式选择
综合考虑使用方案一Cache Aside,但是方案一调用者如何处理呢?这里有几个问题:
操作缓存和数据库时有三个问题需要考虑:
如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来
4.3.3.4.1.更新缓存还是删除缓存?
-
更新缓存会产生无效更新,并且存在较大的线程安全问题,每次更新数据库都更新缓存,无效写操作较多。
-
√删除缓存本质是延迟更新,没有无效更新,线程安全问题相对较低,更新数据库时让缓存失效,查询时再更新缓存。
4.3.3.4.2.先操作数据库还是缓存?
-
√(1).先更新数据,再删除缓存
-
在满足原子性的情况下,安全问题概率较低
-
(2).先删除缓存,再更新数据库
-
安全问题概率较高
4.3.3.4.3.如何确保数据库与缓存操作原子性?
4.3.3.4.3.1.单体系统
将缓存与数据库操作放在一个事务。
4.3.3.4.3.2.分布式系统
- 利用分布式事务(TCC)机制。
4.3.3.5.最佳实践
应该具体操作缓存还是操作数据库,我们应当是先操作数据库,再删除缓存,原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。
- 先操作缓存还是先操作数据库?
- 先删除缓存,再操作数据库
- 先操作数据库,再删除缓存
总结
4.3.3.5.1. 实现商铺和缓存与数据库双写一致
核心思路如下:
修改ShopController中的业务逻辑,满足下面的需求:
根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间。
根据id修改店铺时,先修改数据库,再删除缓存
修改重点代码1:修改ShopServiceImpl的queryById方法
设置redis缓存时添加过期时间
ShopServiceImpl.java
// 3.设置缓存的超时时间
stringRedisTemplate.expire(RedisConstants.CACHE_SHOP_KEY + id, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
修改重点代码2
代码分析:通过之前的淘汰,我们确定了采用删除策略,来解决双写问题,当我们修改了数据之后,然后把缓存中的数据进行删除,查询时发现缓存中没有数据,则会从mysql中加载最新的数据,从而避免数据库和缓存不一致的问题
修改ShopController.java
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
// 写入数据库
return shopService.updateShop(shop);
}
更新
Result updateShop(Shop shop);
更新ShopServiceImpl.java
@Override
@Transactional
public Result updateShop(Shop shop) {
// 1.更新mysql数据库
updateById(shop);
// 2.因为下面是根据id进行删除,所以必须要保证id存在
if (null != shop.getId()) {
// 3.删除redis缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + shop.getId());
}
// 4.返回
return Result.ok();
}
启动程序
看一下终端后台
查询一下redis数据库
刷新一下,看一下TTL,设置了有效期
借助postMan工具进行更新操作(因为更新是管理员操作,这里做一下模拟)
更改名字为新疆绝味烤肉
点击send后发现是405
原来是地址错误了,我们要用nginx的地址
更换地址
发现也是报错,原来是更新的时候,时间戳不能还是原来那个
于是在PostMan的json中去掉坐标和时间的属性
再点击send,说明更新成功了
我们看IDEA,也有更新的语句
数据库中更改了
再看Redis中没有数据了
再次刷浏览器,发现名字被修改了。
4.3.3.5.2. 查询数据时
1.先查询缓存
2.如果缓存命中,直接返回
3.如果缓存未命中,则查询数据库
4.将数据库数据写入缓存
5.返回结果
4.3.3.5.3. 修改数据库时
1.先修改数据库
2.然后删除缓存
3.确保两者的原子性
4.3.4.缓存穿透
4.3.4.1.产生原因(定义)
客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
4.3.4.2.缓存穿透问题的解决方案
4.3.4.2.1.⭐方案一:缓存空对象
- (1).思路
对于不存在的数据也在Redis建立缓存,值为空,并设置一个较短的TTL时间。
当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了。 - (2).优点
实现简单,维护方便。 - (3).缺点
额外的内存消耗。
短期的数据不一致问题。
4.3.4.2.2.方案二:布隆过滤
- (1).思路
利用布隆过滤算法,在请求进入Redis之前先判断是否存在,如果不存在则直接拒绝请求。
布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,
假设布隆过滤器判断这个数据不存在,则直接返回
这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突。
- (2).优点
内存占用少。 - (3).缺点
实现复杂
存在误判的可能性
4.3.4.2.2.其它
- (1).做好数据的基础格式校验
- (2).加强用户权限校验
- (3).做好热点参数的限流
核心思路如下:
在原来的逻辑中,我们如果发现这个数据在mysql中不存在,直接就返回404了,这样是会存在缓存穿透问题的
现在的逻辑中:如果这个数据不存在,我们不会返回404 ,还是会把这个数据写入到Redis中,并且将value设置为空,欧当再次发起查询时,我们如果发现命中之后,判断这个value是否是null,如果是null,则是之前写入的数据,证明是缓存穿透数据,如果不是,则直接返回数据。
这里讲一下查看源码帮助文档的方式
打开Terminal,与当前项目中pom.xml的统计目录输入以下指令
mvn dependency:sources
mvn dependency:resolve -Dclassifier=javadoc
如果只想单独下载某个依赖, 可以输入
mvn dependency:sources -DincludeArtifactIds=spring-rabbit
这时候鼠标悬浮到想看的代码,就会有注释
更改ShopServiceImpl.java
代码如下:ShopServiceImpl.java
@Override
public Result queryById(Long id) {
// 1.从Redis中查询商铺缓存 cache:shop: + id
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
// 2.判断缓存是否命中
if (StrUtil.isNotBlank(shopJson)) {
// 2.1如果存在直接返回商铺信息
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
} else {
//现在shopJson有2种可能,空字符串和null
if ("".equals(shopJson)) {
// 如果是空字符串,返回一个错误信息
return Result.fail("您要查询的店铺不存在!");
}
// 2.2如果为null,就进行下面的操作
// 2.2.1根据id查询数据库的商铺信息
LambdaQueryWrapper<Shop> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Shop::getId, id);
Shop shop = shopMapper.selectOne(wrapper);
// 2.2.2 如果不存在,直接返回错误
if (null == shop) {
// 将空值写入redis并且设置有效期(缓存穿透)
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("您要查询的店铺不存在!");
} else {
// 2.2.2 如果存在,先写入Redis然后返回
String jsonShop = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, jsonShop);
// 3.设置缓存的超时时间
stringRedisTemplate.expire(RedisConstants.CACHE_SHOP_KEY + id, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 返回
return Result.ok(shop);
}
}
}
修改RedisConstants.java
public static final Long CACHE_NULL_TTL = 2L;
重启应用
打开连接如下:
发现IDEA后台输出查询语句
清空后刷新页面,输入一个没有的id
看IDEA后台有sql输出
清空IDEA后台后再刷新当前失败的跳转页,发现没有sql输出了,说明是用的Redis缓存,我们看一下Redis的图形界面
看以下TTL
同理,我们给店铺种类,也可以加上相同的缓存null的逻辑,防止缓存击穿
修改ShopTypeServiceImpl.java
ShopTypeServiceImpl.java
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryTypeList() {
// 1.从Redis中查询商铺种类
Long size = stringRedisTemplate.opsForList().size(RedisConstants.CACHE_SHOP_TYPE_KEY);
//特殊用法:通过stringRedisTemplate.opsForList().range(key, 0, -1)可以查询索引第一个到索引倒数第一个(即所有数据)
List<String> lists = stringRedisTemplate.opsForList().range(RedisConstants.CACHE_SHOP_TYPE_KEY, 0, size - 1);
// 2.查询商铺种类是否存在
if (!lists.isEmpty()) {
// 2.1商铺种类对象如果存在,则返回该对象
ArrayList<ShopType> typeList = new ArrayList<>();
for (String list : lists) {
typeList.add(JSONUtil.toBean(list, ShopType.class));
}
return Result.ok(typeList);
} else {
// 现在lists有2种可能,空字符串和null
if ("".equals(lists)) {
// 如果是空字符串,返回一个错误信息
return Result.fail("您要查询的店铺种类不存在!");
}
// 2.2商铺种类对象如果不存在,则查询数据库
List<ShopType> typeList = query().orderByAsc("sort").list();
if (typeList.isEmpty()) {
// 设置空值防止缓存击穿
stringRedisTemplate.opsForList().rightPush(RedisConstants.CACHE_SHOP_TYPE_KEY, "");
return Result.fail("店铺分类不存在!");
} else {
// 3.如果存在就保存到redis中
for (ShopType shopType : typeList) {
stringRedisTemplate.opsForList().rightPush(RedisConstants.CACHE_SHOP_TYPE_KEY, JSONUtil.toJsonStr(shopType));
// 设置缓存时间
stringRedisTemplate.expire(RedisConstants.CACHE_SHOP_TYPE_KEY, RedisConstants.CACHE_SHOP_TYPE__TTL, TimeUnit.MINUTES);
}
// 4.返回店铺种类
return Result.ok(typeList);
}
}
}
总结:
缓存穿透产生的原因是什么?
- 用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力
缓存穿透的解决方案有哪些?
被动措施:
- 缓存null值
- 布隆过滤
主动措施:
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
4.3.5.缓存雪崩
4.3.5.1 产生原因(定义)
在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
4.3.5.2 缓存穿透的解决方案
- (1).给不同的Key的TTL添加随机值
- (2).利用Redis集群提高服务的可用性
- (3).给缓存业务添加降级限流策略
- (4).给业务添加多级缓存
4.3.6.缓存击穿(热点Key)
4.3.6.1.产生原因
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
4.3.6.1.1.热点Key
逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大。
(1).在某一时段被高并发访问
(2).缓存重建耗时较长
常见的缓存击穿问题解决方案有两种。
4.3.6.2.解决方案
4.3.6.2.1.方案一:互斥锁
1. 思路
给缓存重建过程加锁,确保重建过程只有一个线程执行,其它线程等待。
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。
2. 优点
实现简单
没有额外内存消耗
一致性好
3. 缺点
等待导致性能下降
有死锁风险
4.利用互斥锁解决缓存击穿问题
核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是 进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询
如果获取到了锁的线程,再去进行查询,查询后将数据写入redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿。
操作锁的代码:
核心思路就是利用redis的setnx方法来表示获取锁,该方法含义是redis中如果没有这个key,则插入成功,返回1;如果当前key被setnx赋值,其它指令再想赋值的时候会返回0,可以当作一个互斥锁。
那这个互斥锁,如何释放呢,我们模拟以下,只需要删除该属性即可。
特别注意如果程序错误,没有删除掉该锁,那么后续业务会一直处于等待的状态,这样是不好的,那么我们的解决方案就是给锁加上有效期。
在stringRedisTemplate中返回true, 如果有这个key则插入失败,则返回0,在stringRedisTemplate返回false,我们可以通过true,或者是false,来表示是否有线程成功插入key,成功插入的key的线程我们认为他就是获得到锁的线程。
修改ShopServiceImpl.java
先增加获取锁的逻辑
//尝试获取锁
private boolean tryLock(String key) {
// setnx = setIfAbsent
// 这里注意获取锁等待时间是10秒
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
// 这里注意flag是包装类Boolean,而方法的返回值是基本类型boolean
// 这里要拆箱后再返回结果
return BooleanUtil.isTrue(flag);
}
//释放锁
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
修改RedisConstants.java添加
public static final Long LOCK_SHOP_TTL = 10L;
修改ShopServiceImpl.java
@Override
public Result queryById(Long id) {
// 解决缓存穿透
//Shop shop = queryWithPassThough(id);
// 互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if (null == shop) {
Result.fail("您要查询的店铺不存在!");
}
return Result.ok(shop);
}
缓存击穿核心处理逻辑ShopServiceImpl.java
public Shop queryWithMutex(Long id) {
boolean flag;
Shop shop = null;
try {
do {
// 1.从Redis中查询商铺缓存 cache:shop: + id
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
// 2.判断缓存是否命中
if (StrUtil.isNotBlank(shopJson)) {
// 2.1如果存在直接返回商铺信息
shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
//现在shopJson有2种可能,空字符串和null
if ("".equals(shopJson)) {
// 如果是空字符串,返回一个错误信息
//return Result.fail("您要查询的店铺不存在!");
return null;
}
// 1.1解决缓存击穿:尝试获取锁
flag = tryLock(RedisConstants.LOCK_SHOP_KEY);
// 1.2判断互斥锁是否获取成功
if (!flag) {
// 1.2.1获取失败 就休眠一会儿,然后查询Redis 的shop
Thread.sleep(50);
}
} while (flag == true);
// 1.2.2获取成功 就直接进行下面的步骤
// 2.2如果为null,就进行下面的操作
// 2.2.1根据id查询数据库的商铺信息
LambdaQueryWrapper<Shop> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Shop::getId, id);
shop = shopMapper.selectOne(wrapper);
// TODO 模拟Redis击穿后,重建Redis的时间,可以去掉
Thread.sleep(200);
// 2.2.2 如果不存在,直接返回错误
if (null == shop) {
// 将空值写入redis并且设置有效期(缓存穿透)
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
//return Result.fail("您要查询的店铺不存在!");
return null;
}
// 2.2.2 如果存在,先写入Redis然后返回
String jsonShop = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, jsonShop);
// 3.设置缓存的超时时间
stringRedisTemplate.expire(RedisConstants.CACHE_SHOP_KEY + id, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 4.释放锁
unLock(RedisConstants.LOCK_SHOP_KEY);
}
// 5.返回
return shop;
}
重启程序
打开压力测试工具JMeter
定义1000个线程
循环次数5秒
Http配置如下
点击发起请求
查看结果树
查看汇总报告
4.3.6.2.2.方案二:逻辑过期
1.思路
- 热点key缓存永不过期,而是设置一个逻辑过期时间,查询到数据时通过对逻辑过期时间判断,来决定是否需要重建缓存。
- 重建缓存也通过互斥锁保证单线程执行。
- 重建缓存利用独立线程异步执行。
- 其它线程无需等待,直接查询到的旧数据即可。
方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。
我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。
2.优点
线程无需等待,性能较好。
3.缺点
不保证一致性。
有额外内存消耗。
实现复杂。
4.利用逻辑过期解决缓存击穿问题
需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题
思路分析:当用户开始查询redis时,判断是否命中,如果没有命中则直接返回空数据,不查询数据库,而一旦命中后,将value取出,判断value中的过期时间是否满足,如果没有过期,则直接返回redis中的数据,如果过期,则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁。
如果封装数据:因为现在redis中存储的数据的value需要带上过期时间,此时要么你去修改原来的实体类,要么你新建一个实体类,我们采用第二个方案,这个方案,对原来代码没有侵入性。
步骤一、
新增类RedisData .java添加逻辑过期时间
RedisData.java代码如下
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RedisData {
private LocalDateTime expireTime;
/**
* data可以理解存储的是Shop对象
*/
private Object data;
}
步骤二、
在ShopServiceImpl 新增此方法,利用单元测试进行缓存预热
修改ShopServiceImpl.java
增加一个方法,用来保存和设置有效期
public void saveShop2Redis(Long id, Long seconds) {
// 1.查询shop数据
LambdaQueryWrapper<Shop> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Shop::getId, id);
// select * from tb_shop where id = ?
Shop shop = shopMapper.selectOne(wrapper);
// 2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(seconds));
// 3.写入Redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
在单元测试类中测试
代码如下
CommentApplicationTests.java
@SpringBootTest
class CommentApplicationTests {
@Resource
private ShopServiceImpl shopService;
@Test
public void testSave() {
shopService.saveShop2Redis(1L, 10L);
}
}
点击测试
查看后台
查看Redis图形界面,有效期存进去了
步骤三:正式代码
修改ShopServiceImpl,逻辑过期核心代码
public Shop queryWithLogicalExpire(Long id) {
// 1.从Redis中查询商铺缓存 cache:shop: + id
String redisData_Str = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
// 2.判断缓存是否命中
if (StrUtil.isBlank(redisData_Str)) {
// 2.1如果未命中Redis缓存信息,则直接返回空
return null;
}
// 2.2如果命中了Redis缓存信息,判断缓存是否过期
RedisData redisData = JSONUtil.toBean(redisData_Str, RedisData.class);
Object o = redisData.getData();
JSONObject data = (JSONObject) o;
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 3.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 3.1未过期,直接返回店铺信息
return shop;
}
// 3.2已过期,需要缓存重建
// 4 获取互斥锁
boolean flag = tryLock(RedisConstants.LOCK_SHOP_KEY + id);
// 5. 判断是否获取锁成功
if (flag) {
// 5.1 获取到锁 首先再判断是否过期,过期
// 然后就开启线程 根据id查询数据库 将商铺数据写入Redis并设置逻辑过期时间,释放互斥锁
ExecutorService executorService = newFixedThreadPool(10);
/*executorService.submit(new Runnable() {
@Override
public void run() {
try {
// 重建缓存
// TODO saveShop2Redis(id, RedisConstants.CACHE_SHOP_TYPE__TTL);
saveShop2Redis(id, 20L);
} catch (Exception e) {
e.printStackTrace();
} finally {
// 释放锁
unLock(RedisConstants.LOCK_SHOP_KEY + id);
}
}
});*/
// 函数式接口
executorService.submit(() -> {
try {
//再次检查缓存有没有过期,防止在高并发环境下缓存多次重建
LocalDateTime time = JSONUtil.toBean(
stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id),
RedisData.class
).getExpireTime();
// 3.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 3.1未过期,直接返回店铺信息
// run方法是无参的,所以这里没有返回值
return;
}
// 重建缓存
// TODO saveShop2Redis(id, RedisConstants.CACHE_SHOP_TYPE__TTL);
saveShop2Redis(id, 20L);
} catch (Exception e) {
e.printStackTrace();
} finally {
// 释放锁
unLock(RedisConstants.LOCK_SHOP_KEY + id);
}
});
}
// 5.2未获取到锁还是获取到锁都要 返回商铺信息同3.1
return shop;
}
修改ShopServiceImpl.java,添加线程池
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
修改RedisConstants.java
添加锁的key和有效期10秒
public static final String LOCK_SHOP_KEY = "lock:shop:";
public static final Long LOCK_SHOP_TTL = 10L;
为了测试一致性
先更改数据库信息
UPDATE tb_shop SET NAME = '102茶餐厅' WHERE id = 1;
此时Redis中数据为
重启应用
继续使用JMeter
配置如下
运行后发现,查询结果是旧的。
睡眠了50ms后更新成最新的了
查看Redis,已经是最新的了
后台只有一次sql的查询
验证了:不保证一致性
4.3.6.2.3.方案对比
**互斥锁方案:**由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响。
逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦。
4.3.7.Redis缓存工具封装
基于StringRedisTemplate封装一个缓存工具类,满足下列需求:
- 方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间。
- 方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓。
存击穿问题
- 方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题。
- 方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题。
将逻辑进行封装
新建工具类CacheClient.java
CacheClient.java代码如下:
@Component
@Slf4j
public class CacheClient {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* @param
* @return void
* @description //将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
* @param: key
* @param: value
* @param: time
* @param: unit
* @date 2023/2/14 9:17
* @author wty
**/
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
/**
* @param
* @return void
* @description //将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
* @param: key
* @param: value
* @param: time
* @param: unit
* @date 2023/2/14 9:20
* @author wty
**/
public void setWithLogicExpire(String key, Object value, Long time, TimeUnit unit) {
RedisData redisData = new RedisData();
redisData.setData(value);
// 把时间转换成秒
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData), time, unit);
}
/**
* @param
* @return T 泛型(实体类的类型)
* @description // 根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
* @param: keyPrefix 设置一个常量
* @param: id 泛型(可以为任意类型)
* @param: type T的类型
* @param: function
* @param: time 有效期
* @param: unit 有效期的时间单位:分钟/小时/秒
* @date 2023/2/14 9:48
* @author wty
**/
public <T, ID> T queryWithPassThough(String keyPrefix, ID id, Class<T> type, Function<ID, T> function, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从Redis中查询实体对象的缓存,例如商铺就是 cache:shop: + id
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断缓存是否命中
if (StrUtil.isNotBlank(json)) {
// 2.1如果存在直接返回对象信息
T t = JSONUtil.toBean(json, type);
return t;
} else {
//现在json有2种可能,空字符串和null
if ("".equals(json)) {
// 如果是空字符串,返回一个空,外层调用的时候抛出错误信息
//return Result.fail("您要查询的店铺不存在!");
return null;
}
// 2.2如果为null,就进行下面的操作
// 2.2.1根据id查询数据库的商铺信息,这里用函数式接口实现
/* LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
// select * from tb_shop where id = ?
wrapper.eq(Shop::getId, id);
Shop shop = shopMapper.selectOne(wrapper);*/
T t = function.apply(id);
// 2.2.2 如果不存在,直接返回错误
if (null == t) {
// 将空值写入redis并且设置有效期(缓存穿透) 2分钟
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
// 上层返回以下信息
//return Result.fail("您要查询的店铺不存在!");
return null;
} else {
// 2.2.2 如果存在,先写入Redis然后返回,并且设置缓存的超时时间,使用上面的方法即可
set(key, t, time, unit);
// 返回
return t;
}
}
}
}
在ShopServiceImpl 中更改
@Resource
private CacheClient cacheClient;
@Override
public Result queryById(Long id) {
// 解决缓存穿透
//Shop shop = queryWithPassThough(id);
// 解决缓存穿透(使用模板)
//cacheClient.queryWithPassThough(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, id_key -> getById(id_key), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 解决缓存穿透(使用模板) 方法引用
Shop shop = cacheClient
.queryWithPassThough(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 互斥锁解决缓存击穿
//Shop shop = queryWithMutex(id);
// 逻辑过期解决缓存击穿
//Shop shop = queryWithLogicalExpire(id);
if (null == shop) {
Result.fail("您要查询的店铺不存在!");
}
return Result.ok(shop);
}
重启应用,还用shop0作为测试用例
多次刷新,发现只有第一次用了sql查询数据库,剩下都是redis
发现空值已经存入redis
下面封装逻辑过期解决缓击击穿
修改CacheClient.java
添加代码
@Component
@Slf4j
public class CacheClient {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* @param
* @return void
* @description //将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
* @param: key
* @param: value
* @param: time
* @param: unit
* @date 2023/2/14 9:20
* @author wty
**/
public void setWithLogicExpire(String key, Object value, Long time, TimeUnit unit) {
RedisData redisData = new RedisData();
redisData.setData(value);
// 把时间转换成秒
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData), time, unit);
}
/**
* @param
* @return T
* @description //逻辑过期解决缓存击穿
* @param: keyPrefix 常量 例如shop是 CACHE_SHOP_KEY = "cache:shop:"
* @param: id 泛型 可以是任意类型
* @param: type 泛型
* @param: function 函数式接口,ID是参数 T是返回值类型
* @param: time 有效期
* @param: unit 有效期类型
* @date 2023/2/14 12:14
* @author wty
**/
public <T, ID> T queryWithLogicalExpire(String keyPrefix, ID id, Class<T> type, Function<ID, T> function, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从Redis中查询实体类缓存 例如shop是 cache:shop: + id
String redisData_Str = stringRedisTemplate.opsForValue().get(key);
// 2.判断缓存是否命中
if (StrUtil.isBlank(redisData_Str)) {
// 2.1如果未命中Redis缓存信息,则直接返回空
return null;
}
// 2.2如果命中了Redis缓存信息,判断缓存是否过期
RedisData redisData = JSONUtil.toBean(redisData_Str, RedisData.class);
Object o = redisData.getData();
JSONObject data = (JSONObject) o;
T t = JSONUtil.toBean(data, type);
LocalDateTime expireTime = redisData.getExpireTime();
// 3.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 3.1未过期,直接返回实体类信息
return t;
}
// 3.2已过期,需要缓存重建
// 4 获取互斥锁
boolean flag = tryLock(RedisConstants.LOCK_SHOP_KEY + id, RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
// 5. 判断是否获取锁成功
if (flag) {
// 5.1 获取到锁 首先再判断是否过期,过期
// 然后就开启线程 根据id查询数据库 将实体类数据写入Redis并设置逻辑过期时间,释放互斥锁
ExecutorService executorService = newFixedThreadPool(10);
/*executorService.submit(new Runnable() {
@Override
public void run() {
try {
// 重建缓存
// TODO saveShop2Redis(id, RedisConstants.CACHE_SHOP_TYPE__TTL);
saveShop2Redis(id, 20L);
} catch (Exception e) {
e.printStackTrace();
} finally {
// 释放锁
unLock(RedisConstants.LOCK_SHOP_KEY + id);
}
}
});*/
// 函数式接口
executorService.submit(() -> {
try {
//再次检查缓存有没有过期,防止在高并发环境下缓存多次重建
LocalDateTime expireTime_Thread = JSONUtil.toBean(
stringRedisTemplate.opsForValue().get(key),
RedisData.class
).getExpireTime();
// 3.判断是否过期
if (expireTime_Thread.isAfter(LocalDateTime.now())) {
// 3.1未过期,直接返回实体类信息
// run方法是无参的,所以这里没有返回值
return;
}
// 4.重建缓存
// 4.1查询数据库
T t_thread = function.apply(id);
// 4.2写入Redis
setWithLogicExpire(key, t_thread, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unLock(RedisConstants.LOCK_SHOP_KEY + id);
}
});
}
// 5.2未获取到锁还是获取到锁都要 返回商铺信息同3.1
return t;
}
/**
* @param
* @return java.util.concurrent.ExecutorService
* @description //创建固定线程数的线程池
* @param: nThreads
* @date 2023/2/14 1:19
* @author wty
**/
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
/**
* @param
* @return boolean
* @description //尝试获取锁
* @param: key
* @param: TTL 有效期,例如shop是RedisConstants.LOCK_SHOP_TTL
* @param: unit 有效期的单位:比如小时/分钟/秒
* @date 2023/2/13 16:37
* @author wty
**/
private boolean tryLock(String key, Long TTL, TimeUnit unit) {
// setnx = setIfAbsent
// 这里注意获取锁等待时间是10秒
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", TTL, unit);
// 这里注意flag是包装类Boolean,而方法的返回值是基本类型boolean
// 这里要拆箱后再返回结果
return BooleanUtil.isTrue(flag);
}
/**
* @param
* @return void
* @description //释放锁
* @param: key
* @date 2023/2/13 16:45
* @author wty
**/
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
}
修改ShopServiceImpl.java
@Override
public Result queryById(Long id) {
// 解决缓存穿透
//Shop shop = queryWithPassThough(id);
// 解决缓存穿透(使用模板)
//cacheClient.queryWithPassThough(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, id_key -> getById(id_key), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 解决缓存穿透(使用模板) 方法引用
//Shop shop = cacheClient
//.queryWithPassThough(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 互斥锁解决缓存击穿
//Shop shop = queryWithMutex(id);
// 逻辑过期解决缓存击穿
Shop shop = cacheClient
.queryWithLogicalExpire(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.SECONDS);
if (null == shop) {
return Result.fail("您要查询的店铺不存在!");
}
return Result.ok(shop);
}
修改CommentApplicationTests.java
@SpringBootTest
class CommentApplicationTests {
@Resource
private CacheClient cacheClient;
@Resource
private ShopServiceImpl shopService;
@Test
public void testLogicExpire() {
Shop shop = shopService.getById(1L);
cacheClient.setWithLogicExpire(RedisConstants.CACHE_SHOP_KEY, shop, 10L, TimeUnit.SECONDS);
}
}
先跑测试类CommentApplicationTests,让Reds中有数据
点击运行测试类后,看到Redis中有数据
更新数据库
UPDATE tb_shop SET NAME = '104茶餐厅' WHERE id = 1;
然后运行JMeter,请求正常
之后我们修改一下利用互斥锁解决缓存击穿问题的代码变成工具类
修改CacheClient.java
public <T, ID> T queryWithMutex(String keyPrefix, ID id, Class<T> type, Function<ID, T> function, Long time, TimeUnit unit) {
String key = keyPrefix + id;
boolean flag;
T t = null;
try {
do {
// 1.从Redis中查询实体类的缓存 例如cache:shop: + id
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断缓存是否命中
if (StrUtil.isNotBlank(json)) {
// 2.1如果存在直接返回实体类信息
t = JSONUtil.toBean(json, type);
return t;
}
//现在json有2种可能,空字符串和null
if ("".equals(json)) {
// 如果是空字符串,返回一个错误信息
//return Result.fail("您要查询的店铺不存在!");
return null;
}
// 1.1解决缓存击穿:尝试获取锁
flag = tryLock(RedisConstants.LOCK_SHOP_KEY + id, RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
// 1.2判断互斥锁是否获取成功
if (!flag) {
// 1.2.1获取失败 就休眠一会儿,然后查询Redis 的实体类
Thread.sleep(50);
}
} while (flag == true);
// 1.2.2获取成功 就直接进行下面的步骤
// 2.2如果为null,就进行下面的操作
// 2.2.1根据id查询数据库的实体类信息
/*LambdaQueryWrapper<Shop> wrapper = new LambdaQueryWrapper<>();
// select * from tb_shop where id = ?
wrapper.eq(Shop::getId, id);
shop = shopMapper.selectOne(wrapper);*/
T t_object = function.apply(id);
// TODO 模拟Redis击穿后,重建Redis的时间,可以去掉
//Thread.sleep(200);
// 2.2.2 如果不存在,直接返回错误
if (null == t_object) {
// 将空值写入redis并且设置有效期(缓存穿透)
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
//return Result.fail("您要查询的店铺不存在!");
return null;
}
// 2.2.2 如果存在,先写入Redis然后返回
String jsont = JSONUtil.toJsonStr(t_object);
stringRedisTemplate.opsForValue().set(key, jsont);
// 3.设置缓存的超时时间
stringRedisTemplate.expire(key, time, unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 4.释放锁
unLock(RedisConstants.LOCK_SHOP_KEY + id);
}
// 5.返回
return t;
}
修改ShopServiceImpl.java
@Override
public Result queryById(Long id) {
// 解决缓存穿透
//Shop shop = queryWithPassThough(id);
// 解决缓存穿透(使用模板)
//cacheClient.queryWithPassThough(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, id_key -> getById(id_key), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 解决缓存穿透(使用模板) 方法引用
/*Shop shop = cacheClient
.queryWithPassThough(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
*/
// 互斥锁解决缓存击穿
//Shop shop = queryWithMutex(id);
// 互斥锁解决缓存击穿
Shop shop = cacheClient.queryWithMutex(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 逻辑过期解决缓存击穿(使用模板)
/*Shop shop = cacheClient
.queryWithLogicalExpire(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
*/
if (null == shop) {
return Result.fail("您要查询的店铺不存在!");
}
return Result.ok(shop);
}
先用测试类给redis缓存中添加一条店铺信息,然后重复登录该id对应的网址,发现redis只会有一次sql的查询即可。
最后为了保证店铺信息的正常加载切换到
ShopServiceImpl.java
@Override
public Result queryById(Long id) {
// 解决缓存穿透
//Shop shop = queryWithPassThough(id);
// 解决缓存穿透(使用模板)
//cacheClient.queryWithPassThough(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, id_key -> getById(id_key), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 解决缓存穿透(使用模板) 方法引用
Shop shop = cacheClient
.queryWithPassThough(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 互斥锁解决缓存击穿
//Shop shop = queryWithMutex(id);
// 互斥锁解决缓存击穿
//Shop shop = cacheClient.queryWithMutex(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 逻辑过期解决缓存击穿(使用模板)
/*Shop shop = cacheClient
.queryWithLogicalExpire(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
*/
if (null == shop) {
return Result.fail("您要查询的店铺不存在!");
}
return Result.ok(shop);
}