Redis:原理速成+项目实战——Redis实战6(封装缓存工具(高级写法)&&缓存总结)

👨‍🎓作者简介:一位大四、研0学生,正在努力准备大四暑假的实习

🌌上期文章:Redis:原理速成+项目实战------Redis实战5(互斥锁、逻辑过期解决缓存击穿问题)

📚订阅专栏:Redis速成

希望文章对你们有所帮助

如果要看懂这篇文章的代码,请提前了解一下函数式编程,了解一下lambda表达式以及this::getById的写法。

在之前几个文章已经讲解了如何给我们的项目增加Redis缓存,并进行了Redis缓存的最佳实践,并且针对Redis缓存会存在的三个问题(缓存穿透、缓存雪崩、缓存击穿)进行了解决:
Redis缓存最佳实践
解决Redis缓存穿透、雪崩、击穿
互斥锁、逻辑过期解决缓存击穿问题

解决缓存击穿和缓存穿透的代码都还是有点复杂的,但是我们可以发现他们的方法的通用性,因此我们可以对其进行封装,封装成工具类以便于后续的开发。并进行一个简单的总结。

封装缓存工具&&缓存总结(高级写法)

缓存工具封装

我们要基于StringRedisTemplate封装一个缓存工具类,可以满足以下的需求:

1、将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间。

2、将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题。

3、根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题。

4、根据指定的key查询缓存,并反序列化指定类型,需要利用逻辑过期解决缓存击穿问题。

意思很容易理解,但是真实的实现过程中,要满足通用性就需要一些泛型、函数式编程等,具体的内容将在代码中进行注释。

我们将上面的内容封装成工具类CacheClient:

java 复制代码
package com.hmdp.utils;


import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.entity.Shop;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

import static com.hmdp.utils.RedisConstants.*;

@Slf4j
@Component
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;

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

    public void set(String key, Object value, Long time, TimeUnit unit){
        //用JSONUtil将其序列化为json对象,再以String形式进行存储
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    //set并带上逻辑过期
    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
        RedisData redisData = new RedisData();
        //注入基本的value
        redisData.setData(value);
        //注入逻辑过期时间,也就是当前时间+time,注意要把时间转换成秒
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        //这里写入Redis的序列是redisData序列化后的
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    /**
     * 解决缓存穿透,注意这里要做成泛型的
     * 我们需要的参数是形成key的前缀,id,以及传过来的对象
     * 这里的id也同样是泛型,因为没办法保证用户传过来的类型是Int还是Long
     * 这里如果不存在的话,我们就需要查询数据库,通用的函数根本不知道从数据库的哪张表进行查询,因此我们需要自行传入
     * Function<T, R>表示有参数有返回值的类型,"dbFallback"表示数据库降级逻辑,代表查询Redis失败后要去做的后备方案
     */
    public <R, ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        //从Redis中查询序列化的json字符串
        String json = stringRedisTemplate.opsForValue().get(key);
        //判断是否存在
        if (StrUtil.isNotBlank(json)) {
            //存在,直接返回
            return JSONUtil.toBean(json, type);
        }
        //注意isNotBlank会忽略null,所以还要看命中的是否是null
        if (json != null) {
            return null;
        }
        //不存在,根据id查询数据库
        R r = dbFallback.apply(id);
        //不存在,返回错误
        if (r == null){
            //存一个null到Redis中
            //这种没用的信息,TTL没必要设置太长了,这里我设置成了2min
            stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
            return null;
        }
        //存在,写入Redis,直接用set方法
        this.set(key, r, time, unit);
        //返回
        return r;
    }

    //逻辑过期解决缓存击穿问题
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    public <ID, R> R queryWithLogicalExpire(
            String keyPrefix, ID id, Class<R> type,Function<ID, R> dbFallback, Long time, TimeUnit unit){
        String key = CACHE_SHOP_KEY + id;
        //从Redis中查询商铺缓存,存储对象可以用String或者Hash,这里用String
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //判断是否存在
        if (StrUtil.isBlank(shopJson)) {
            //未命中,直接返回
            return null;
        }
        //命中,先把json反序列化成对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        JSONObject data = (JSONObject) redisData.getData();//注意我们获取到了data信息以后,返回的会是一个JSONObject的格式
        R r = JSONUtil.toBean(data, type);
        LocalDateTime expireTime = redisData.getExpireTime();
        //判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())){
            //未过期,直接返回店铺信息
            return r;
        }
        //已过期,缓存重建
        //获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;//LOCK_SHOP_KEY="lock:shop:"
        boolean isLock = tryLock(lockKey);
        //判断是否获取锁成功
        if (isLock){
            //成功获取锁,开启独立线程来实现缓存重建,用线程池来做
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 重建缓存
                    //查询数据库,这里依旧使用函数式编程
                    R r1 = dbFallback.apply(id);
                    //写入Redis
                    this.setWithLogicalExpire(key, r1, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }finally {
                    //释放锁
                    unlock(lockKey);
                }
            });
        }
        //没有成功获取,直接返回过期信息
        return r;
    }

    private boolean tryLock(String key){
        //opsForValue里面没有真正的setNx,而是setIfAbsent,表示如果不存在就执行set
        //值就随便设定一下,重点是要获取到锁,但是设定了TTL为10s
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        /**
         * 如果是直接返回flag,可能会有拆箱操作,造成空指针,需要用BooleanUtil工具类
         * 因为Boolean不是基本类型的boolean,是boolean的封装类
         */
        return BooleanUtil.isTrue(flag);
    }

    private void unlock(String key){
        stringRedisTemplate.delete(key);
    }
}

工具调用方式

我们之前对于店铺信息的查询方式就可以修改成如下形式:

java 复制代码
    @Resource
    private CacheClient cacheClient;

    @Override
    public Result queryById(Long id) {
        //缓存穿透的代码调用
        //这里的this::getById其实就是lambda表达式:id2->getById(id2),写高级点
        //Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
        //逻辑过期解决缓存击穿
        Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);
        if (shop == null) {
            return Result.fail("店铺不存在");
        }
        //返回
        return Result.ok(shop);
    }

相应的验证方式大家可以借鉴之前的文章,在这里我已经是完成了验证了,证明代码调通,没有发生线程安全问题。

缓存总结

认识缓存

问题 答案
什么是缓存 一种具备高效读写能力的数据暂存区域
缓存的作用 降低后端负载;提高服务读写响应速度
缓存的成本 开发成本、运维成本、一致性问题

我们为商铺查询以及商品类型的查询分别添加了缓存,基本的缓存作用模型很容易理解,需要注意一下相应的String与Hash类型的应用。当然之前的文章中我还用了List类型实现了商品类型查询的缓存。

缓存更新策略

1、三种策略

(1)内存淘汰:Redis自带的内存淘汰机制

(2)过期淘汰:利用expire命令给数据设置过期时间

(3)主动更新:主动完成数据库与缓存的同时更新

2、策略选择

(1)低一致性要求:内存淘汰或过期淘汰

(2)高一致性要求:主动更新为主,过期淘汰兜底

3、主动更新的方案有三种,我们选用的是最常用的Cache Aside Pattern,由缓存的调用者在更新数据库同时更新缓存。

4、Cache Aside Pattern的模式选择

问题 选择
更新缓存还是删除缓存? 删除缓存
先操作数据库还是缓存? 先操作数据库
如何确保数据库与缓存操作的原子性? 单体系统(事务机制)或分布式系统(分布式事务机制)

总结:先操作数据库、再删除缓存,并保证原子性。

5、最佳实践:Redis缓存最佳实践

(1)查询

①查缓存

②命中,直接返回

③未命中,查询数据库

④将数据库数据写入缓存

⑤返回结果

(2)修改(确保原子性)

①修改数据库

②删除缓存

缓存穿透

1、产生原因:客户端请求的数据在缓存和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

2、解决方案:

(1)缓存空对象(我们的选择)

(2)布隆过滤

(3)其他:做好数据的基础格式校验、加强用户权限校验;做好热点参数的限流

缓存雪崩

1、产生原因:同一时段有大量的缓存key失效,或者Redis服务宕机,导致大量请求到达数据库,带来的巨大压力。

2、解决方案:

(1)给不同的key的TTL添加随机值

(2)利用Redis集群提高服务可用性

(3)给缓存业务添加降级限流策略

(4)给业务添加多级缓存

缓存击穿

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

2、解决方案:

(1)互斥锁

(2)逻辑过期

上面三个问题的解决:
解决Redis缓存穿透、雪崩、击穿
互斥锁、逻辑过期解决缓存击穿问题

相关推荐
得物技术18 分钟前
基于Redis内核的热key统计实现方案|得物技术
redis
小宋102121 分钟前
实现Excel文件和其他文件导出为压缩包,并导入
java·javascript·excel·etl
guihong00425 分钟前
JAVA面试题、八股文学习之JVM篇
java·jvm·学习
QQ_11543203127 分钟前
基于Java+SpringBoot+Mysql在线简单拍卖竞价拍卖竞拍系统功能设计与实现九
java·spring boot·mysql·毕业设计·毕业源码·竞拍系统·竞拍项目
醉颜凉29 分钟前
计算(a+b)/c的值
java·c语言·数据结构·c++·算法
周杰伦fans31 分钟前
Java与C#
java·开发语言·c#
Allen Bright36 分钟前
Redis主从架构
数据库·redis·架构
ZHOU西口40 分钟前
MySQL系列之远程管理(安全)
数据库·mysql·安全·tls·加密连接·require_secure
fcopy1 小时前
Golang项目:实现生产者消费者模式
缓存·golang