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

一、前言

上一节我们实现了登录校验的功能,说明这个项目正式地入门了,那么随之而来的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才更新一次,但是用户体验感挺好的,拿的之前的数据,所以不会卡。

相关推荐
Java陈序员14 小时前
企业级!一个基于 Java 开发的开源 AI 应用开发平台!
spring boot·agent·mcp
杨运交1 天前
[041][公共模块]分布式唯一ID生成器设计与实现:一款灵活可扩展的雪花算法框架
spring boot
用户3074596982072 天前
Redis 延时队列详解
redis
烤代码的吐司君2 天前
Redis 数据结构 ZSet, BIT, HyperLogLog,Geo 空间数据
redis·后端
Flittly2 天前
【AgentScope Java新手村系列】(14)人机交互
java·spring boot·spring
Flynt3 天前
从Spring Boot 4.0升到4.1,我在Maven和gRPC上栽了跟头
java·spring boot·后端
掉鱼的猫4 天前
Spring Boot → Solon 注解迁移实战指南:一张对照表说清楚
java·spring boot
leeyi4 天前
Checkpoint 机制:Agent 怎么在断电后接着跑
redis·aigc·agent
人活一口气5 天前
Spring Boot与AIGC的完美结合:从零搭建智能内容生成平台
java·spring boot·aigc
云技纵横5 天前
一个 @Async 让循环依赖暴雷:Spring 代理的暗坑
redis