Skip to content

缓存击穿

1.1 定义

缓存击穿是指某一热点Key突然过期,在失效到重写中间这段时间内,大量请求直接落在了MySQL上,注重“单点击破“。

1.2 解决方法

  1. 互斥锁:在读写Redis之前要先拿到锁,否则等待,保证了强一致性,但是要牺牲一点可用性,因为其他线程要等待。并且要注意在实现上,线程拿到锁进入临界区后,要先查一遍Redis(==双重检查锁定==),Redis没有数据再去查数据库,并回写Redis,防止后来的线程拿到锁后又去查数据库。在单体项目和分布式项目的实现上有区别,单体项目使用Java的Synchronized或者setnx即可,分布式项目需要使用Redission(分布式锁)。

  2. 热点key永不过期

    有两种实现:

    • 物理上永不过期:不设TTL,实现简单,但是会占用内存,更新不及时,适用于数据更新不频繁的场景,并且实现的时候,需要将该key的淘汰优先级调低,防止Redis内存淘汰机制将其剔除。

    • 逻辑上永不过期:设置一个过期时间字段 expiredtime ,每次请求数据时查看是否过期,如果已经过期,则获取锁,同时需要将旧数据返回,如果获得锁,则起一个异步线程去更新字段,更新完数据之后,释放锁。在此段时间内,如果有其他线程过来,获取锁失败,则返回旧数据。适用于极大并发,可以短暂牺牲数据一致性的场景,如秒杀。

image-20260405145531814

  1. 实现细节

    在实际业务情况中,可能会有非预期的key流量激增,可以使用类似SentinelRedis-Cell 的工具,实时统计请求频次,一旦发现某个非预期的 Key 流量激增,系统自动通过程序将其设置为“逻辑永不过期”或延长其 TTL,实现被动变主动

1.3 实际代码开发

1.3.1 互斥锁

java
    /**
     * 设置锁,返回是否成功持有锁
     * @return {@link Boolean }
     */
    private Boolean lock(String key) {
        Boolean haveLock = stringRedisTemplate.opsForValue().setIfAbsent(key, "", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(haveLock);
    }

    /**
     * 释放锁
     */
    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }

    /**
     *
     * 查询Redis中数据,考虑到了缓存穿透情况
     * @param id
     * @return {@link Shop }
     */
    private Result queryCacheById(Long id) {
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 有记录
        if (StrUtil.isNotBlank(shopJson)) {
            // 判断是否是缓存的空对象,如果是,则不再查询数据库,解决缓存穿透问题
            if ("NULL".equals(shopJson)) {
                return Result.fail("店铺不存在!");
            }
            // 查到记录续期
            stringRedisTemplate.expire(key, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
            return Result.ok(JSONUtil.toBean(shopJson, Shop.class));
        }
        // 无记录
        return null;
    }


    /**
     * 引入缓存层的查询商户
     * @param id
     * @return {@link Result }
     */
    @Override
    public Result queryById(Long id) {
        // 1. 先查Redis
        Result result = queryCacheById(id);
        // 有记录或者缓存穿透情况
        if (result != null) {
            return result;
        }
        // Redis无记录,查数据库
        // 2. 抢锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        Shop shop = null;
        try {
            // 互斥锁解决缓存击穿,Redis没数据之后,查数据库之前,获取锁
            // 使用setnx,注意,要有持锁时间,查数据库,最多10秒,并且在回写Redis之后,要释放掉锁
            Boolean haveLock = lock(lockKey);
            // (1)不持有锁,等待一段时间重试
            if (!haveLock) {
                Thread.sleep(50);
                return queryById(id);
            }

            // (2)持有锁
            // 双重检查锁定,线程进来之后要先查Redis,主要应对第一个线程写完之后的进来的线程
            result = queryCacheById(id);
            // 有记录或者缓存穿透情况
            if (result != null) {
                return result;
            }

            // 处理第一次拿到锁进来的线程
            // 3. 无记录去查MySQL
            shop = getById(id);

            String key = RedisConstants.CACHE_SHOP_KEY + id;
            // 4. 数据库也无记录,缓存低TTL的空对象,防止缓存穿透
            if (shop == null) {
                stringRedisTemplate.opsForValue().set(key, "NULL", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
                // 数据库无记录,也需要释放掉缓存击穿的锁,否则第一次之后的线程会白白被锁住直到锁过期
                return Result.fail("店铺不存在!");
            }
            // 5. 有记录回写Redis并返回
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
            return Result.ok(shop);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 6. 当前线程处理完之后,必须释放锁
            unlock(lockKey);
        }
    }

image-20260406154835022

1.3.2 逻辑不过期

java
    // 声明一个全局静态线程池(避免重复创建线程)
    private static final ExecutorService CACHE_REBUILD_EXECUTOR =
            Executors.newFixedThreadPool(10);

    /**
     * 将数据及其逻辑过期时间封装后写入 Redis
     *
     * @param key        Redis Key
     * @param obj        要缓存的对象
     * @param expireTime 过期时间长度
     * @param unit       时间单位
     */
    public void setWithLogicalExpire(Object obj, String key, Long expireTime, TimeUnit unit) {
        if (obj == null) {
            log.warn("Attempting to set null object for logical expire, key: {}", key);
            return;
        }

        // 1. 封装逻辑过期包装类
        RedisData redisData = new RedisData();
        redisData.setData(obj); // RedisData 的 data 属性本身就是 Object,直接存即可

        // 2. 设置逻辑过期时间
        // 将当前时间加上指定步长,转换为 LocalDateTime
        LocalDateTime logicalExpireTime = LocalDateTime.now().plusSeconds(unit.toSeconds(expireTime));
        redisData.setExpiredTime(logicalExpireTime);

        // 3. 写入 Redis
        // 注意:这里使用的是 JSONUtil 序列化,确保你的序列化器能处理 LocalDateTime
        stringRedisTemplate.opsForValue().set(key, JsonUtils.toJsonStr(redisData));
    }  

  /**
     * 逻辑不过期
     *
     * @param id
     * @return {@link Result }
     */
    @Override
    public Result queryById(Long id) {

        // 1. 查Redis,这里一定能查到,因为没有设TTL
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        String jsonBean = stringRedisTemplate.opsForValue().get(key);

        // 有记录
        if (StrUtil.isNotBlank(jsonBean)) {
            // 处理缓存穿透
            if ("NULL".equals(jsonBean)) {
                return Result.fail("店铺不存在!");
            }
            // 2. 判断是否到了expiredTime过期时间
            LocalDateTime expiredTime = Objects.requireNonNull(JsonUtils.toBean(jsonBean, RedisData.class)).getExpiredTime();
            if (expiredTime.isBefore(LocalDateTime.now())) {
                // 3. 已过期就拿锁去起一个线程去数据库查询新数据写回
                String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
                Boolean haveLock = lock(lockKey);
                // 4. 拿到锁拉起一个线程去查数据库,写回
                if (haveLock) {
                    // 获取锁成功,开启独立线程,查数据库回写
                    // 注意:这里最好再进行一次 Double-Check,重新查一遍 Redis 确认是否真的过期
                    CACHE_REBUILD_EXECUTOR.submit(() -> {
                        try {
                            // 【重要】再次查询 Redis,确认是否真的需要重建
                            // 如果别的线程刚刚更新完,我们就没必要再查库了
                            String latestJson = stringRedisTemplate.opsForValue().get(key);
                            RedisData latestData = JsonUtils.toBean(latestJson, RedisData.class);
                            if (latestData.getExpiredTime().isAfter(LocalDateTime.now())) {
                                return; // 已经被别人更新过了
                            }
                            Shop shop = getById(id);
                            setWithLogicalExpire(shop, key, 30L, TimeUnit.MINUTES);
                        }finally {
                            // 释放锁
                            unlock(lockKey);
                        }
                    });
                }
            }
            // 4. 不管有没有拿到锁,都返回旧数据
            return Result.ok(Objects.requireNonNull(JsonUtils.toBean(jsonBean, RedisData.class)).getData());
        }

        // 无记录去查数据库
        Shop shop = getById(id);
        // 数据库也无记录,缓存低TTL的空对象,防止缓存穿透
        if (shop == null) {
            stringRedisTemplate.opsForValue().set(key, "NULL", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
            return Result.fail("店铺不存在!");
        }
        // 写回
        setWithLogicalExpire(shop, key, 30L, TimeUnit.MINUTES);
        return Result.ok(shop);
    }

image-20260406154757577

1.4 自己实现的代码的AIreview

1.4.1 互斥锁

原代码:

java
    /**
     *
     *  设置锁,返回是否成功持有锁
     * @return {@link Boolean }
     */
    private Boolean lock(String key){
        Boolean haveLock = stringRedisTemplate.opsForValue().setIfAbsent(key, "", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(haveLock);
    }

    /**
     *
     * 释放锁
     */
    private void unlock(String key){
        stringRedisTemplate.delete(key);
    }



    /**
     *
     * 引入缓存层的查询商户
     *
     * @param id
     * @return {@link Result }
     */
    @Override
    public Result queryById(Long id) {
        // 1. 先查Redis
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);

        // 1.1 有记录直接返回
        if (StrUtil.isNotBlank(shopJson)) {

            // 判断是否是缓存的空对象,如果是,则不再查询数据库,解决缓存穿透问题
            if ("NULL".equals(shopJson)) {
                return Result.fail("店铺不存在!");
            }
            // 续期
            stringRedisTemplate.expire(key, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
            return Result.ok(JSONUtil.toBean(shopJson, Shop.class));
        }

        // 判断是否是缓存的空对象,如果是,则不再查询数据库
//        if (shopJson != null) {
//            return null;
//        }

        // 互斥锁解决缓存击穿,Redis没数据之后,查数据库之前,获取锁
        // 使用setnx,注意,要有持锁时间,查数据库,最多10秒,并且在回写Redis之后,要释放掉锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        Boolean haveLock = lock(lockKey);

        // (1)不持有锁,等待一段时间重试
        if (!haveLock) {
            try {
                Thread.sleep(50);
                queryById(id);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }

        // (2)持有锁
        // 双重检查锁定,线程进来之后要先查Redis,
        shopJson = stringRedisTemplate.opsForValue().get(key);
        
        // 处理第二次及以后进来的线程
        if (StrUtil.isNotBlank(shopJson)) {

            // 判断是否是缓存的空对象,如果是,则不再查询数据库,解决缓存穿透问题
            if ("NULL".equals(shopJson)) {
                unlock(lockKey);
                return Result.fail("店铺不存在!");
            }
            // 续期
            stringRedisTemplate.expire(key, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
            return Result.ok(JSONUtil.toBean(shopJson, Shop.class));
        }


        // 处理第一次拿到锁进来的线程
        // 1.2 无记录去查MySQL
        Shop shop = getById(id);

        // 1.3 数据库也无记录,缓存低TTL的空对象,防止缓存穿透
        if (shop == null) {
            stringRedisTemplate.opsForValue().set(key, "NULL", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 数据库无记录,也需要释放掉缓存击穿的锁,否则第一次之后的线程会白白被锁住直到锁过期
            unlock(lockKey);
            return Result.fail("店铺不存在!");
        }
        // 1.4 有记录回写Redis并返回
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        // 缓存穿透,释放锁
        unlock(lockKey);
        return Result.ok(shop);
    }

优化方向:

  • 将查询Redis并续期的操作提取出来,并且建议保留当下情况,缓存穿透情况直接返回Result.fail(),不要再往下参与竞争
  • 使用try-finally结构,保证当前线程一定会释放锁
  • 关于queryById(id),其执行完之后会继续执行下方代码,依然会进行差Redis等操作,需要使用 return 的写法,就不会再调用下方逻辑

优化代码:

java
    /**
     * 设置锁,返回是否成功持有锁
     * @return {@link Boolean }
     */
    private Boolean lock(String key) {
        Boolean haveLock = stringRedisTemplate.opsForValue().setIfAbsent(key, "", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(haveLock);
    }

    /**
     * 释放锁
     */
    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }

    /**
     *
     * 查询Redis中数据,考虑到了缓存穿透情况
     * @param id
     * @return {@link Shop }
     */
    private Result queryCacheById(Long id) {
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 有记录
        if (StrUtil.isNotBlank(shopJson)) {
            // 判断是否是缓存的空对象,如果是,则不再查询数据库,解决缓存穿透问题
            if ("NULL".equals(shopJson)) {
                return Result.fail("店铺不存在!");
            }
            // 查到记录续期
            stringRedisTemplate.expire(key, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
            return Result.ok(JSONUtil.toBean(shopJson, Shop.class));
        }
        // 无记录
        return null;
    }


    /**
     * 引入缓存层的查询商户
     * @param id
     * @return {@link Result }
     */
    @Override
    public Result queryById(Long id) {
        // 1. 先查Redis
        Result result = queryCacheById(id);
        // 有记录或者缓存穿透情况
        if (result != null) {
            return result;
        }
        // Redis无记录,查数据库
        // 2. 抢锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        Shop shop = null;
        try {
            // 互斥锁解决缓存击穿,Redis没数据之后,查数据库之前,获取锁
            // 使用setnx,注意,要有持锁时间,查数据库,最多10秒,并且在回写Redis之后,要释放掉锁
            Boolean haveLock = lock(lockKey);
            // (1)不持有锁,等待一段时间重试
            if (!haveLock) {
                Thread.sleep(50);
                return queryById(id);
            }

            // (2)持有锁
            // 双重检查锁定,线程进来之后要先查Redis,主要应对第一个线程写完之后的进来的线程
            result = queryCacheById(id);
            // 有记录或者缓存穿透情况
            if (result != null) {
                return result;
            }

            // 处理第一次拿到锁进来的线程
            // 3. 无记录去查MySQL
            shop = getById(id);

            String key = RedisConstants.CACHE_SHOP_KEY + id;
            // 4. 数据库也无记录,缓存低TTL的空对象,防止缓存穿透
            if (shop == null) {
                stringRedisTemplate.opsForValue().set(key, "NULL", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
                // 数据库无记录,也需要释放掉缓存击穿的锁,否则第一次之后的线程会白白被锁住直到锁过期
                return Result.fail("店铺不存在!");
            }
            // 5. 有记录回写Redis并返回
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
            return Result.ok(shop);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 6. 当前线程处理完之后,必须释放锁
            unlock(lockKey);
        }
    }
  • 进阶优化:目前递归调用queryCacheById在高并发情况下仍有栈溢出风险,后续可以进一步使用while(True)来完成这部分逻辑。

1.4.2 逻辑不过期

java

    /**
     * 将数据及其逻辑过期时间封装后写入 Redis
     *
     * @param key        Redis Key
     * @param obj        要缓存的对象
     * @param expireTime 过期时间长度
     * @param unit       时间单位
     */
    public void setWithLogicalExpire(Object obj, String key, Long expireTime, TimeUnit unit) {
        if (obj == null) {
            log.warn("Attempting to set null object for logical expire, key: {}", key);
            return;
        }

        // 1. 封装逻辑过期包装类
        RedisData redisData = new RedisData();
        redisData.setData(obj); // RedisData 的 data 属性本身就是 Object,直接存即可

        // 2. 设置逻辑过期时间
        // 将当前时间加上指定步长,转换为 LocalDateTime
        LocalDateTime logicalExpireTime = LocalDateTime.now().plusSeconds(unit.toSeconds(expireTime));
        redisData.setExpiredTime(logicalExpireTime);

        // 3. 写入 Redis
        // 注意:这里使用的是 JSONUtil 序列化,确保你的序列化器能处理 LocalDateTime
        stringRedisTemplate.opsForValue().set(key, JsonUtils.toJsonStr(redisData));
    }  

  /**
     * 逻辑不过期
     *
     * @param id
     * @return {@link Result }
     */
    @Override
    public Result queryById(Long id) {

        // 1. 查Redis,这里一定能查到,因为没有设TTL
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        String jsonBean = stringRedisTemplate.opsForValue().get(key);

        // 有记录
        if (StrUtil.isNotBlank(jsonBean)) {
            // 处理缓存穿透
            if ("NULL".equals(jsonBean)) {
                return Result.fail("店铺不存在!");
            }
					// 2. 判断是否到了expiredTime过期时间
        LocalDateTime expiredTime = Objects.requireNonNull(JsonUtils.toBean(jsonBean, 				RedisData.class)).getExpiredTime();
            if (expiredTime.isBefore(LocalDateTime.now())) {
                // 3. 已过期就拿锁去起一个线程去数据库查询新数据写回
                String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
                Boolean haveLock = lock(lockKey);
                // 4. 拿到锁拉起一个线程去查数据库,写回
                if (haveLock) {
                    new Thread(() -> {
                    // 查数据库
                    Shop shop = getById(id);
                    // 写入Redis
                    setWithLogicalExpire(shop,key, 30L, TimeUnit.MINUTES);
                    unlock(lockKey);
                    }).start();
                }
            }
            // 4. 不管有没有拿到锁,都返回旧数据
            return Result.ok(Objects.requireNonNull(JsonUtils.toBean(jsonBean, RedisData.class)).getData());
        }

        // 无记录去查数据库
        Shop shop = getById(id);
        // 数据库也无记录,缓存低TTL的空对象,防止缓存穿透
        if (shop == null) {
            stringRedisTemplate.opsForValue().set(key, "NULL", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
            return Result.fail("店铺不存在!");
        }
        // 写回
        setWithLogicalExpire(shop, key, 30L, TimeUnit.MINUTES);
        return Result.ok(shop);
    }

踩到的坑:

  • new 完 Thread 没有 ==.start()==,一定注意
  • 在封装过期时间的时候,创建的RedisData类型,两个成员变量:LocalDateTime 过期时间 ,Object data,使用Object类型有助于之后扩展

优化方向:

  • 使用线程池

优化代码如上