目录
3.7.2 基于互斥锁解决缓存击穿案例——以请求店铺信息为例
3.7.3 基于逻辑过期解决缓存击穿案例——以请求店铺信息为例
3.7.4【故障排查】关于逻辑过期时间策略无法查询店铺信息的现象
三、商户查询缓存系列功能实现
3.1 缓存的理解
我们的程序如果想要用户有一个比较良好的使用体验,在请求数据速度上必然要有所突出。因此我们一般会在项目中运用缓存策略,从而提高我们程序的响应速度。与此同时,在添加缓存策略之后,数据一致性、缓存穿透、雪崩、热Key、维护成本提高等相继出现。如何平衡好这种关系,成为了我们学习Redis的重要所在。
3.2 查询商户店铺---添加Redis缓存
3.2.1 添加缓存逻辑理解
由于Redis访问快的优点,我们在客户端与数据库之间添加一层 Redis数据层。用户发送的请求首先打到Redis进行查询,
- 如果在Redis上查询命中,则直接返回给用户,从而减轻了底层数据库服务器数据压力。
- 如果没能命中,则请求打到数据库进行查询,查询未命中则说明此次请求为 错误请求
- 数据库命中的话,就将数据写入Redis中 ,接着返回给用户。
3.2.2 添加缓存逻辑实现
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}
/**
* 根据id查询店铺(添加Redis缓存版)
* @param id
* @return
*/
@Override
public Result queryById(Long id) {
//1. 根据id到Redis中查询用户信息
//2. Redis命中 ----------------> 返回商户信息 -------> 结束
//3. Redis未命中 查询数据库
//4. 查询数据库未命中 ----------------> 返回错误信息 ------> 结束
//5. 数据库命中 ---------------> 将数据写入Redis ------> 返回商户信息 -------> 结束
String stopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
if(StrUtil.isNotBlank(stopJson)){
// 存在直接返回
Shop shop = JSONUtil.toBean(stopJson, Shop.class); //将JSON字符串转换为对象
return Result.ok(shop);
}
// query().eq("shop_id",id);
// 查询数据库
Shop shop = getById(id);
if(shop == null){
Result.fail("店铺不存在");
}
stringRedisTemplate.opsForValue().set(
RedisConstants.CACHE_SHOP_KEY + id,
JSONUtil.toJsonStr(shop),
RedisConstants.CACHE_SHOP_TTL,
TimeUnit.MINUTES);
return Result.ok(shop);
}
3.2.3 缓存功能效果展示
第一次查询商户店铺,未缓存Redis,后台查询数据库
第二次再次查询商户店铺,已缓存Redis,后台没有查询店铺的数据库语句
3.3(课后作业)查询商户分类列表---添加Redis缓存
3.3.1 使用String存储类型实现
/**
* 查询店铺类型 (添加Redis版)
* @return
*/
@GetMapping("list")
public Result queryTypeList() {
// List<ShopType> typeList = typeService
// .query().orderByAsc("sort").list();
return typeService.queryTypeList();
}
/**
* 查询店铺类型列表(添加Redis版)
* String 实现版
* @return
*/
@Override
public Result queryTypeList() {
// 1. 在Redis中查询店铺类型列表
// 2. Redis命中 ------> 直接返回店铺类型数据 -------> 结束
// 3. Redis未命中, 查询数据库
// 4. 数据库未命中 -------> 返回报错信息 --------> 结束
// 5. 数据库命中,-------> 将数据存入Redis --------> 返回店铺类型数据 --------> 结束
String Key = RedisConstants.CACHE_SHOP_TYPE_KEY;
String shopTypeJSON = stringRedisTemplate.opsForValue().get(Key);
// 将字符串转换为对象
List<ShopType> shopTypeList = null;
if(StrUtil.isNotBlank(shopTypeJSON)){
shopTypeList = JSONUtil.toList(shopTypeJSON, ShopType.class);
return Result.ok(shopTypeList); // 返回店铺类型数据
}
// 查询数据库
shopTypeList = query().orderByAsc("sort").list();
// 将对象转换为字符串
shopTypeJSON = JSONUtil.toJsonStr(shopTypeList);
// 将数据存入Redis
stringRedisTemplate.opsForValue().set(Key, shopTypeJSON);
return Result.ok(shopTypeList);
}
3.3.2 使用List存储类型实现
/**
* 查询店铺类型列表(添加Redis版)
* List 实现版
* @return
*/
@Override
public Result queryTypeList() {
// 1. 在Redis中查询店铺类型列表
// 2. Redis命中 ------> 直接返回店铺类型数据 -------> 结束
// 3. Redis未命中, 查询数据库
// 4. 数据库未命中 -------> 返回报错信息 --------> 结束
// 5. 数据库命中,-------> 将数据存入Redis --------> 返回店铺类型数据 --------> 结束
String Key = RedisConstants.CACHE_SHOP_TYPE_KEY;
// 获取列表中所有元素(字符串格式)
List<String> shopTypeJSON = stringRedisTemplate.opsForList().range(Key, 0, -1); // 获取列表中所有元素
if(shopTypeJSON != null && !shopTypeJSON.isEmpty()){
// Redis中存在数据,需要将所有的Value转换成 ShopType对象
// 将字符串转换为对象
List<ShopType> shopTypeList = new ArrayList<>();
for(String str : shopTypeJSON){
shopTypeList.add(JSONUtil.toBean(str, ShopType.class));
}
return Result.ok(shopTypeList); // 返回店铺类型数据
}
// 查询数据库
List<ShopType> shopTypeList = query().orderByAsc("sort").list();
if(shopTypeList == null || shopTypeList.isEmpty()){
return Result.fail("店铺类型不存在");
}
// 将对象转换为字符串(每一项都是)
for (ShopType shopType : shopTypeList) {
stringRedisTemplate.opsForList().rightPushAll(Key, JSONUtil.toJsonStr(shopType));
}
// 设置过期时间
stringRedisTemplate.expire(Key, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shopTypeList);
}
3.3.3 缓存功能效果展示
3.4 (知识点)缓存更新策略最佳实践
3.4.1 数据一致性问题
前面引入Redis时已经讲过了,缓存的使用可以降低后端负载、降低响应时间、提高读写效率。但也会带来系列问题,数据一致性便是其中最为常见的问题之一。
数据一致性问题的根本原因就是缓存和数据库中的数据不同步,因此,我们需要提出一套良好的缓存更新方案,尽可能的使得缓存和数据库中的数据进行及时同步。
3.4.2 缓存更新策略的最佳实践
- 内存淘汰机制【全自动触发】
所谓内存淘汰机制,就是当Redis出现内存不足的情况下,会选择性的剔除一部分数据。这种全自动触发的淘汰机制不受控制,且触发概率不高,因此只适用于一些低一致性需求的业务
- 超时剔除机制【半自动触发】
超时剔除机制通常被我们当作一个保底策略,就是习惯性的给缓存数据设置一定的过期时间TTL。到期后,由Redis自动进行剔除,从而更好的利用缓存空间
- 主动更新机制【手动触发】
手动编码实现缓存更新,在修改数据库的同时更新缓存,实现双写。
特别灵活,可控性好
- 读写穿透(Read / Write Through Pattern)
缓存与数据库整合成为一个服务,由服务来维护统一性。调用者调用该服务,无需关心缓存的一致性问题
问题:想实现困难,市面上也很难找到这种服务
- 写回方案 (Write Behind Caching Pattern)
调用者只操作缓存,由其他一个独立线程异步的将缓存数据持久化到数据库,从而实现最终保持一致
问题:异步任务复杂,一致性难以保证(异步非同步),宕机及丢失
- 双写方案 (Cache Aside Pattern)
由缓存的调用者,在更新数据库的同时去更新缓存
- 更新缓存模式
无效写操作较多,不推荐使用:
例如对于一个数据,我们要进行100次的修改更新。如果每次都去更新,实际上只有最后一次更新是有效操作。所有对于查询少的情况下,更新缓存模式性能反而不太好。
- 删除缓存模式
无效写操作相对较少,推荐使用
- 先操作缓存
先操作缓存再操作数据库在多线程环境下出错情况说明:
【线程1 做更新缓存操作 线程2 做查询操作】
由于先操作缓存,线程1执行删除缓存操作
如果 从删除缓存 到 更新完成这一个过程复杂 耗时特别长
同一时间,线程2执行查询操作,由于缓存未命中,查询数据库
此时还未更新完成,查询到旧的值,并把旧的值写回了缓存
这样一来,缓存数据和数据库数据产生了不一致
这个概率比较大:
线程1先删掉缓存,然后执行一个耗时长的更新动作
而线程2 则是进行查询缓存 和 写入缓存的动作,耗时短
- 先操作数据库
先操作数据库再删除缓存出错情况说明:
【线程1 做查询操作 线程2 做更新操作】
假设恰好缓存失效了
线程1来查,刚好处于未命中状态,于是查询数据库【旧值】
并准备把数据库数据写入缓存
恰好线程2 执行更新数据库操作,将数据库的值更新成【新值】
最后线程1终于开始执行写入缓存操作了,但是写的是【旧值】
这个概率微乎其微:
两个恰好,其次在线程1查询后 写缓存的这个微妙级别的操作之内,
必须恰好有另外的线程完成了 更新数据库这一耗时长的操作。
- 先操作缓存
- 更新缓存模式
- 读写穿透(Read / Write Through Pattern)
总结:最佳的缓存更新策略:
使用超时剔除机制作为保底策略,采用主动更新机制中的双写模式,选择删除缓存模式,先操作数据库后操作缓存。
3.4.3 实现查询商户店铺详细的缓存与数据库双写一致
超时剔除机制保底:
// 在原先的 queryById 方法中 添加过期时间
// 将数据写入Redis + 设置过期时间
stringRedisTemplate.opsForValue().set(
RedisConstants.CACHE_SHOP_KEY + id,
JSONUtil.toJsonStr(shop),
RedisConstants.CACHE_SHOP_TTL,
TimeUnit.MINUTES);
更新数据时,主动删除缓存:
注意添加事务,确保更新数据库和删除缓存的原子性
/**
* 更新店铺信息
* @param shop
* @return
*/
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if(id == null){
return Result.fail("店铺id不能为空");
}
// 1. 更新数据库
updateById(shop);
// 2. 删除缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + shop.getId());
// 3. 返回结果
return Result.ok();
}
3.5 (知识点)缓存穿透与解决策略
3.5.1 什么是缓存穿透?
缓存穿透是指客户端请求的数据在缓存中和在数据库中都不存在,这样缓存永远不会生效,这些请求都会直接被达到数据库上。
3.5.2 缓存穿透的危害?
若是有大量这种无效、恶意请求直接打到数据库。会极大地增加数据库本身的压力,很可能造成数据库宕机。就像DDOS攻击,导致正常的客户端请求无法得到及时的响应。
3.5.3 缓存穿透的解决措施
以下介绍的两种方式都是被动的解决缓存穿透方案。除此之外,我们还可以采用主动的方案预防缓存穿透,比如:增强id的复杂度避免被猜测id规律、做好数据的基础格式校验、加强用户权限校验
3.5.3.1 缓存空对象
优点: | 缺点: |
实现简单,维护方便 | 1. 额外的内存消耗 2. 可能造成短期的不一致问题 |
3.5.3.2 布隆过滤器
当一个元素加入布隆过滤器中的时候,会进行如下操作:
- 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。
- 根据得到的哈希值,在位数组中把对应下标的值置为 1。
当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行如下操作:
- 对给定元素再次进行相同的哈希计算;
- 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。
优点: | 缺点: |
内存占用少,没有多余的Key | 1. 实现复杂 2. 存在误判可能 |
3.5.3.3 解决缓存穿透——以请求店铺信息为例
缓存空对象实现代码
/**
* 根据id查询店铺(添加Redis缓存版 + 解决缓存穿透[缓存空对象])
* @param id
* @return
*/
@Override
public Result queryById(Long id) {
//1. 根据id到Redis中查询用户信息
//2. Redis命中 ----------------> 返回商户信息 -------> 结束
//2.改:Redis命中 ----------------> 判断是否为空对象-------> 结束
// | 非空
// ------> 返回商户信息 -------> 结束
//3. Redis未命中 查询数据库
//4. 查询数据库未命中 ----------------> 返回错误信息 ------> 结束
//4.改: 查询数据库未命中----------> 将空对象写入Redis ------> 结束
//5. 数据库命中 ---------------> 将数据写入Redis ------> 返回商户信息 -------> 结束
String stopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
// 判断Redis中是否存在数据
if(StrUtil.isNotBlank(stopJson)){
// 存在直接返回
Shop shop = JSONUtil.toBean(stopJson, Shop.class); //将JSON字符串转换为对象
return Result.ok(shop);
}
// 判断Redis中是否存在空对象
if (RedisConstants.CACHE_PENETRATION_NULL_VALUE.equals(stopJson)){
return Result.fail("店铺信息不存在");
}
// query().eq("shop_id",id);
// 查询数据库
Shop shop = getById(id);
// 查询数据库不存在
if(shop == null){
// 将空对象写入Redis
stringRedisTemplate.opsForValue().set(
RedisConstants.CACHE_SHOP_KEY + id,
RedisConstants.CACHE_PENETRATION_NULL_VALUE,
RedisConstants.CACHE_NULL_TTL,
TimeUnit.MINUTES
);
return Result.fail("店铺不存在");
}
// 将数据写入Redis + 设置过期时间
stringRedisTemplate.opsForValue().set(
RedisConstants.CACHE_SHOP_KEY + id,
JSONUtil.toJsonStr(shop),
RedisConstants.CACHE_SHOP_TTL,
TimeUnit.MINUTES
);
return Result.ok(shop);
}
测试结果
3.6 (知识点)缓存雪崩与解决策略
缓存雪崩是指在同一时间段大量缓存key同时失效或者Redis服务宕机,导致大量请求打到数据库,带来巨大压力
解决策略:
- 事前:Redis集群部署,主从 + 哨兵机制, 避免全盘崩溃宕机
- 事中:本地缓存 + 限流和降级,避免数据库压力过大造成宕机
- 事后:Redis持久化,机器重启后可以自动从磁盘加载数据,恢复缓存数据
3.7 (知识点)缓存击穿与解决策略
缓存击穿问题又叫热点Key问题,是指一个高访问量并且缓存重建业务较为复杂的key突然失效了,导致无数请求直接打到数据库,造成巨大压力的情况
如图,在线程1进行缓存重建的过程,由于重建业务耗时较长,在重建业务期间,有其他大量线程执行查询操作,由于缓存未命中,均尝试执行查询数据库重建缓存的重复操作。
3.7.1 缓存击穿的解决方案
3.7.1.1 基于互斥锁的解决方案
为了防止在缓存重建的过程中,其余线程也去进行查询数据库重建缓存。互斥锁策略则是采用给第一个尝试重建缓存的线程添加互斥锁,其余的线程则在不断地进行 “尝试获取锁 --- 休眠等待”。从而减少了查询数据库造成的压力问题。
3.7.1.2 基于逻辑过期的解决方案
首先,我们会给这种高访问量业务的Key设置一个逻辑过期时间(到期不会被Redis自动删除,以确保缓存必定命中)。
然后,线程每次访问时,会先对当前时间与逻辑过期时间进行判断,过期则获取一个互斥锁,来表明自己是第一个发现需要缓存重建的线程。
接着,该线程就会开启一个独立线程,专门用于执行缓存重建的任务。自己则是先返回旧的数据使用。
在缓存重建线程执行完成之前,互斥锁不会释放,此时其他线程在访问的过程中获取锁失败,则直接返回旧数据。
最后,当缓存重建线程执行完毕后,释放互斥锁。
3.7.1.3 总结比较
互斥锁追求的是对一致性要求较高的业务,但是代价是多线程等待,性能受到了一定的影响
逻辑过期追求的是高性能的服务,但是却牺牲了一致性。
3.7.2 基于互斥锁解决缓存击穿案例——以请求店铺信息为例
3.7.2.1 互斥锁解决缓存击穿思路流程
3.7.2.2 代码实现——抽取方法
在实现功能的过程中,我们人为的在进行缓存重建的过程中添加了Thread休眠,这样使得整个缓存重建的时长变长,模拟复杂的重建业务,从而更容易能展示出互斥锁在高并发情况下减缓数据库压力的作用
模拟重建的延迟情况 : Thread.sleep(100);
public Result queryById(Long id) {
//1. 根据id到Redis中查询用户信息
//2. Redis命中 ----------------> 返回商户信息 -------> 结束
//2.改:Redis命中 ----------------> 判断是否为空对象-------> 结束
// | 非空
// ------> 返回商户信息 -------> 结束
//3. Redis未命中 -----> 查询数据库
//3.改:Redis未命中 -------> 尝试获取互斥锁 ------> 获取成功 -----> 查询数据库 -------> 将数据库结果写入Redis -------> 释放锁 ------> 返回商户信息 -------> 结束
// | 获取失败
// ------> 休眠一段时间后重试
//4. 查询数据库未命中 ----------------> 返回错误信息 ------> 结束
//4.改: 查询数据库未命中----------> 将空对象写入Redis ------> 结束
//5. 数据库命中 ---------------> 将数据写入Redis ------> 返回商户信息 -------> 结束
// -------------------------------------------上述思路将被分装成两个方法分别解决 缓存穿透 和 缓存击穿 问题------------------------------------------------------------------------------------------------ //
// 基于互斥锁解决缓存击穿问题
Shop shop = queryWithMutex(id);
if(shop == null){
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}
/**
* 获取互斥锁 setNx ----- setIfAbsent
* @param key
* @return
*/
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"1",RedisConstants.LOCK_SHOP_TTL,TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放互斥锁
* @param key
*/
private void unLock(String key){
stringRedisTemplate.delete(key);
}
/**
* 基于互斥锁解决缓存击穿问题保存代码
* @param id
* @return
*/
private Shop queryWithMutex(Long id){
String stopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
// 判断Redis中是否存在数据
if(StrUtil.isNotBlank(stopJson)){
// 存在直接返回
Shop shop = JSONUtil.toBean(stopJson, Shop.class); //将JSON字符串转换为对象
// return Result.ok(shop);
return shop;
}
// 判断Redis中是否存在空对象
if (RedisConstants.CACHE_PENETRATION_NULL_VALUE.equals(stopJson)){
// return Result.fail("店铺信息不存在");
return null;
}
// 4. 实现缓存重建
//4.1 尝试获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
//4.2 获取互斥锁失败 休眠一段时间后 重新查询Redis(自旋)
if(!isLock){
Thread.sleep(50);
return queryWithMutex(id);
}
//4.3 获取互斥锁成功 查询数据库 将商铺信息写入Redis
// query().eq("shop_id",id);
// 查询数据库
shop = getById(id);
// 模拟重建的延迟情况
Thread.sleep(100);
// 查询数据库不存在
if(shop == null){
// 将空对象写入Redis
stringRedisTemplate.opsForValue().set(
RedisConstants.CACHE_SHOP_KEY + id,
RedisConstants.CACHE_PENETRATION_NULL_VALUE,
RedisConstants.CACHE_NULL_TTL,
TimeUnit.MINUTES
);
// return Result.fail("店铺不存在");
return null;
}
// 将数据写入Redis + 设置过期时间
stringRedisTemplate.opsForValue().set(
RedisConstants.CACHE_SHOP_KEY + id,
JSONUtil.toJsonStr(shop),
RedisConstants.CACHE_SHOP_TTL,
TimeUnit.MINUTES
);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//4.4 释放互斥锁
unLock(lockKey);
}
//4.5 返回商铺信息
// return Result.ok(shop);
return shop;
}
3.7.2.3 功能测试——基于JMeter的压力测试
配置JMeter测试任务
配置访问后端数据地址
清空缓存店铺信息
执行测试任务
后台查询数据库只执行了一次
3.7.3 基于逻辑过期解决缓存击穿案例——以请求店铺信息为例
3.7.3.1 逻辑过期解决缓存击穿思路流程
3.7.3.2 构建RedisData对象——组合优于继承
我们先前定义的Shop对象实际上是没有逻辑过期时间字段的。如何解决这个问题呢?明显有两种方法:
基于继承的方法:【对Shop有侵入性】
创建一个父类,内涵 expireTime字段,让原先的Shop对象继承父类,从而获得逻辑过期时间字段。
基于组合的方法:
定义一个组合对象RedisData,包含一个逻辑过期时间字段和一个Object对象
3.7.3.3 代码实现
代码包括:
1. 开启一个大小为10的线程池
2. 查询店铺方法
3. 封装RedisData对象的公用方法【需要用于单元测试获取数据,所以要定义成公用方法】
4. 封装利用逻辑过期时间解决缓存击穿的私有方法
单元测试代码
@SpringBootTest
class HmDianPingApplicationTests {
@Resource
private ShopServiceImpl shopService;
@Test
void testSaveShop() throws InterruptedException {
shopService.saveShop2Redis(1L,10L);
}
}
/**
* 开启线程池
*/
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 根据id查询店铺(添加Redis缓存版 + 解决缓存穿透【缓存空对象】+ 互斥锁实现方案)
* @param id
* @return
*/
@Override
public Result queryById(Long id) {
// 基于逻辑过期解决缓存击穿问题
Shop shop = queryWithLogicalExpire(id);
if(shop == null){
return Result.fail("热点店铺不存在");
}
return Result.ok(shop);
}
/**
* 创建RedisData对象 【原Shop + 逻辑过期时间字段】
* @param id
* @param expireTime
*/
public void saveShop2Redis(Long id, Long expireTime) throws InterruptedException {
// 1.查询店铺数据
Shop shop = getById(id);
// 模拟休息情况
Thread.sleep(100);
// 2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireTime));
// 3.写入Redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
/**
* 基于逻辑过期解决缓存击穿问题 保存代码、
* 为什么不用判断缓存穿透问题,因为一定有Key,如果没有Key必然不对,不需要让它继续访问数据库了,直接打回就可
* @param id
* @return
*/
private Shop queryWithLogicalExpire(Long id){
String stopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
//1. 判断Redis中是否存在数据
if(StrUtil.isBlank(stopJson)){
//1.1 未命中直接结束
return null;
}
//2. 命中了,判断缓存是否过期
//2.1 将JSON字符串反序列为对象
RedisData redisData = JSONUtil.toBean(stopJson,RedisData.class);
JSONObject data =(JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data,Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
if(expireTime.isAfter(LocalDateTime.now())){ // 未过期
// 直接返回
return shop;
}
// 3. 已过期,需要缓存重建
// 4. 尝试获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
if(isLock){
//5. 获取互斥锁成功,开启独立线程,查询数据库,重建缓存
CACHE_REBUILD_EXECUTOR.submit(() ->{
try {
// 重建缓存
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 6. 释放互斥锁
unLock(lockKey);
}
});
}
return shop;
}
3.7.3.4 功能测试——基于JMeter的压力测试
想要测试【逻辑过期时间】策略,必须先在缓存中准备好数据,否则测试不成功,具体原因请看3.7.4故障排除
1. 执行测试方法获取Redis缓存店铺数据
等到该缓存数据过期后再执行方法
2. 修改后台数据库中店铺名,用于后续更好的观察该策略的效果
3. JMemet测试
全部请求都通过了,但是前面的请求查询到的数据还是 “102茶餐厅”【旧数据】
从这个请求开始,重建线程已经完成,后续请求都和后台数据库同步了,都是“103茶餐厅”
3.7.4【故障排查】关于逻辑过期时间策略无法查询店铺信息的现象
当我们使用逻辑过期时间策略时,在直接访问店铺数据时会发现无论怎么样,店铺数据都不会被存储到缓存中,也不会去查询数据库,导致了店铺数据一直无法访问。
这是出于逻辑过期时间策略 默认要求 热点Key数据是由管理员事先存放到Redis中设置好,等到活动结束后再人为的删除掉的。
所以在第一次访问时,如果没有提前将店铺数据存放在Redis,该策略直接返回空对象后结束,因而造成了这种现象的发生。
3.8 封装Redis缓存工具
3.8.1 封装代码
@Component
@Slf4j
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
/**
* 开启线程池
*/
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 获取互斥锁 setNx ----- setIfAbsent
* @param key
* @return
*/
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"1",RedisConstants.LOCK_SHOP_TTL,TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放互斥锁
* @param key
*/
private void unLock(String key){
stringRedisTemplate.delete(key);
}
/**
* 构造方法
* @param stringRedisTemplate
*/
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 将任意Java对象序列化为json并存储在String类型的key中,并且可以设置过期时间
* @param key
* @param value 任意java对象
* @param time 过期时间
* @param unit 过期时间单位
*/
public void set(String key, Object value, Long time, TimeUnit unit){
// 序列化
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
/**
* 将任意java对象序列化成json并存储在String类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
* @param key
* @param value 任意java对象
* @param time 逻辑过期时间
* @param unit 逻辑过期时间单位
*/
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
// 封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(value);
// 利用uint的 toSeconds 转换成Second
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 写入Redis【逻辑过期】
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
}
/**
* 根据指定的key查询缓存,并反序列为指定的类型,利用缓存空值来解决缓存穿透问题
* @param keyPrefix key前缀
* @param id 查询数据库id
* @param type 返回值类型
* @param dbFallback 数据库查询方法
* @param time 过期时间
* @param unit 过期时间单位
* @return
* @param <R> 返回值类型
* @param <ID> 查询数据库id类型
*/
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. 查询Redis
String json = stringRedisTemplate.opsForValue().get(key);
//2. 判断是否存在
if(StrUtil.isNotBlank(json)){
//3. 存在,直接返回
// 反序列化回 type 类型
return JSONUtil.toBean(json,type);
}
//判断是否为空值
if(json != null){
// 返回
return null;
}
//4. 不存在,根据id查询数据库
R r = dbFallback.apply(id);
//5. 不存在,返回错误
if(r == null){
// 写入空值
stringRedisTemplate.opsForValue().set(key,CACHE_PENETRATION_NULL_VALUE,CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//6. 存在,写入Redis
this.set(key,r,time,unit);
//7. 返回
return r;
}
/**
* 根据指定的key查询缓存,并反序列成指定类型,利用逻辑过期解决缓存击穿问题
* @param KeyPrefix key前缀
* @param id 查询数据库id
* @param type 返回值类型
* @param dbFallback 数据库查询方法
* @param time 逻辑过期时间
* @param unit 逻辑过期时间单位
* @return
* @param <R> 返回值类型
* @param <ID> 查询数据库id类型
*/
public <R,ID> R queryWithLogicalExpire(
String KeyPrefix,
ID id,
Class<R> type,
Function<ID,R> dbFallback,
Long time,
TimeUnit unit
) {
// 查询Redis
String json = stringRedisTemplate.opsForValue().get(KeyPrefix + id);
//1. 判断Redis中是否存在数据
if(StrUtil.isBlank(json)){
//1.1 不存在直接结束
return null;
}
//2. 命中了,判断缓存是否过期
//2.1 将JSON字符串反序列为对象
RedisData redisData = JSONUtil.toBean(json,RedisData.class);
JSONObject data =(JSONObject) redisData.getData();
R r = JSONUtil.toBean(data,type);
LocalDateTime expireTime = redisData.getExpireTime();
if(expireTime.isAfter(LocalDateTime.now())){ // 未过期
// 直接返回
return r;
}
// 3. 已过期,需要缓存重建
// 4. 尝试获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
if(isLock){
//5. 获取互斥锁成功,开启独立线程,查询数据库,重建缓存
CACHE_REBUILD_EXECUTOR.submit(() ->{
try {
// 重建缓存
//1. 查数据库
R r1 = dbFallback.apply(id);
//2. 写redis
this.setWithLogicalExpire(KeyPrefix,r1,time,unit);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 6. 释放互斥锁
unLock(lockKey);
}
});
}
return r;
}
}
3.8.2 使用方法
/**
* 根据id查询店铺(添加Redis缓存版 + 解决缓存穿透【缓存空对象】+ 互斥锁实现方案)
* @param id
* @return
*/
@Override
public Result queryById(Long id) {
// 解决缓存穿透问题
// Shop shop = queryWithPassThrough(id);
// 基于互斥锁解决缓存击穿问题
// Shop shop = queryWithMutex(id);
// 基于逻辑过期解决缓存击穿问题 【需要提前准备好Redis缓存数据】
// Shop shop = queryWithLogicalExpire(id);
// 基于自定义封装的Redis缓存工具解决缓存穿透问题
// Shop shop = cacheClient.queryWithPassThrough(
// CACHE_SHOP_KEY,
// id,
// Shop.class,
// this::getById,
// CACHE_SHOP_TTL,
// TimeUnit.SECONDS);
// 基于自定义封装的Redis缓存工具解决缓存击穿问题【逻辑过期时间】
Shop shop = cacheClient.queryWithLogicalExpire(
CACHE_SHOP_KEY,
id,
Shop.class,
this::getById,
CACHE_SHOP_TTL,
TimeUnit.MINUTES);
if (shop == null){
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}