【Redis-缓存工具类:缓存穿透,缓存击穿,缓存雪崩】

时间:2023-03-13 14:10:45

前言:

阅读本文需要了解Java泛型以及lambda表达式的基础使用,会微量包含这些内容,但这些又是代码的一些关键。

  • 目录:
  • 一、Redis缓存相关工具类
  • 二、缓存穿透相关方法
  • 缓存穿透相关概念
  • 三、缓存击穿相关方法
  • 缓存击穿相关概念
  • 四、缓存雪崩(补充)
  • 缓存雪崩相关概念


一、Redis缓存相关工具类

1、基础依赖

  • ① redis相关依赖
    ​spring-boot-starter-data-redis​​:redis基础依赖;
    ​commons-pool2​​:redis连接池;
  • ② web相关依赖
    ​spring-boot-starter-web​​:SpringBoot的Web依赖;
  • ③ 数据库连接依赖
    ​mysql-connector-java​​:mysql连接依赖;
  • ④ mybatis-plus相关依赖
    ​mybatis-plus-boot-starter​​:MyBatis Plus的相关依赖;
  • ⑤ lombok相关依赖
    ​lombok​​:方便编写实体类;
  • ⑥ hutool相关依赖
    ​hutool-all​​:各种工具类的依赖;
  • ⑦ test相关依赖
    ​spring-boot-starter-test​​:测试相关依赖;


<dependencies>
<!--spring_data_redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--redis_pool-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--spring_boot-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--mysql_connector-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
<version>5.1.47</version>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--spring_boot_test-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--mybatis_plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.17</version>
</dependency>
</dependencies>

2、编写缓存工具类

  • ① @Slf4j注解
    方便打日志,省去​​private final Logger logger = LoggerFactory.getLogger(当前类名.class);​​;
  • ② @Component
    将工具类注册到Spring容器中,方便使用;
  • ③ StringRedisTemplate类
    Redis的模板类,其​​key​​和​​value​​的形式均为​​String类型​​。


@Slf4j
@Component
public class CacheClient {

private final StringRedisTemplate stringRedisTemplate;

public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}

... ...

}


二、缓存穿透相关方法

0、缓存穿透相关概念

缓存穿透是指客户端请求的数据在​​缓存中​​​和​​数据库中​​​都​​不存在​​​,这样缓存永远不会生效,这些​​请求都会打到数据库​​。


常见的两种解决方案:Ⅰ、缓存空对象;Ⅱ、布隆过滤器。

注意:以下案例以缓存空对象为例。

1、保存任意Java类型对象到缓存,并设置过期时间

  • ① JSONUtil#toJsonStr方法
    ​toJsonStr方法​​可以将Java对象类型转换为String类型;
  • ② stringRedisTemplate#opsForValue#set方法
    ​​​set(K key, V value, final long timeout, final TimeUnit unit)方法​​​对应redis指令中的​​set key value ex time​​​。
    ​​​​​​
/**
* 设置任意Java对象的缓存过期时间
*
* @param key 缓存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);
}

2、预防缓存穿透的方法

【Redis-缓存工具类:缓存穿透,缓存击穿,缓存雪崩】


  • ① keyPrefix参数
    ​String key = keyPrefix + id;​​用来保证某一资源key的唯一性;
  • ② ID id
    由于id的数据类型可能是Long、Integer、String,所以此处使用泛型;
  • ③ R
    结果类型,查询缓存的数据类型不确定,也使用泛型;
  • ④ JSONUtil#toBean方法
    ​toBean(String jsonString, Class<T> beanClass)方法​​可以将JSON字符串转为实体类对象;
    由于参数中需要实体类对象的类型,所以我们传入​​Class<R> type​​;
  • ⑤ Function<ID, R> dbFallback参数
    从数据库中查询所需对象,这个函数会因结果对象不同而不同,所以我们也通过函数参数化传入函数参数;
  • ⑥ StrUtil#isNotBlank方法
    StrUtil.isNotBlank(null) // false
    StrUtil.isNotBlank("") // false
    StrUtil.isNotBlank(" \t\n") // false
    StrUtil.isNotBlank("abc") // true


/**
* 预防缓存穿透的方法
*
* @param keyPrefix key前缀
* @param id 查询id
* @param type 结果类型
* @param dbFallback 查询函数
* @param time 过期时间
* @param unit 过期时间单位
* @param <R> 结果类型泛型
* @param <ID> 时间类型泛型
* @return 结果
*/
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. 存在,直接返回
R r = JSONUtil.toBean(json, type);
return r;
}
//判断json是等于空值
if (json != null){ //即json等于""的情形
//结果不存在
return null;
}
//4. 从数据库中查询
R r = dbFallback.apply(id);
//4.1 在数据库中也不存在
if (r == null){
//将空值写入Redis中
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
//返回错误信息
return null;
}
//4.2 在数据库中存在,写入redis,返回信息
this.set(key, r, time, unit);
return r;
}

3、service类

  • ① ShopMapper接口
    其中,BaseMapper是DAO层的CRUD封装;

【Redis-缓存工具类:缓存穿透,缓存击穿,缓存雪崩】

public interface ShopMapper extends BaseMapper<Shop> {

}
  • ② IShopService接口
    其中,IService是业务逻辑层的CRUD封装,多了批量增、删、改的操作封装;

【Redis-缓存工具类:缓存穿透,缓存击穿,缓存雪崩】

public interface IShopService extends IService<Shop> {

Result queryShopById(Long id);

}
  • ③ ShopServiceImpl实现类
    其中,ServiceImpl 针对业务逻辑层的实现;


@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

@Resource
private CacheClient cacheClient;

/**
* 根据id查询商铺信息
*
* @param id 商铺id
* @return 结果
*/
@Override
public Result queryShopById(Long id) {
//1. 通过缓存工具类调用预防缓存穿透的查询方法
Shop shop = cacheClient.queryWithPassThrough(
RedisConstants.CACHE_SHOP_KEY,
id,
Shop.class,
this::getById,
RedisConstants.CACHE_SHOP_TTL,
TimeUnit.MINUTES);
//2. 判断查询结果是否为null
if (shop == null){
return Result.fail("店铺不存在!");
}
//3. 返回结果
return Result.ok(shop);
}

}


三、缓存击穿相关方法

0、缓存击穿相关概念

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效​了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

常见的两种解决方案:Ⅰ、互斥锁;Ⅱ、逻辑过期。

注意:以下方案以逻辑过期为例。

1、设置任意Java对象的逻辑过期时间

  • ① RedisData类
    用于存储时间数据​​expireTime​​,以及对象数据​​data​​;


@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
  • ② LocalDateTime.now().plusSeconds方法
    给当前时间加上设置的过期时间;
  • ③ unit.toSeconds方法
    ​TimeUnit#toSeconds​​方法,将单位换算成秒;
/**
* 设置任意Java对象的逻辑过期时间
*
* @param key 缓存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);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time))); //将对应单位转换成秒
// 写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}

注意:设置逻辑过期,并没有真正的给缓存设置过期时间。


2、预防缓存击穿的方法

【Redis-缓存工具类:缓存穿透,缓存击穿,缓存雪崩】


  • ① 准备线程池


//线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
  • ② 互斥锁准备
    ​BooleanUtil#isTrue方法​​处理Boolean结果拆箱为null的问题,返回boolean类型;
    stringRedisTemplate.opsForValue().setIfAbsent方法对应SETNX指令


/**
* 预防缓存击穿查询方法(互斥锁方案) 获取互斥锁
*
* @param key id
* @return 结果
*/
public boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", RedisConstants.LOCK_CACHE_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}

/**
* 预防缓存击穿查询方法(互斥锁方案) 释放互斥锁
*
* @param key id
*/
public void unlock(String key){
stringRedisTemplate.delete(key);
}
  • ③ queryWithLogicalExpire方法主体
  • Ⅰ keyPrefix参数
    String key = keyPrefix + id;用来保证某一资源key的唯一性;
  • Ⅱ ID id
    由于id的数据类型可能是Long、Integer、String,所以此处使用泛型;
  • Ⅲ R
    结果类型,查询缓存的数据类型不确定,也使用泛型;
  • Ⅳ JSONUtil#toBean方法
    toBean(String jsonString, Class<T> beanClass)方法可以将JSON字符串转为实体类对象;
    由于参数中需要实体类对象的类型,所以我们传入Class<R> type
    toBean(JSONObject json, Class<T> beanClass)方法可以将JSONObject对象转换成我们指定的对象类型;
  • Ⅴ Function<ID, R> dbFallback参数
    从数据库中查询所需对象,这个函数会因结果对象不同而不同,所以我们也通过函数参数化传入函数参数;
  • Ⅵ LocalDateTime#isAfter方法
    该方法用于判断当前时间(此处我们使用逻辑过期时间为此时间)是否晚于比较时间(此处我们获取当前时间为被对比的时间)
    即逻辑过期时间晚于当前时间,则不算过期;

注意:步骤3中判断为空即返回不存在,是因为业务中保存店铺信息时就会将店铺信息保存到Redis中。


if (StrUtil.isBlank(json)){
//3. 不存在,直接返回
return null;
}

完整方法如下:

/**
* 预防缓存击穿的方法(逻辑过期方案)
*
* @param keyPrefix key前缀
* @param id 查询id
* @param type 结果类型
* @param dbFallback 查询函数
* @param time 过期时间
* @param unit 过期时间单位
* @param <R> 返回结果类型泛型
* @param <ID> 查询id类型泛型
* @return 结果
*/
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. 从redis中查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
//2. 判断缓存中是否存在数据
if (StrUtil.isBlank(json)){
//3. 不存在,直接返回
return null;
}
//4. 命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
JSONObject jsonObject = (JSONObject) redisData.getData();
R r = JSONUtil.toBean(jsonObject, type);
LocalDateTime expireTime = redisData.getExpireTime();
//5. 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())){
//5.1 未过期,直接返回缓存信息
return r;
}
//5.2 已过期,需要缓存重建
//6. 缓存重建
String lockKey = keyPrefix + id;
//6.1 获取互斥锁
boolean isLock = tryLock(lockKey);
//6.2 判断是否获取锁成功
if (isLock){
//6.3 成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R r1 = dbFallback.apply(id);
// 重建缓存
this.setWithLogicalExpire(key, r1, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
});
}
//6.4 返回过期的商铺信息
return r;
}

可以看到,我们将重建缓存数据的任务交由线程池中的线程来完成了,单独看如下:

//6. 缓存重建
String lockKey = keyPrefix + id;
//6.1 获取互斥锁
boolean isLock = tryLock(lockKey);
//6.2 判断是否获取锁成功
if (isLock){
//6.3 成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R r1 = dbFallback.apply(id);
// 重建缓存
this.setWithLogicalExpire(key, r1, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
});
}

3、service类

注意:此处service接口、mapper接口省略了,具体可以看上面的缓存穿透方案。

/**
* 根据id查询商铺信息
*
* @param id 商铺id
* @return 结果
*/
@Override
public Result queryShopById(Long id) {
//5. 通过缓存工具类调用预防缓存击穿的方法(逻辑过期方案)
Shop shop = cacheClient.queryWithLogicalExpire(
RedisConstants.CACHE_SHOP_KEY,
id,
Shop.class,
this::getById,
RedisConstants.CACHE_SHOP_TTL,
TimeUnit.MINUTES);

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


四、缓存雪崩(补充)

0、缓存雪崩相关概念

缓存雪崩是指在同一时段​​大量的缓存key同时失效​​或者​​Redis服务宕机​​,导致大量请求到达数据库,带来巨大压力。

常见的解决方案:

Ⅰ、 给不同的Key的TTL添加随机值;

Ⅱ、 利用Redis集群提高服务的可用性;

Ⅲ、 给缓存业务添加降级限流策略;

Ⅳ、 给业务添加多级缓存。


五、结尾

以上即为Redis缓存实践的部分内容