

摘要:整体来说就是把我们之前对一个业务的操作抽象出一个类,然后从特殊性到普遍性。
本文介绍了Redis缓存工具类的设计与实现,重点解决缓存穿透、击穿和雪崩三大问题。 通过泛型化和函数式接口实现通用性,提供三种核心方案:空值缓存应对穿透、互斥锁防止击穿、逻辑过期优化性能。 工具类采用包装类存储逻辑过期时间,支持异步更新和双检机制,并详细对比了各方案的适用场景。 文章还包含线程池配置、死锁预防等注意事项,以及形象化的快递柜比喻帮助理解。该工具类可灵活应用于店铺查询等场景,实现高性能缓存管理。
一、整体架构概览 这个工具类解决三个核心问题:
java
public class RedisData {
private LocalDateTime expireTime; // 逻辑过期时间
private Object data; // 实际数据
}为什么需要这个包装类?
传统 Redis 过期是物理删除(时间到了自动删),逻辑过期是自己维护过期标志:
json
// 存入Redis的实际格式
{
"expireTime": "2024-12-31T23:59:59",
"data": {"id": 1, "name": "海底捞"}
}好处:
java
public <R, ID> R queryWithMutex(
String keyPrefix,
ID id, // 泛型ID:可以是Long、String、Integer
Class<R> type, // 泛型R:返回类型
Function<ID, R> dbFallback, // 函数式接口
Long timeout,
TimeUnit unit
)泛型 | 含义 | 实际使用示例 |
|---|---|---|
<R, ID> | 声明两个泛型类型 | Shop, Long |
R | 返回的数据类型 | Shop, User, Product |
ID | 查询ID的类型 | Long, String, Integer |
java
// 没有泛型:每个类型都要写一个方法
public Shop queryShopById(Long id) { ... }
public User queryUserById(Long id) { ... }
public Product queryProductById(String id) { ... }
// 有泛型:一个方法通吃
public <R, ID> R queryById(ID id, Class<R> type) { ... }
// 调用示例
Shop shop = queryById(1L, Shop.class);
User user = queryById(100L, User.class);
Product product = queryById("P001", Product.class);java
Function<ID, R> dbFallback
// 参数类型 ID → 返回值类型 R
// 使用时传入方法引用
this::getById // getById(Long id) 返回 Shop
this::getUserById // getUserById(Long id) 返回 User问题场景:
text
攻击者请求 id = -1 的数据
→ 缓存没有
→ 数据库也没有
→ 每次都查数据库
→ 数据库压力剧增解决原理:空值缓存
java
// 流程图
请求 id = -1
↓
查缓存 → 没有
↓
查数据库 → 返回 null
↓
缓存空值:set key "" 过期2分钟
↓
下次请求 → 查到空值 → 直接返回 null代码关键点:
java
// 判断空值缓存
if (json != null) {
return null; // 说明缓存的是空字符串
}
// 缓存空值
if (result == null) {
stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
return null;
}注意:空字符串和 null 的区别
null:缓存不存在
"":缓存存在,但值是空的(表示数据库没有这条数据)
问题场景:
text
热点数据过期瞬间
1000个请求同时发现缓存失效
全部去查数据库
数据库压力爆炸解决原理:只让一个线程去查数据库,其他线程等待
java
// 流程图
请求1 ──→ 发现缓存失效 ──→ 获取锁成功 ──→ 查DB ──→ 写缓存 ──→ 释放锁 ──→ 返回
请求2 ──→ 发现缓存失效 ──→ 获取锁失败 ──→ 休眠50ms ──→ 递归重试
请求3 ──→ 发现缓存失效 ──→ 获取锁失败 ──→ 休眠50ms ──→ 递归重试DoubleCheck 的必要性:
java
// 场景:请求1重建缓存期间,请求2在等待
// 请求1释放锁后,请求2拿到锁
// 如果没有DoubleCheck,请求2会再次查数据库
// 有DoubleCheck:
String newJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(newJson)) {
return JSONUtil.toBean(newJson, type); // 直接返回,不再查DB
}锁的细节:
java
private boolean tryLock(String key) {
// setIfAbsent = SETNX(不存在才设置)
// 过期时间10秒:防止死锁(如果线程挂了,锁自动释放)
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}问题场景: 互斥锁方案中,等待的线程会被阻塞,影响性能
解决原理:永远不阻塞,直接返回旧数据,后台异步更新
java
// 流程图
请求 ──→ 查缓存
↓
未过期 ──→ 返回最新数据
↓
已过期 ──→ 尝试获取锁
├── 获取锁成功 ──→ 提交异步任务 ──→ 立即返回旧数据
└── 获取锁失败 ──→ 立即返回旧数据关键代码:
java
// 异步重建(不等待)
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
R newData = dbFallback.apply(id);
setWithLogicalExpire(key, newData, expireSeconds);
} finally {
unlock(lockKey); // 必须在finally释放
}
});
// 立即返回旧数据(不等待重建完成)
return data;维度 | 穿透方案 | 互斥锁方案 | 逻辑过期方案 |
|---|---|---|---|
核心思想 | 空值缓存 | 只让一个线程查DB | 异步刷新,先返回旧数据 |
响应时间 | 快 | 可能等待(最坏100ms) | 极快(无等待) |
数据一致性 | 强一致 | 强一致 | 最终一致 |
数据库压力 | 无 | 极小 | 极小 |
适用场景 | 恶意请求 | 一致性要求高 | 高并发、容忍短暂不一致 |
缺点 | 浪费一点内存 | 等待线程会阻塞 | 可能返回旧数据 |
java
@Service
public class ShopServiceImpl implements IShopService {
@Autowired
private RedisCacheClient redisCacheClient;
@Autowired
private ShopMapper shopMapper;
private static final String CACHE_SHOP_KEY = "cache:shop:";
}java
public Shop queryShopById(Long id) {
return redisCacheClient.queryWithMutex(
CACHE_SHOP_KEY, // key前缀
id, // 查询id
Shop.class, // 返回类型
this::getById, // 数据库查询方法
30L, // 过期时间
TimeUnit.MINUTES // 时间单位
);
}
// 数据库查询方法
private Shop getById(Long id) {
return shopMapper.selectById(id);
}java
public Shop queryShopWithLogicalExpire(Long id) {
// 注意:逻辑过期需要提前预热缓存
return redisCacheClient.queryWithLogicalExpire(
CACHE_SHOP_KEY,
id,
Shop.class,
this::getById,
30L // 逻辑过期时间(秒)
);
}
// 缓存预热
@PostConstruct
public void initCache() {
List<Shop> shops = shopMapper.selectList(null);
for (Shop shop : shops) {
redisCacheClient.setWithLogicalExpire(
CACHE_SHOP_KEY + shop.getId(),
shop,
30L
);
}
}java
// 当前是固定大小线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
// 生产环境建议使用自定义线程池
@Bean
public ThreadPoolTaskExecutor cacheRebuildExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("cache-rebuild-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}java
// 锁必须在finally中释放
try {
// 业务逻辑
} finally {
unlock(lockKey); // 确保释放
}java
// 互斥锁方案中的递归调用
if (!isLock) {
Thread.sleep(50);
return queryWithMutex(...); // 递归
}风险:可能无限递归 解决:设置重试次数限制
java
private static final int MAX_RETRY = 10;
int retryCount = 0;
if (!isLock && retryCount++ < MAX_RETRY) {
Thread.sleep(50);
return queryWithMutex(...);
}java
// 空值缓存不能太长,避免真正数据写入后还返回空
stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);逻辑过期方案要求缓存必须提前存在,否则会返回 null:
java
// 系统启动时预热
@PostConstruct
public void warmUpCache() {
// 加载热点数据到缓存
List<Long> hotShopIds = getHotShops();
for (Long id : hotShopIds) {
Shop shop = shopMapper.selectById(id);
redisCacheClient.setWithLogicalExpire(key, shop, 30L);
}
}你要解决什么问题 | 用哪个方法 |
|---|---|
有人用不存在的ID恶意请求 | queryWithPassThrough |
热点数据过期,要求数据绝对一致 | queryWithMutex |
超高并发,允许短暂不一致 | queryWithLogicalExpire |
核心记忆点:
想象一下,你开了一家店,店里有个快递柜(Redis),里面放着顾客常买的东西(缓存数据)。
场景:有个捣乱的小孩,不停地按快递柜上不存在的柜子号码(比如第100号,但只有1-50号)。
解决方案(空值缓存):
text
在那些不存在的柜子上贴张纸条:“这个柜子不存在,别找了!”
下次小孩再来按 → 看到纸条 → 直接走人,不麻烦仓库管理员场景:店里最火的商品(比如最新款iPhone)的快递柜刚好到期打开,外面1000个顾客同时伸手去拿。
解决方案1(互斥锁):
text
给柜子装一把锁 🔒
- 第一个顾客:拿到锁 → 去仓库取货 → 放进柜子 → 开锁
- 其他顾客:看到锁着 → 在门口排队等
- 等第一个放好货 → 大家直接从柜子拿→ 像排队买票,慢但不会乱
解决方案2(逻辑过期):
text
柜子上贴个标签:“商品已过期,但还能用,新货正在补”
- 第一个顾客:发现过期 → 通知店员去仓库补货 → 自己先拿旧货走
- 后面999个:发现过期 → 看到已经有店员去了 → 也直接拿旧货走
- 店员后台慢慢补货,不影响顾客→ 像外卖APP显示“30分钟送达”,先让你下单,后台慢慢做
场景:快递柜里100个商品设置了同一个过期时间,到点了全部一起打开。
解决方案:
text
设置过期时间时加点随机数:
- 商品A:30分钟 + 随机1-5分钟
- 商品B:30分钟 + 随机3-7分钟
→ 不会同时过期方案 | 比喻 | 流程 | 优缺点 |
|---|---|---|---|
缓存穿透 | 有人点不存在的“星空奶茶” | 第一次:查菜单没有 → 贴告示“没有这款” 以后:看到告示直接说没有 | 防止捣乱 |
互斥锁 | 招牌奶茶卖完了,店员去仓库补货 | 第1个顾客:拿到“补货牌” → 等5分钟拿到奶茶 后面顾客:看到有人拿着牌 → 排队等 | 强一致,但要等 |
逻辑过期 | 奶茶显示“已售罄,但可预订” | 所有顾客:直接预订(立即拿到预订券) 后台:慢慢做奶茶 | 快,但拿到的不是真正的奶茶 |
没有泛型:你要准备3个不同的盒子
text
BoxForApple 盒子(只能装苹果)
BoxForBanana 盒子(只能装香蕉)
BoxForOrange 盒子(只能装橘子)有泛型:只准备1个万能盒子
text
Box<T> 万能盒子
- Box<Apple> → 装苹果
- Box<Banana> → 装香蕉
- Box<Orange> → 装橘子代码对应:
java
// 没有泛型:每种类型写一个方法
Shop getShop(Long id) { ... }
User getUser(Long id) { ... }
Product getProduct(String id) { ... }
// 有泛型:一个方法搞定
<R, ID> R getData(ID id, Class<R> type) { ... }
// 使用
Shop shop = getData(1L, Shop.class); // 装的是Shop
User user = getData(100L, User.class); // 装的是Usertext
时间线:
T1: 小明和小红同时看到柜子空
T2: 小明拿到锁,去仓库取货
T3: 小红没拿到锁,在门口等
T4: 小明取完货,放好,开锁
T5: 小红拿到锁
【如果没有DoubleCheck】
T6: 小红:柜子空吗?不知道 → 再去仓库取一次(浪费时间)
【如果有DoubleCheck】
T6: 小红:先看看柜子 → 发现有货了 → 直接拿,不去仓库 ✅DoubleCheck = 拿到锁后,再看一眼柜子有没有货
普通过期(物理过期):
text
牛奶到保质期 → 直接扔掉 → 下次买要等逻辑过期:
text
牛奶过最佳饮用期 → 贴标签“已过期,但还能喝”
- 顾客:看到标签 → 直接拿走喝(不等新牛奶)
- 店员:后台慢慢补新牛奶核心:过期不等同于不可用,旧数据还能用,新数据慢慢补
text
顾客 → 看柜子
├─ 有货 → 拿走 ✅
└─ 没货 → 看谁拿着锁
├─ 没人拿 → 拿锁 → 去仓库取货 → 放柜子 → 开锁 → 拿走
└─ 有人拿 → 排队等待 → 等锁释放 → 从柜子拿text
顾客 → 看柜子
├─ 没过期 → 拿走 ✅
└─ 已过期 → 看谁拿着锁
├─ 没人拿 → 拿锁 → 喊店员去补货(不等待)→ 拿旧货走
└─ 有人拿 → 直接拿旧货走问题:ID 不一定是 Long 类型
java
// ❌ 只支持Long
public Shop query(Long id) { ... }
// ✅ 支持任意类型
public <R, ID> R query(ID id, Class<R> type) { ... }
// 使用示例
query(1L, Shop.class); // Long类型
query("P001", Product.class); // String类型
query(1001, User.class); // Integer类型问题:不能只为 Shop 服务
java
// ❌ 只能返回Shop
public Shop queryShop(Long id) { ... }
// ✅ 返回任意类型
public <R, ID> R query(ID id, Class<R> type) { ... }
// 使用示例
Shop shop = query(1L, Shop.class);
User user = query(100L, User.class);
Product product = query("P001", Product.class);问题:每个表的查询方法不同
java
// ❌ 写死查询方法
Shop shop = shopMapper.selectById(id);
// ✅ 通过函数式接口传入
Function<ID, R> dbFallback // 调用方自己决定怎么查
// 使用示例
this::getById // Shop表
this::getUserById // User表
this::getProductById // Product表问题:不同业务用不同前缀
java
// ❌ 写死前缀
String key = "cache:shop:" + id;
// ✅ 作为参数传入
String keyPrefix // 调用方自己定义
// 使用示例
queryWithMutex("cache:shop:", id, ...); // 店铺缓存
queryWithMutex("cache:user:", id, ...); // 用户缓存
queryWithMutex("cache:product:", id, ...); // 商品缓存问题:不同数据过期时间不同
java
// ❌ 写死30分钟
Long timeout = 30L;
// ✅ 作为参数传入
Long timeout, TimeUnit unit
// 使用示例
queryWithMutex(..., 30L, TimeUnit.MINUTES); // 30分钟
queryWithMutex(..., 10L, TimeUnit.SECONDS); // 10秒
queryWithMutex(..., 2L, TimeUnit.HOURS); // 2小时问题:空值缓存时间写死了2分钟
java
// 当前代码(写死)
stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
// 改进:作为参数
public <R, ID> R queryWithPassThrough(
...,
Long nullTimeout, // 空值过期时间
TimeUnit nullUnit
) {
if (result == null) {
stringRedisTemplate.opsForValue().set(key, "", nullTimeout, nullUnit);
return null;
}
}问题:锁的过期时间写死了10秒
java
// 当前代码(写死)
private boolean tryLock(String key) {
setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
}
// 改进:可配置
private boolean tryLock(String key, Long timeout, TimeUnit unit) {
setIfAbsent(key, "1", timeout, unit);
}
// 或者使用常量
private static final long LOCK_TIMEOUT = 10;
private static final TimeUnit LOCK_UNIT = TimeUnit.SECONDS;问题:互斥锁方案中无限递归,可能栈溢出
java
// 当前代码(危险)
if (!isLock) {
Thread.sleep(50);
return queryWithMutex(...); // 无限递归
}
// 改进:限制重试次数
public <R, ID> R queryWithMutex(
...,
int maxRetry // 最大重试次数
) {
return queryWithMutex(..., maxRetry, 0); // 重试计数器
}
private <R, ID> R queryWithMutex(
...,
int maxRetry,
int retryCount
) {
if (!isLock) {
if (retryCount >= maxRetry) {
// 超过重试次数,直接查数据库(降级)
return dbFallback.apply(id);
}
Thread.sleep(50);
return queryWithMutex(..., maxRetry, retryCount + 1);
}
}问题:写死的固定大小线程池
java
// 当前代码
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
// 改进:可配置 + 使用Spring线程池
@Component
public class RedisCacheClient {
@Autowired
private ThreadPoolTaskExecutor cacheRebuildExecutor; // 从配置文件读取
// 或者使用配置文件
@Value("${redis.cache.thread-pool-size:10}")
private int threadPoolSize;
@PostConstruct
public void init() {
CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(threadPoolSize);
}
}application.yml:
yaml
redis:
cache:
thread-pool-size: 20 # 线程池大小
lock-timeout: 10 # 锁超时时间(秒)
null-cache-timeout: 2 # 空值缓存时间(分钟)
max-retry: 3 # 最大重试次数问题 | 原因 | 解决方案 | 黑马代码体现 |
|---|---|---|---|
缓存穿透 | 查询不存在的数据 | 1. 缓存空对象 2. 布隆过滤器 | ShopServiceImpl.queryById 缓存 null 对象 |
缓存雪崩 | 大量key同时失效 | 1. TTL随机 2. 集群 3. 多级缓存 | 商铺缓存设置不同TTL |
缓存击穿 | 热点key失效 | 1. 互斥锁 2. 逻辑过期 | RedisData 对象 + logicalExpire 字段 |
缓存更新一致性 | 数据变更后缓存脏读 | 更新DB后删除缓存 | updateShop 方法中先更新DB,再 delete 缓存 |
工具封装 | 重复造轮子 | 泛型 + 函数式接口 | CacheClient 类(setWithLogicalExpire、queryWithMutex 等) |