经过前面的学习,发现缓存中的问题,无论是缓存穿透,缓存雪崩,还是缓存击穿,这些问题的解决方案业务代码逻辑都很复杂,我们也不应该每次都来重写这些逻辑,我们可以将其封装成工具。而在封装的时候,也会有不少的问题需要去解决。
案例学习:缓存工具封装
基于StringRedisTemplate封装一个缓存工具类,满足下列需求:
-
方法一:将任意Java对象序列化成JSON并存储在String类型的key中,并且可以设置TTL过期时间
-
方法二:将任意Java对象序列化成JSON并存储在String类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
-
方法三:根据指定的key查询缓存,并且反序列化成指定类型,利用缓存空值的方式解决缓存穿透问题
-
方法四:根据指定的key查询缓存,并且反序列化成指定类型,需要利用逻辑过期解决缓存击穿问题
代码展示:
首先需要先声明是一个组件@Component,方便spring管理,再添加一个@slf4j注解方便日志输出,管理等,再注入StringRedisTemplate的Bean对象,使用构造器方式注入
@Slf4j
@Component
public class CacheClient {
// 注入redisTemplate,用构造器模式注入
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
方法一:将任意Java对象序列化成JSON并存储在String类型的key中,并且可以设置TTL过期时间
//方法一:将任意Java对象序列化成JSON并存储在String类型的key中,并且可以设置TTL过期时间
//编译set方法,需要注意的是 key是String类型,value是Object类型,所以需要转换一下,将 value 转为json字符串
public void set(String key, Object value, Long time, TimeUnit unit){
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
}
方法二:将任意Java对象序列化成JSON并存储在String类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
//方法二:- 将任意Java对象序列化成JSON并存储在String类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
//逻辑过期的核心思想,时将过期时间作为字段写入到数据中, 读取的时候,先判断是否过期,如果过期,则返回null,否则返回数据
//设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
方法三:根据指定的key查询缓存,并且反序列化成指定类型,利用缓存空值的方式解决缓存穿透问题
//方法三:根据指定的key查询缓存,并且反序列化成指定类型,利用缓存控制的方式解决缓存穿透问题
//设置返回值为泛型,因为在编译工具类,返回值无法确定,需要调用者告知。
// 参数:keyPrefix key前缀,id 商铺id,type 返回值类型
//id类型也不确定,需要使用泛型
public <R,ID> R queryWithPassThrough(String KeyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback,Long time,TimeUnit unit) {
//1.从redis中查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,做反序列化,返回
return JSONUtil.toBean(json, type);
}
//判断命中的是否是空值
if (json!= null) {
return null;
}
// 4.不存在,根据id查询数据库 我们工具类不清楚实体类以及数据库方法,所以我们需要让调用者将这段逻辑提供给我们,使用函数式接口,
//有参数,有返回值 ,使用函数式接口
//Function<ID,R> dbFallback,ID为参数,R为返回值
R r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 6.不存在,写入redis
stringRedisTemplate.opsForValue().set(KeyPrefix + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 5.不存在,返回错误
return null;
}
//存在,写入redis
//时间不能够写死,需要根据业务来定
this.set(KeyPrefix + id, r, time, unit);
return r;
}
在这里进行测试,进入ShopServiceImpl,调用工具类,尝试替代解决缓存穿透方案的业务代码
首先注入工具类Bean对象
@Resource
private CacheClient cacheClient;
进行方法三调用
Shop shop = cacheClient
.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
开始测试:
访问测试接口,观察数据库查询次数
数据库仅查询一次。
测试成功。工具类搭建成功
方法四:根据指定的key查询缓存,并且反序列化成指定类型,需要利用逻辑过期解决缓存击穿问题
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//方法四:根据指定的key查询缓存,并且反序列化成指定类型,利用逻辑过期解决缓存击穿问题
public <R ,ID> R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type,Function<ID,R> dbFallback,Long time,TimeUnit unit){
//1.从redis中查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(keyPrefix+id);
// 2.判断是否存在
if(StrUtil.isBlank(json)) {
// 3.不存在,直接返回空
return null;
}
//4.命中,需要把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
// 4.存在,判断缓存是否过期
//Data实际上是jsonObject对象
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return r;
}
// 6.重建缓存
// 6.1.获取互斥锁
String lock = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lock);
// 6.2.判断是否获取锁成功
if(isLock) {
//再次检测redis缓存是否过期
redisData = JSONUtil.toBean(stringRedisTemplate.opsForValue().get(keyPrefix + id), RedisData.class);
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
// 6.1.未过期,直接返回。
return JSONUtil.toBean((JSONObject) redisData.getData(), type);
}
// 6.3.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 重建缓存
R r1 = dbFallback.apply(id);
//写入Redis
setWithLogicalExpire(keyPrefix+id,r1,time,unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lock);
}
});
}
return r;
}
private boolean tryLock(String key){
//设置有效期时间取决于业务执行时间,一般比业务时间长一些即可。
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//建议不要直接返回flag,防止返回空指针,因为Boolean是boolean的包装类,需要进行拆箱操作,可能导致空指针 网络问题或者键不存在但Redis未响应,可能会返回null,因此需要实用工具类判断。改成BooleanUtil.isTrue(flag)。
return BooleanUtil.isTrue(flag);
}
// 释放锁
private void unlock(String key){
stringRedisTemplate.delete(key);
}
测试:
调用工具类:
Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
注意事项:进行测试之前需要提前将数据存入Redis中,并且保证已经过期。
在测试单元中进行存储:
@SpringBootTest
class HmDianPingApplicationTests {
@Resource
private ShopServiceImpl shopService;
@Resource
private CacheClient cacheClient;
@Test
void testSaveShop() throws InterruptedException {
shopService.getById(1L);
cacheClient.setWithLogicalExpire(CACHE_SHOP_KEY,1L,10L, TimeUnit.SECONDS);
}
测试结果:
新建100个线程并发执行。
检查成果:
测试成功,在前一半线程中,数据为旧数据,后一半线程数据为新数据。
查看数据库查询次数,只查询一次,说明并无线程并发问题。
代码汇总:
@Slf4j
@Component
public class CacheClient {
// 注入redisTemplate,用构造器模式注入
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
//方法一:将任意Java对象序列化成JSON并存储在String类型的key中,并且可以设置TTL过期时间
//编译set方法,需要注意的是 key是String类型,value是Object类型,所以需要转换一下,将 value 转为json字符串
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
//方法二:- 将任意Java对象序列化成JSON并存储在String类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
//逻辑过期的核心思想,时将过期时间作为字段写入到数据中, 读取的时候,先判断是否过期,如果过期,则返回null,否则返回数据
//设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
//方法三:根据指定的key查询缓存,并且反序列化成指定类型,利用缓存控制的方式解决缓存穿透问题
//设置返回值为泛型,因为在编译工具类,返回值无法确定,需要调用者告知。
// 参数:keyPrefix key前缀,id 商铺id,type 返回值类型
//id类型也不确定,需要使用泛型
public <R, ID> R queryWithPassThrough(String KeyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
//1.从redis中查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,做反序列化,返回
return JSONUtil.toBean(json, type);
}
//判断命中的是否是空值
if (json != null) {
return null;
}
// 4.不存在,根据id查询数据库 我们工具类不清楚实体类以及数据库方法,所以我们需要让调用者将这段逻辑提供给我们,使用函数式接口,
//有参数,有返回值 ,使用函数式接口
//Function<ID,R> dbFallback,ID为参数,R为返回值
R r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 6.不存在,写入redis
stringRedisTemplate.opsForValue().set(KeyPrefix + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 5.不存在,返回错误
return null;
}
//存在,写入redis
//时间不能够写死,需要根据业务来定
this.set(KeyPrefix + id, r, time, unit);
return r;
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//方法四:根据指定的key查询缓存,并且反序列化成指定类型,利用逻辑过期解决缓存击穿问题
public <R ,ID> R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type,Function<ID,R> dbFallback,Long time,TimeUnit unit){
//1.从redis中查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(keyPrefix+id);
// 2.判断是否存在
if(StrUtil.isBlank(json)) {
// 3.不存在,直接返回空
return null;
}
//4.命中,需要把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
// 4.存在,判断缓存是否过期
//Data实际上是jsonObject对象
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return r;
}
// 6.重建缓存
// 6.1.获取互斥锁
String lock = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lock);
// 6.2.判断是否获取锁成功
if(isLock) {
//再次检测redis缓存是否过期
redisData = JSONUtil.toBean(stringRedisTemplate.opsForValue().get(keyPrefix + id), RedisData.class);
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
// 6.1.未过期,直接返回。
return JSONUtil.toBean((JSONObject) redisData.getData(), type);
}
// 6.3.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 重建缓存
R r1 = dbFallback.apply(id);
//写入Redis
setWithLogicalExpire(keyPrefix+id,r1,time,unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lock);
}
});
}
return r;
}
private boolean tryLock(String key){
//设置有效期时间取决于业务执行时间,一般比业务时间长一些即可。
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//建议不要直接返回flag,防止返回空指针,因为Boolean是boolean的包装类,需要进行拆箱操作,可能导致空指针 网络问题或者键不存在但Redis未响应,可能会返回null,因此需要实用工具类判断。改成BooleanUtil.isTrue(flag)。
return BooleanUtil.isTrue(flag);
}
// 释放锁
private void unlock(String key){
stringRedisTemplate.delete(key);
}
}
工具类小结:
难点:
-
在查询函数中返回值的类型是不确定的,还有ID类型也无法确定,要善于利用泛型,在数据类型不确定的情况下去指定对应的类型,由调用者告知工具类真实类型,从而做出泛型的推断
-
在封装查询逻辑时,牵扯到数据库查询,而调用者的数据库类型及查询方式以及实体类都不得而知,都需要让调用者告知如何查询,而查数据库是一段函数,因此调用者需要传入一段函数,这就是用到了函数式编程,因为是根据ID查询后返回,有参数以及返回值,正对应了Java中的Function<参数,返回值 >,要调用者传递进来。
希望对大家有所帮助