黑马点评 —— 缓存穿透和缓存击穿及其解决方案

一、前言

上一节我们实现了登录校验的功能,说明这个项目正式地入门了,那么随之而来的redis第二个核心用法,就是去操作缓存,这个的应用很多,比如我们对于商户的查询,如果从缓存中拿出来是比数据库快得多的,所以按照苍穹外卖的手段,我们可以将增删改查都增加一个环节:对redis的操作环节,接下来我将实际操作,并且引出两个可能出现的安全问题。

二、商户缓存

查询的整体流程如下:

1.查询缓存,检查是否存在缓存,有缓存就直接查缓存。

2.如果没有缓存,就去查数据库。

3.将数据库中的数据添加进缓存。

这个功能还是非常好实现的,也不存在理解困难,所以我直接给出代码:

查询:

java 复制代码
 private final StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryById(Long id) {
        Shop shop = queryWithPassThrough(id);
        if(shop==null){
            return Result.fail("店铺不存在");
        }
        return Result.ok(shop);
    }

    public Shop queryWithPassThrough(Long id) {
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        //1.从redis中查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在(商铺是否有缓存)
        if (StrUtil.isNotBlank(shopJson))/*shopJson != null && !shopJson.isEmpty()*///这样也可以,更加直观
        {//(这里的判断条件是:如果缓存查询到有不为空(null || "")的字符串,直接返回)
            //3.存在,直接返回
            return JSONUtil.toBean(shopJson, Shop.class);
        }

        //4.不存在,根据id查询数据库
        Shop shop = getById(id);
        
        //5.存在,写入redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);

        //6.返回
        return shop;
    }

更新:

java 复制代码
    @Override
    @Transactional
    public Result update(Shop shop) {
        Long id = shop.getId();
        if (id == null) {
            return Result.fail("店铺id不能为空");
        }
        //1.更新数据库
        updateById(shop);
        //2.删除缓存
        stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + id);
        return null;
    }

三、商户类型缓存

这里是我自己实现的,也很简单,直接看代码吧。

java 复制代码
@Service
@RequiredArgsConstructor
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {

    private final ShopTypeMapper shopTypeMapper;

    private final StringRedisTemplate stringRedisTemplate;

    @Override
    public List<ShopType> queryTypeList() {

        String json = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_TYPE_KEY);
        if (StrUtil.isNotBlank(json)) {
            List<ShopType> shopTypeList = JSONUtil.toList(json, ShopType.class);
            return shopTypeList;
        }
        List<ShopType> shopTypes = shopTypeMapper
                .selectList(new QueryWrapper<ShopType>()
                        .orderByAsc("sort"));
        stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_TYPE_KEY,JSONUtil.toJsonStr(shopTypes));
        return shopTypes;
    }
}

四、缓存穿透

以下所有问题我们都以查询商户为例:

首先给出一个情景来解释缓存穿透,如果浏览器发出一个请求,这个请求在数据库和缓存中都没有(比如我想查id=-1的商户信息),那么首先我会去查缓存,那缓存中当然没有了,所以接着就会去查数据库,数据库发现也没有,最后我们就会返回一个 "商户不存在" 回去。

这里有人会觉得没问题啊,毕竟已经返回不存在的信息回去了,但是试想一下,如果是在高并发的情况下呢?比如我有10000个请求同时请求这个不存在的信息(恶意请求),那我必然会去查10000次数据库,这对于数据库的压力是很大的,甚至可能会有崩溃的风险。

于是这就是第一个安全问题------缓存穿透。

那么怎么去解决这个问题呢?从本质来看,是否只要不去查数据库就可以避免这个问题了,那我们就可以有两个方案:

1.存入这个不存在的缓存,但是给它数据设置成空,这样的话,只要再请求这个不存在的信息,就会查到值为空的缓存,然后返回,自然也就不会再去调用数据库查询了。

2.布隆过滤器,在请求进入服务器之前拦截检验是否是合理请求。

由于第二个方案比较复杂,这里我们先实现第一个方案:存入空缓存

java 复制代码
@Service
@RequiredArgsConstructor
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    private final StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryById(Long id) {
        //缓存穿透
        //Shop shop = queryWithPassThrough(id);
        if(shop==null){
            return Result.fail("店铺不存在");
        }
        return Result.ok(shop);
    }

    public Shop queryWithPassThrough(Long id) {
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        //1.从redis中查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在(商铺是否有缓存)
        if (StrUtil.isNotBlank(shopJson))/*shopJson != null && !shopJson.isEmpty()*///这样也可以,更加直观
        {//(这里的判断条件是:如果缓存查询到有不为空(null || "")的字符串,直接返回)
            //3.存在,直接返回
            return JSONUtil.toBean(shopJson, Shop.class);
        }

        //由于上个判断筛选了,所以能到这里的查询结果只有两种:1.null 2.""
        //判断命中的是否是空值(如果不是null,那就是空字符串了,而空字符串就说明一定是缓存穿透,就不能再查数据库了)
        if (shopJson != null) {
            //返回错误信息
            return null;
        }

        //而如果是null,,就是未命中就有两种情况:
        // (1)只是数据库没有上传到缓存,那就去上传
        // (2)是缓存穿透,也就是数据库查不到,这里就需要存入空值

        //4.不存在,根据id查询数据库
        Shop shop = getById(id);
        //5.不存在,返回错误
        if (shop == null) {
            //将空值存入redis
            stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
            //返回错误信息
            return null;
        }
        //6.存在,写入redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);

        //7.返回
        return shop;
    }

    @Override
    @Transactional
    public Result update(Shop shop) {
        Long id = shop.getId();
        if (id == null) {
            return Result.fail("店铺id不能为空");
        }
        //1.更新数据库
        updateById(shop);
        //2.删除缓存
        stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + id);
        return null;
    }
}

这里我的注释写得非常非常详细,因为这个过程还是不太好理解的,但是本质还是通过多重判断来给请求定性,只要定性了就好处理了。

这里的 "定性" 我可以定为三种:

1.合理请求,有缓存

2.合理请求,没缓存

3.不合理请求,没缓存

自然也就有对应的解决方法:

1.直接查询缓存

2.查询数据库,然后添加缓存

3.存入空缓存

五、缓存击穿

同样的,给出一个情景,redis有一个热点"key",经常会被查到,非常常用,这就意味这这个key将长时间经历来自高并发的压力。

当我想改变这个key对应的值,正常来讲我们就会先改变数据库,然后删除缓存,最后重建缓存 。确实,在正常情况下是不会出现问题的,但是这是一个超高并发的热点 "key" ,**在缓存被删除了但是还没有被重建的阶段,**我们的请求将会直接去查数据库,在超高并发的情况下,数据库又有被查爆的风险了。

好了,这就是第二个安全问题------缓存击穿

解决缓存击穿的方法有两种:

1.互斥锁

2.逻辑过期

1.互斥锁

我们先来看第一种**:互斥锁**

首先还是看本质,本质上是要解决数据库的崩溃问题: 这是因为太多人查询了,而且和缓存穿透不同,这些都是合理的请求啊,我们不能把别人的合理请求给拦截了呀,那么既然无法拦截,那么就从如何重建来思考解决方案,我们重建缓存是不是只需要一条请求就够了,那我们就只放一条请求去查数据库然后重建缓存,其他的请求就先等着,等那一条请求完成使命(重建完成)了,这些请求就只需要去查缓存就可以拿到数据了,这样是不是就只查询了一次数据库了呢?

想法很好,但是如何实现?我们注意到这是高并发情况,并且存在排队等待的情景,那就可以想到多线程中类似的情景,当时我们是通过上锁解决的,所以这里我们也可以通过上锁来解决。

这里的上锁其实就是去redis中添加一个键,我们要求所有的请求在想去查数据库之前先停下来,看看是不是已经有人去尝试重建缓存了,如果有人已经在走重建缓存的流程了,那就停下来等待它重建完成,如果没人走这个流程,那我们自己就去重建缓存,当然,当我们去重建缓存时,其他的请求就都排队等着吧。

基本流程已经确定了,接下来有个小问题,排队等待的形式是什么?这里我们可以使用递归来不断重试,我们可以间隔50ms就查一次缓存试试,看看别人重建是否完成。

于是代码如下:

java 复制代码
@Service
@RequiredArgsConstructor
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    private final StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryById(Long id) {
        //互斥锁定义缓存击穿
        Shop shop = queryWithMutex(id);
        if(shop==null){
            return Result.fail("店铺不存在");
        }
        return Result.ok(shop);
    }

    public Shop queryWithMutex(Long id) {
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        //1.从redis中查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在(商铺是否有缓存)
        if (StrUtil.isNotBlank(shopJson))/*shopJson != null && !shopJson.isEmpty()*///这样也可以,更加直观
        {//(这里的判断条件是:如果缓存查询到有不为空(null || "")的字符串,直接返回)
            //3.存在,直接返回
            return JSONUtil.toBean(shopJson, Shop.class);
        }

        //由于上个判断筛选了,所以能到这里的查询结果只有两种:1.null 2.""
        //判断命中的是否是空值(如果不是null,那就是空字符串了,而空字符串就说明一定是缓存穿透,就不能再查数据库了)
        if (shopJson != null) {
            //返回错误信息
            return null;
        }

        //而如果是空null,就是未命中,这里有两种情况:
        //  (1)只是数据库没有上传到缓存,那就去上传
        //  (2)是缓存穿透,也就是数据库查不到,这里就需要存入空值

        //4.实现缓存重建
        //4.1 获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        Shop shop = null;
        try {
            boolean isLock = tryLock(lockKey);
            //4.2 判断是否获取成功
            if (!isLock) {
                //4.3 失败,则休眠并重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }

            //4.4上锁后二次确认缓存是否存在
            String shopJsonDouble = stringRedisTemplate.opsForValue().get(key);
            if (StrUtil.isNotBlank(shopJsonDouble)) {
                return JSONUtil.toBean(shopJsonDouble, Shop.class);
            }

            //4.5 到这里就表示确实还是需要重建缓存(别的线程没有重建),根据id查询数据库
            shop = getById(id);
            //模拟延迟(测试)
            //Thread.sleep(200);

            //5.不存在,返回错误
            if (shop == null) {
                //将空值存入redis
                stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
                //返回错误信息
                return null;
            }
            //6.存在,写入redis
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            //7.释放互斥锁
            unlock(lockKey);
        }

        //8.返回
        return shop;
    }

    /**
     * 上锁
     *
     * @param key
     * @return
     */
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);//避免拆包问题
    }

    /**
     * 关锁
     *
     * @param key
     */
    private void unlock(String key) {
        stringRedisTemplate.delete(key);

    }
}

这里注意了,缓存穿透和缓存击穿是两个概念!!!这是两个安全问题,所以目前要想同时解决这两个问题,一要多重判断定性,二要重建上锁。(当然,后续可能还有更先进的方案,但是两个问题无论如何都必须用两个方案解决)

2.逻辑过期

逻辑过期和互斥锁是两种不同的方式,但是他们都是解决缓存击穿的解决方案,所以情景与刚刚的互斥锁是一样的,但是这里先总结一下他们的效果差别:

互斥锁的效果是:一个请求重建缓存,其余请求等待。

逻辑过期的效果是:一个分出一个线程重建缓存,其余请求拿已经过期了的数据。

这里来讲讲逻辑过期的本质:其实就是给缓存一个逻辑过期时间,每次请求的时候看看过期没有,过期了就从数据库更新一次。

对于热点商户,基本上一直会有请求,所以可以看作每20s自动更新一次,而对于冷门商户,由于很久才会有一次请求,所以很久都不会更新,这样的好处是不会占用资源,并且如果真的要更新,只需要请求一次就直接更新了(因为间隔时间远大于逻辑过期时间了,也就是早就过期了)。

因为这个方案的步骤很繁琐,所以有必要理清步骤:

0. 准备工作:

(1)准备一个类来存储逻辑过期时间和数据(其实就是扩展原有的Shop类,相当于多添加一个成员变量------过期时间,但是这个类是普适的,无论哪个类来了都能这样扩展)。

(2)创建一个线程池便于后续重建缓存时使用,保证重建时不会阻塞。

1. 预热缓存,手动先存入热点key到缓存去,这里是的缓存在数据上看是永久存在的。

2. 由于预热了缓存,所以就不存在未命中的情况了,所以我们直接可以拿到缓存(无论逻辑上是否过期)。

3. 判断这个缓存逻辑上是否过期,没有过期就直接返回店铺信息。

4.如果过期了就要重建缓存了(注意,这里有两个线程,一个是本方法的线程,一个是线程池的线程,在拿锁的过程中,本方法的线程只负责开启线程池线程任务,让线程池中的一个独立的线程来重建,然后本线程会继续走下去,拿到过期的缓存,无论线程池的线程是否走完):上锁,查数据库,存缓存,放锁。

基本流程:

java 复制代码
    public Shop queryWithLogicalExpire(Long id) {
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        //1.从redis中查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在(商铺是否有缓存)
        if (StrUtil.isBlank(shopJson)) {
            //3.不存在,直接返回空
            return null;
        }

        //4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        JSONObject data = (JSONObject) redisData.getData();
        Shop shop = JSONUtil.toBean(data, Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        //5.判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            //5.1未过期,直接返回店铺信息
            return shop;
        }
        //5.2 已过期,需要缓存重建
        //6.缓存重建
        //6.1 获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        //6.2 判断是否获取锁成功
        boolean isLock = tryLock(lockKey);
        if (isLock) {
            //6.3 成功,开启独立线程实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    //重建
                    this.saveShop2Redis(id, 20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //释放锁
                    unlock(lockKey);
                }
            });
        }
        //6.4 失败,返回过期的商铺信息
        return shop;
    }

创建线程池:

java 复制代码
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

重建缓存:

java 复制代码
public void saveShop2Redis(Long id, Long expireSecond) throws InterruptedException {
        //1.查询店铺数据
        Shop shop = getById(id);
        Thread.sleep(200);
        //2.封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSecond));
        //3.写入redis
        stringRedisTemplate
                .opsForValue()
                .set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
    }

扩展过期时间:

java 复制代码
@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

最后看看互斥锁和逻辑过期的优缺点:

互斥锁:数据一致性极强,但是由于有等待时间,用户体验不太好,可能会卡,所以可以作为逻辑过期的兜底方案。

逻辑过期:数据一致性比较差,至少20s才更新一次,但是用户体验感挺好的,拿的之前的数据,所以不会卡。

相关推荐
沐硕2 小时前
Dietify 智能饮食推荐系统全解析 —— 当协同过滤遇上营养科学,构建你的私人饮食管家
spring boot·python·fastapi·多目标优化·饮食推荐·改进协同过滤
爱吃烤鸡翅的酸菜鱼2 小时前
从抽象设计到落地实践:openJiuwen可插拔会话存储机制深度解析
人工智能·redis·ai·agent
努力学习的小廉2 小时前
redis学习笔记(八)—— C++ 操作 Redis
redis·笔记·学习
WZTTMoon2 小时前
Spring Boot 启动报错:OpenFeign 隐性循环依赖,排查了整整一下午
java·spring boot·后端·spring cloud·feign
難釋懷10 小时前
Redis分片集群插槽原理
数据库·redis·缓存
ノBye~10 小时前
Centos7.6 Docker安装redis(带密码 + 持久化)
java·redis·docker
知识分享小能手12 小时前
Redis入门学习教程,从入门到精通,Redis 数据操作:知识点详解与代码实战(2)
数据库·redis·学习
菜鸟‍13 小时前
【后端项目】苍穹外卖day01-开发环境搭建
java·开发语言·spring boot
Dylan~~~14 小时前
Redis MCP Server:让 AI 拥有“持久记忆“的革命性方案
数据库·人工智能·redis