Redis 缓存穿透与缓存击穿解决(依据黑马点评项目CacheClient工具类代码解析)

在高并发系统中,缓存是性能的生命线
但如果缓存使用不当,不仅不能提升性能,反而可能直接拖垮数据库。
本文依据黑马点评项目中CacheClient工具类中代码的知识点进行讲解

📚 目录(点击跳转对应章节)

前言、完整代码展示
一、为什么要封装 CacheClient?
二、基础能力:普通缓存写入
三、缓存穿透:数据库被"打穿"的元凶
四、缓存击穿:热点 Key 的致命问题
五、终极方案:逻辑过期 + 异步重建
六、分布式锁实现(简洁但有效)
七、总结:这套缓存方案解决了什么?
八、总结


前言、完整代码展示

package com.hmdp.utils;

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

import java.time.LocalDateTime;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

@Component
@Slf4j
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;

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

    /**
     * 将数据缓存进Redis,并且设置超时时间
     *
     * @param key     缓存的键名
     * @param value   缓存的数据
     * @param timeout 超时时间
     * @param unit    时间单位
     */
    public void set(String key, Object value, Long timeout, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), timeout, unit);
    }

    /**
     * 将数据加入缓存,并且设置逻辑过期时间
     *
     * @param key     缓存的键名
     * @param value   缓存的数据
     * @param timeout 逻辑过期时间
     * @param unit    时间单位
     */
    public void setWithLogicalExpire(String key, Object value, Long timeout, TimeUnit unit) {
        RedisData redisData = new RedisData();
        redisData.setData(value);
        //unit.toSeconds()是为了确保计时单位是秒
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(timeout)));
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    /**
     * 缓存穿透处理(根据id查询数据)
     *
     * @param keyPrefix  缓存的键名前缀
     * @param id         查询的id
     * @param type       查询的数据类型
     * @param dbFallback 数据查询的回调函数
     * @param timeout    超时时间
     * @param unit       时间单位
     * @param <T>        查询的数据类型
     * @param <ID>       查询的id的类型
     * @return 查询到的数据
     */
    public <T, ID> T handCachePenetration(String keyPrefix, ID id, Class<T> type,
                                          Function<ID, T> dbFallback, Long timeout, TimeUnit unit) {
        String key = keyPrefix + id;
        //1.从Redis中查询数据
        String jsonStr = stringRedisTemplate.opsForValue().get(key);

        T t = null;
        //2.判断缓存是否命中
        if (StrUtil.isNotBlank(jsonStr)) {
            //3.缓存命中,将数据转为对象并返回
            t = JSONUtil.toBean(jsonStr, type);
            return t;
        }

        //4.缓存未命中,判断缓存中查询的数据是否为空字符串(isNotBlank()把null和空字串都判断为false,所以排除了)
        if (Objects.nonNull(jsonStr)) {
            //5.缓存命中,但数据为空字符串,返回null
            return null;
        }
        //6.缓存未命中(jsonStr为null),查询数据库
        t = dbFallback.apply(id);

        //7.判断查询到的数据是否存在店铺数据
        if (Objects.isNull(t)) {
            //7.1数据库未查询到数据,将空字符串写入Redis并返回null
            stringRedisTemplate.opsForValue().set(key, "", timeout, unit);
            return null;
        }

        //7.2数据库中查询到数据,将数据写入Redis并返回
        this.set(key, t, timeout, unit);
        //8.返回查询到的数据
        return t;
    }

    /**
     * 缓存重建线程池
     */
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 缓存击穿处理(根据id查询数据)
     *
     * @param keyPrefix  缓存的键名前缀
     * @param id         查询的id
     * @param type       查询的数据类型
     * @param dbFallback 数据查询的回调函数
     * @param timeout    逻辑过期时间
     * @param unit       时间单位
     * @param <T>        查询的数据类型
     * @param <ID>       查询的id的类型
     * @return 查询到的数据
     */
    public <T, ID> T handleCacheBreakdown(String keyPrefix, ID id, Class<T> type,
                                          Function<ID, T> dbFallback, Long timeout, TimeUnit unit) {
        String key = keyPrefix + id;
        //1.从Redis中查询数据
        String jsonStr = stringRedisTemplate.opsForValue().get(key);

        //2.判断缓存是否命中
        if (StrUtil.isBlank(jsonStr)) {
            //3.缓存未命中,返回null
            return null;
        }

        //4.缓存命中,先将JSON字符串反序列化为对象
        RedisData redisData = JSONUtil.toBean(jsonStr, RedisData.class);
        T t = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        //5.获取逻辑过期时间,判断是否过期
        if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
            //6.未过期,返回数据
            return t;
        }

        //7.已过期,获取互斥锁,并且重建缓存
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        //8.判断是否获取锁成功
        if (isLock) {
            //9.获取锁成功,创建线程,并开始重建缓存
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    //9.1重建缓存
                    T newT = dbFallback.apply(id);
                    //9.2写入Redis
                    this.setWithLogicalExpire(key, newT, timeout, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //9.3释放锁
                    unlock(lockKey);
                }
            });
        }

        //10.获取锁失败,再次查询缓存并重建缓存(双检操作)
        jsonStr = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isBlank(jsonStr)) {
            //11.缓存未命中,返回null
            return null;
        }
        //12.缓存命中,先将JSON字符串反序列化为对象
        redisData = JSONUtil.toBean(jsonStr, RedisData.class);
        t = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        //13.判断逻辑过期时间,判断是否过期
        if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
            //14.未过期,返回数据
            return t;
        }

        //15.已过期,返回过期数据
        return t;
    }


    /**
     * 释放锁
     *
     * @param key 锁的键名
     * @return 是否释放成功
     */
    private boolean tryLock(String key) {
        return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS));//当flag为null时,说明没有获取锁,返回false
    }

    /**
     * 释放锁
     *
     * @param key 锁的键名
     */
    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }
}

一、为什么要封装 CacheClient?

在业务代码中,如果我们到处写:

先查 Redis → 再查 DB → 再写 Redis

会带来几个问题:

  1. 代码重复、难维护
  2. 不同开发者实现方式不一致
  3. 缓存问题(穿透、击穿)处理不统一

正确做法
👉 把缓存访问的"套路"抽象成一个统一的工具类

这正是 CacheClient 存在的意义。


二、基础能力:普通缓存写入

1️⃣ 普通 TTL 缓存

public void set(String key, Object value, Long timeout, TimeUnit unit) {
    stringRedisTemplate.opsForValue()
        .set(key, JSONUtil.toJsonStr(value), timeout, unit);
}

特点:

  • 使用 Redis 物理过期
  • 适合:普通数据、更新不频繁的场景
  • Redis 到期后,key 会被自动删除

2️⃣ 逻辑过期缓存(核心)

public void setWithLogicalExpire(String key, Object value, Long timeout, TimeUnit unit)

这里并没有给 Redis 设置 TTL,而是存了一个结构:

{
  "data": {...},
  "expireTime": "2026-01-10T12:00:00"
}

📌 逻辑过期的本质

  • Redis 中的数据永远存在
  • 是否过期由业务代码判断
  • 允许返回"过期数据",但后台异步重建缓存

👉 这是解决 缓存击穿 的关键技术。


三、缓存穿透:数据库被"打穿"的元凶

在这里插入图片描述

什么是缓存穿透?

请求一个数据库中根本不存在的数据

Redis:没有
DB:没有
→ 每次请求都打到 DB

如果有人恶意请求不存在的 id,数据库会被直接打爆。


解决方案:缓存空值

核心方法:

handCachePenetration(...)
关键流程拆解

① 查 Redis

String jsonStr = stringRedisTemplate.opsForValue().get(key);

② Redis 命中且有值 → 直接返回

if (StrUtil.isNotBlank(jsonStr)) {
    return JSONUtil.toBean(jsonStr, type);
}

③ Redis 命中但为空字符串 → 说明数据库也没有

if (Objects.nonNull(jsonStr)) {
    return null;
}

⚠️ 这里非常关键:

  • jsonStr == null → Redis 没有这个 key
  • jsonStr == "" → Redis 命中,但这是空值缓存

④ Redis 未命中 → 查数据库

T t = dbFallback.apply(id);

⑤ 数据库也没有 → 缓存空值

stringRedisTemplate.opsForValue().set(key, "", timeout, unit);

📌 效果:

  • 第一次查 DB
  • 后续所有请求都被 Redis 拦住
  • 数据库安全了 ✅

四、缓存击穿:热点 Key 的致命问题

在这里插入图片描述

什么是缓存击穿?

某个 热点 key

  • 访问量极大
  • 在某一时刻刚好过期
  • 大量请求同时打到数据库

👉 即使 Redis 正常,也可能把 DB 压垮。


五、终极方案:逻辑过期 + 异步重建

核心方法:

handleCacheBreakdown(...)

1️⃣ Redis 必须"永远有数据"

if (StrUtil.isBlank(jsonStr)) {
    return null;
}

👉 如果 Redis 直接没数据,说明逻辑过期方案没初始化好


2️⃣ 判断是否逻辑过期

if (expireTime.isAfter(LocalDateTime.now())) {
    return data;
}
  • 未过期 → 直接返回
  • 已过期 → 进入重建流程

3️⃣ 互斥锁 + 异步线程池

boolean isLock = tryLock(lockKey);

只允许 一个线程 重建缓存:

CACHE_REBUILD_EXECUTOR.submit(() -> {
    T newT = dbFallback.apply(id);
    setWithLogicalExpire(key, newT, timeout, unit);
});

📌 非常重要的思想:

  • 用户线程不阻塞
  • 返回旧数据(可接受)
  • 后台悄悄更新缓存

4️⃣ 双重检查(Double Check)

jsonStr = stringRedisTemplate.opsForValue().get(key);

防止:

  • 刚释放锁
  • 其他线程已经完成了缓存重建
  • 避免重复查询数据库

六、分布式锁实现(简洁但有效)

setIfAbsent(key, "1", 10, TimeUnit.SECONDS);

特点:

  • 原子性(Redis 保证)
  • 有过期时间,避免死锁
  • 性能高,足够应付大多数业务场景

七、总结:这套缓存方案解决了什么?

问题 解决方案
缓存穿透 缓存空值
缓存击穿 逻辑过期
高并发 互斥锁
性能 异步线程池
代码复用 CacheClient 封装

八、总结

缓存不是"加上 Redis 就完事了",而是一整套并发与一致性的设计。


这一套 CacheClient 并不是“给 Redis 加了一层工具封装”,而是围绕高并发读场景下缓存失效问题,系统性地给出了一套可落地的解决方案。

它解决的不是“如何用 Redis”,而是三个更本质的问题:

  • 当数据不存在时,如何保护数据库?
  • 当数据过期时,如何避免并发打爆数据库?
  • 当系统高并发访问时,如何在性能和一致性之间做取舍?

8.1 缓存的核心定位:数据库的“缓冲层”而不是“强一致副本”

在这套设计中,缓存的首要目标并不是保证数据绝对新鲜,而是:

为数据库挡住绝大多数请求压力

因此你会看到几个明显的设计取舍:

  • 允许缓存返回逻辑过期数据
  • 允许短时间的数据不一致
  • 优先保证系统可用性,而非强一致性

这是一种典型的高并发读多写少场景设计思路,也是生产环境中最常见、最现实的选择。


8.2 缓存穿透的总结:用“空值”堵住所有无效请求

缓存穿透的本质,是请求的数据在数据库中根本不存在。

本方案通过 缓存空值 解决该问题:

  • 第一次查询数据库
  • 数据不存在时,向 Redis 写入空字符串
  • 后续所有相同请求全部被 Redis 拦截

这种做法的特点是:

  • 实现简单
  • 性能极高
  • 能 100% 保护数据库

代价是:

  • 短时间内数据可能不可见
  • 需要合理设置空值的过期时间

这是一次用时间换稳定性的设计取舍。


8.3 缓存击穿的总结:用逻辑过期替代物理过期

缓存击穿真正危险的地方,并不是“过期”,而是:

热点 Key 在同一时刻失效

这套方案通过引入 RedisData,将缓存从:

Redis 物理过期 → 业务逻辑过期

带来的改变包括:

  • Redis 中始终有数据
  • 是否过期由业务代码判断
  • 即使过期,也可以继续对外提供服务

这种设计从根本上避免了“缓存瞬间失效”的问题。


8.4 异步缓存重建:把“慢操作”移出用户请求链路

在缓存逻辑过期后:

  • 用户线程只负责读取并返回数据
  • 缓存重建交给独立线程池异步执行

这样做的直接结果是:

  • 用户请求不会被数据库查询阻塞
  • 数据库的并发访问被严格限制
  • 系统整体吞吐量保持稳定

这是高并发系统中非常关键的一步设计


8.5 分布式锁的真实作用:限流,而不是强一致

这里使用 Redis 分布式锁的目的,并不是为了保证数据绝对一致,而是:

防止多个线程同时重建同一份缓存

锁的特点是:

  • 锁粒度小(只锁重建逻辑)
  • 持有时间短
  • 不影响正常读请求

这是一个克制、实用、成本极低的并发控制方案。


8.6 双重检查机制:防止重复重建缓存

在获取锁失败后再次读取 Redis,本质上是一个 Double Check

  • 避免其他线程已经完成缓存重建
  • 减少不必要的数据库访问
  • 提高整体系统效率

这一步虽然简单,但在高并发下价值非常大。


8.7 整体方案的适用场景与边界

这套 CacheClient 非常适合:

  • 读多写少的业务
  • 存在明显热点数据
  • 对可用性要求高于一致性的系统

但它并不适合:

  • 强一致性业务(如账户余额)
  • 写操作频繁、对实时性要求极高的场景

8.8 最终结论

这套缓存方案的核心思想可以概括为:

  • 用 Redis 挡流量
  • 用逻辑过期保可用
  • 用异步重建控并发
  • 用适度不一致换系统稳定
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐