一、核心逻辑
经过前面几节的学习,我们已经解决了缓存使用过程中的各种各样的一些问题,但是我们发现无论是解决缓存穿透还是解决缓存击穿,她们的代码逻辑都是挺复杂的,如果说我们每次开发的时候都要去写这些逻辑,其实开发的成本还是挺高的,所以将来我们一定会将这些解决方案封装成工具。不过在封装的时候,其实还会碰到不少的问题需要去解决,所以在这就给大家演示一下这个工具封装的时候它有一些什么使用技巧。
在这里我们并不是封装一个完整的工具类,为什么只是以几个方法为例去演示一下中间可能存在的问题,以及解决的思路。
需求:基于StringRedisTemplate封装一个缓存工具类,满足下列需求
-
方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
也就是说人家存的时候你不能要求别人穿字符串,而且别人存任意数据类型的都可以,此时这个序列化的动作就交给工具类去做了,做一个简化
-
方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
-
方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
因为你封装的这个工具类,它并不知道使用者要操作的数据是什么类型,因此这个方法在使用的时候需要指定类型,然后才能做反序列化
-
方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
这四个方法你可以认为这个方法是两两配对的,方法一和方法三对应起来,即对应一些常见的、普通的缓存,可以解决缓存穿透、设置TTL过期。
而方法二和方法四结合起来是针对热点Key解决击穿的问题。
二、将逻辑进行封装
1)封装缓存穿透
@Slf4j // 在工具类中做日志记录
@Component // 由spring管理
public class CacheClient {
// 可以由构造函数注入,也可以使用@AutoWired注解
private final StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// 由于是任意Java对象,因此这里的value的类型应该为Object。这里过期时间可以跟spring的玩法一致
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, 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));
}
// 查询缓存穿透和缓存击穿的代码直接将之前写的拷贝过来即可
// 但是由于这里我们定义的是工具类,因此返回的应该是任意的类型,此时就可以使用泛型:R
// 而R真实的类型就可以通过Class<R> type来确定,这叫泛型的推断
// 还需要传入id:用来根据id查数据库
// 要想从redis中查,还需要有一个key的前缀keyPrefix
// 并且用户的ID不一定是Long类型。现在我们的店铺是Long,但是将来别人传的就不一定是Long了,因此这里不能写死,既然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查询,查询到的结果是一个JSON,这里不要再当成以前的店铺了
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,直接返回,返回的也应该是R的类型
return JSONUtil.toBean(json, type);
}
// 判断命中的是否是空值
if (json != null) {
// 返回一个错误信息
return null;
}
// 4.不存在,根据id查询数据库。
// 但是查询的方法并不能确定,查询Shop有Shop的查询方法;查User有User的查询方法,这样往Redis中存的数据不一样,那么查的数据也就不一样,因此这里根本不可能知道调用的是什么方法
// 这段逻辑我们不知道的,就可以交给调用者,调用我们的人它肯定知道数据库要怎么做,因此我们要求它将这段数据库查询的逻辑传递给我们即可,数据库查询的逻辑不就是一个函数吗,函数式编程就可以传递这个函数
// 传进来的函数应该有什么特点呢?首先有参数id,其次你有返回值R,因此你是一个有参有返回值的函数,有参有返回值的函数在Java中叫什么?叫Function
// 在这个方法的参数列表中加上Function<ID, R> dbFallback,Function有两个泛型,一个是参数的类型,另一个是返回值的类型,这里取名为dbFallback,也就是数据库降级的逻辑:即我先去查redis,redis失败了我才走查数据库,即一个后备的逻辑
// 定义好函数参数后,将来就有别人传递给我逻辑就行了,这里直接使用apply调用方法即可。这里我们可以点进apply方法看看
R r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis,这里的缓存时间也应该由别人传进来,不能写死,于是又要加上两个参数Long time, TimeUnit unit
// 存入的时候直接调用我们刚刚写好的set方法即可
this.set(key, r, time, unit);
return r;
}
}
2)解决缓存击穿
CacheClient.java
参数一定是用到了再添加,不是一上来就写这么多
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);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return r;
}
// 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 {
// 查询数据库
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;
}
public <R, ID> R queryWithMutex(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, type);
}
// 判断命中的是否是空值
if (shopJson != null) {
// 返回一个错误信息
return null;
}
// 4.实现缓存重建
// 4.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2.判断是否获取成功
if (!isLock) {
// 4.3.获取锁失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
// 4.4.获取锁成功,根据id查询数据库
r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
// 7.释放锁
unlock(lockKey);
}
// 8.返回
return r;
}
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
3)修改原来的代码
ShopServiceImpl.java
此时在ShopServiceImpl 中,我们就不用自己写逻辑了,只需要使用我们刚刚写好的工具类里面的方法了
@Resource // 注入
private CacheClient cacheClient;
@Override
public Result queryById(Long id) {
// 解决缓存穿透
// this::getById:传入的应该是一个查询数据库的函数,并且这个函数的参数,id是Long类型的,返回值是你要查询的结果,这里即Shop,如果用Lambda表达式可以写成这样:id2 -> getById(id2),但是像这样的Lambda表达式是可以简写的:this::getById
// 也就是说我们以后在做缓存穿透的时候,这一行代码就搞定了,因为我们已经有工具类了,而且以后任意的对象都能使用这个工具类
Shop shop = cacheClient
.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 互斥锁解决缓存击穿
// Shop shop = cacheClient
// .queryWithMutex(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 逻辑过期解决缓存击穿,为了测试缓存击穿,时间就设置为20s。但是要注意测试逻辑过期需要先通过单元测试进行缓存预热
// Shop shop = cacheClient
// .queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);
if (shop == null) {
return Result.fail("店铺不存在!");
}
// 7.返回
return Result.ok(shop);
}
HmDianPingApplicationTests.java
@Resource
private CacheClient cacheClient;
@Test
void testSaveShop() throws InterruptedException {
Shop shop = shopService.getById(1L);
// 过期时间设置为10s,这样很方便我们测试缓存重建
cacheClient.setWithLogicalExpire(CACHE_SHOP_KEY + 1L, shop, 10L, TimeUnit.MINUTES);
}
测试的话跟之前是一样的。