Redis--缓存工具封装
经过前面的学习,发现缓存中的问题,无论是缓存穿透,缓存雪崩,还是缓存击穿,这些问题的解决方案业务代码逻辑都很复杂,我们也不应该每次都来重写这些逻辑,我们可以将其封装成工具。而在封装的时候,也会有不少的问题需要去解决。
案例学习:缓存工具封装
基于StringRedisTemplate封装一个缓存工具类,满足下列需求:
-
方法一:将任意Java对象序列化成JSON并存储在String类型的key中,并且可以设置TTL过期时间
-
方法二:将任意Java对象序列化成JSON并存储在String类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
-
方法三:根据指定的key查询缓存,并且反序列化成指定类型,利用缓存空值的方式解决缓存穿透问题
-
方法四:根据指定的key查询缓存,并且反序列化成指定类型,需要利用逻辑过期解决缓存击穿问题
代码展示:
首先需要先声明是一个组件@Component,方便spring管理,再添加一个@slf4j注解方便日志输出,管理等,再注入StringRedisTemplate的Bean对象,使用构造器方式注入
@Slf4j@Componentpublic 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.不存在,写入redisstringRedisTemplate.opsForValue().set(KeyPrefix + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);// 5.不存在,返回错误return null;}//存在,写入redis//时间不能够写死,需要根据业务来定this.set(KeyPrefix + id, r, time, unit);return r;}
在这里进行测试,进入ShopServiceImpl,调用工具类,尝试替代解决缓存穿透方案的业务代码
首先注入工具类Bean对象
@Resourceprivate 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);//写入RedissetWithLogicalExpire(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中,并且保证已经过期。
在测试单元中进行存储:
@SpringBootTestclass HmDianPingApplicationTests {@Resourceprivate ShopServiceImpl shopService;@Resourceprivate CacheClient cacheClient;@Testvoid testSaveShop() throws InterruptedException {shopService.getById(1L);cacheClient.setWithLogicalExpire(CACHE_SHOP_KEY,1L,10L, TimeUnit.SECONDS);}
测试结果:
新建100个线程并发执行。
检查成果:
测试成功,在前一半线程中,数据为旧数据,后一半线程数据为新数据。
查看数据库查询次数,只查询一次,说明并无线程并发问题。
代码汇总:
@Slf4j@Componentpublic 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.不存在,写入redisstringRedisTemplate.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);//写入RedissetWithLogicalExpire(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<参数,返回值 >,要调用者传递进来。
希望对大家有所帮助