Redis 缓存穿透与缓存击穿解决(依据黑马点评项目CacheClient工具类代码解析)
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
会带来几个问题:
- 代码重复、难维护
- 不同开发者实现方式不一致
- 缓存问题(穿透、击穿)处理不统一
正确做法:
👉 把缓存访问的"套路"抽象成一个统一的工具类
这正是 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 没有这个 keyjsonStr == ""→ 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 挡流量
- 用逻辑过期保可用
- 用异步重建控并发
- 用适度不一致换系统稳定
更多推荐




所有评论(0)