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缓存穿透、雪崩、击穿
互斥锁、逻辑过期解决缓存击穿问题

相关推荐
辛一一1 小时前
neo4j图数据库基本概念和向量使用
数据库·neo4j
熊大如如2 小时前
Java 反射
java·开发语言
巨龙之路2 小时前
什么是时序数据库?
数据库·时序数据库
蔡蓝3 小时前
binlog日志以及MySQL的数据同步
数据库·mysql
猿来入此小猿3 小时前
基于SSM实现的健身房系统功能实现十六
java·毕业设计·ssm·毕业源码·免费学习·猿来入此·健身平台
goTsHgo3 小时前
Spring Boot 自动装配原理详解
java·spring boot
卑微的Coder3 小时前
JMeter同步定时器 模拟多用户并发访问场景
java·jmeter·压力测试
是店小二呀3 小时前
【金仓数据库征文】金融行业中的国产化数据库替代应用实践
数据库·金融·数据库平替用金仓·金仓数据库2025征文
pjx9874 小时前
微服务的“导航系统”:使用Spring Cloud Eureka实现服务注册与发现
java·spring cloud·微服务·eureka
炒空心菜菜4 小时前
SparkSQL 连接 MySQL 并添加新数据:实战指南
大数据·开发语言·数据库·后端·mysql·spark