day33 缓存击穿方案与缓存工具类封装

day33 缓存击穿方案与缓存工具类封装

一、缓存击穿概述

1.1 什么是缓存击穿

缓存击穿,也叫热点 Key 问题,是指一个被高并发访问且缓存重建业务较复杂的 key 突然失效了,无数请求会在瞬间同时打到数据库,带来巨大冲击。

与缓存穿透的区别:

问题 原因 特点
缓存穿透 数据在缓存和数据库中都不存在 请求的数据本身就不存在
缓存击穿 热点 key 过期,缓存重建期间大量请求到达 数据存在,只是缓存恰好失效了

1.2 产生原因

缓存击穿的产生需要同时满足三个条件:

  1. 高并发访问:该 key 的访问量很大
  2. 缓存重建耗时较长:查询数据库 + 组装数据的过程不是瞬间完成的
  3. 缓存恰好失效:TTL 到期,缓存被清除

在缓存重建的这段时间窗口内,所有请求都无法从缓存中获取数据,只能直接查询数据库,导致数据库压力骤增。


二、互斥锁方案

2.1 核心思路

为”查询数据库 + 重建缓存”的过程加上互斥锁,确保同一时刻只有一个线程去查数据库并重建缓存,其他线程等待缓存重建完成后再从缓存中读取数据。

1
2
3
线程1: 缓存未命中 → 获取锁 → 查数据库 → 重建缓存 → 释放锁 → 返回
线程2: 缓存未命中 → 获取锁失败 → 休眠重试 → 缓存命中 → 返回
线程3: 缓存未命中 → 获取锁失败 → 休眠重试 → 缓存命中 → 返回

2.2 Redis 实现互斥锁

项目中使用 Redis 的 setIfAbsent(即 SETNX)命令实现互斥锁:

1
2
3
4
5
6
7
8
9
private boolean tryLock(String lockKey) {
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10L, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}

private void unlock(String lockKey) {
stringRedisTemplate.delete(lockKey);
}

要点说明:

  • setIfAbsent:只有 key 不存在时才设置成功,返回 true;key 已存在则返回 false。利用这一特性实现互斥

  • 设置过期时间:防止锁因异常无法释放(如业务代码抛异常、服务宕机),导致死锁

  • BooleanUtil.isTrue(flag):避免自动拆箱时空指针,因为 setIfAbsent 返回 Boolean 包装类型

锁的 key 格式为 lock:shop:{id},每个商铺独立一把锁,互不影响。

2.3 完整实现

ShopServiceImpl.queryWithMutex 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
private Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);

// 2. 缓存命中且有数据,直接返回
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, Shop.class);
}
// 3. 缓存命中但为空值(缓存穿透的空对象),返回 null
if (shopJson != null) {
return null;
}
// 4. 缓存未命中,尝试获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
if (!isLock) {
// 4.1 获取锁失败,休眠后重试
Thread.sleep(50);
return queryWithMutex(id); // 递归调用
}
// 4.2 获取锁成功,查询数据库
shop = getById(id);
if (shop == null) {
// 数据库不存在,缓存空值(解决缓存穿透)
stringRedisTemplate.opsForValue()
.set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 5. 重建缓存,TTL 加随机值(解决缓存雪崩)
stringRedisTemplate.opsForValue()
.set(key, JSONUtil.toJsonStr(shop),
CACHE_SHOP_TTL + RandomUtil.randomLong(0L, 10L), TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 6. 释放锁(放在 finally 中确保一定执行)
unlock(lockKey);
}
return shop;
}

关键细节:

  • 递归重试:获取锁失败后休眠 50ms 再递归调用,直到获取到锁或缓存命中

  • finally 释放锁:无论业务是否异常,都要释放锁

  • 兼容缓存穿透:空值缓存逻辑仍然保留

2.4 优缺点

优点 缺点
实现简单,逻辑清晰 线程需要等待,有性能损耗
无额外内存占用(除了锁 key) 极端情况下可能出现死锁(靠 TTL 兜底)
保证数据一致性,重建完成后读到的都是新数据 高并发下大量线程自旋重试,消耗 CPU

三、逻辑过期方案

3.1 核心思路

逻辑过期方案不依赖 Redis 的物理 TTL,而是将过期时间作为数据的一部分存储在 value 中。key 永不过期,请求始终能命中缓存。当发现数据逻辑过期时,获取互斥锁并开启独立线程异步重建缓存,当前线程直接返回旧数据。

1
2
3
线程1: 命中缓存 → 发现逻辑过期 → 获取锁 → 开启新线程重建缓存 → 返回旧数据
线程2: 命中缓存 → 发现逻辑过期 → 获取锁失败 → 直接返回旧数据
线程3: 命中缓存 → 未过期 → 直接返回数据

3.2 RedisData 封装类

使用 RedisData 封装业务数据和逻辑过期时间,不侵入原始实体类:

1
2
3
4
5
@Data
public class RedisData {
private LocalDateTime expireTime; // 逻辑过期时间
private Object data; // 实际业务数据
}

缓存中存储的是 RedisData 的 JSON 字符串,结构如下:

1
2
3
4
{
"expireTime": "2026-09-08T23:56:24",
"data": { "id": 1, "name": "茶百道", ... }
}

3.3 缓存预热

逻辑过期方案要求热点数据提前预热到 Redis 中。项目通过 saveShop2Redis 方法写入:

1
2
3
4
5
6
7
8
9
public void saveShop2Redis(Long id, Long expireSeconds) {
Shop shop = getById(id);
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 注意:没有设置物理 TTL,key 永久存在
stringRedisTemplate.opsForValue()
.set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}

可以通过单元测试或启动时调用该方法,将热点商铺数据预先写入 Redis。

3.4 完整实现

ShopServiceImpl.queryWithLogicalExpire 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 缓存重建线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR =
Executors.newFixedThreadPool(10);

private Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);

// 2. 缓存未命中,直接返回 null(热点数据必须提前预热)
if (StrUtil.isBlank(shopJson)) {
return null;
}

// 3. 缓存命中,反序列化为 RedisData
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();

// 4. 判断是否逻辑过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 4.1 未过期,直接返回
return shop;
}

// 5. 已过期,尝试获取锁进行缓存重建
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);

if (isLock) {
// 5.1 获取锁成功,提交异步任务重建缓存
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
saveShop2Redis(id, CACHE_SHOP_TTL * 60);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
});
}

// 6. 无论是否获取到锁,都返回旧数据
return shop;
}

关键细节:

  • 缓存未命中直接返回 null:这是故意设计的,逻辑过期方案只适用于已预热的热点数据

  • 异步重建:获取锁的线程通过线程池提交重建任务,自身不等待,直接返回旧数据

  • 线程池:使用固定大小的线程池控制并发重建的线程数量

  • 数据类型转换:redisData.getData() 返回的是 Object,实际是 JSONObject,需要强转后再反序列化为 Shop

3.5 优缺点

优点 缺点
线程无需等待,性能好 实现复杂,需要额外的 RedisData 封装
始终返回数据,用户体验好 返回的可能是过期数据,不保证强一致性
只有一个线程做缓存重建,数据库压力小 需要提前预热热点数据,不适合普通数据
key 不设物理 TTL,永不过期 额外占用内存存储过期时间字段

四、两种方案对比

对比维度 互斥锁 逻辑过期
数据一致性 强一致,重建完成后才返回新数据 弱一致,过期期间返回旧数据
性能 线程需要等待,有阻塞 始终快速返回,无阻塞
实现复杂度 简单 较复杂(线程池、RedisData、预热)
内存占用 几乎无额外占用 额外存储过期时间字段
适用场景 普通数据,对一致性要求较高 热点数据,对响应速度要求高、可接受短暂不一致
数据预热 不需要,缓存未命中自动查库 必须提前预热
死锁风险 有(靠 TTL 兜底) 有(靠 TTL 兜底)

五、逻辑过期方案的设计思考

5.1 为什么缓存未命中直接返回 null

逻辑过期方案的核心假设是”缓存始终存在“——因为 key 不设物理过期时间,只要预热过就一直在 Redis 中。

如果缓存未命中时去查数据库,会产生两个问题:

  1. 首次访问即击穿:高并发场景下,第一次访问某个 key 时,大量请求同时打到数据库,本质上就是缓存击穿问题本身
  2. 违背设计目标:逻辑过期方案的目标是”请求不等待、快速返回”,如果要查数据库就需要等待,违背了这个目标

所以逻辑过期方案故意在缓存未命中时返回 null,强制要求热点数据必须提前预热。

5.2 实际项目中的使用方式

实际项目中不会只用逻辑过期一种方案,而是多种方案并存,根据数据类型选择不同策略:

数据类型 适用方案 说明
普通数据 互斥锁 + 缓存穿透(queryWithMutex) 正常缓存查询,未命中查数据库,用互斥锁防止击穿
热点数据 逻辑过期(queryWithLogicalExpire) 提前预热到 Redis,过期后异步重建,始终返回旧数据

六、缓存工具类封装:把函数当参数

把第二、三章中写死在 ShopServiceImpl 里的缓存逻辑抽取成独立的工具类后,发现方法参数里出现了一个”函数”,这是今天第一次接触的写法,单独整理。

6.1 为什么要封装成工具类

第二、三章的 queryWithPassThrough、queryWithMutex、queryWithLogicalExpire 都写在 ShopServiceImpl 中,存在两个问题:

  1. 逻辑无法复用:缓存穿透、缓存击穿的处理思路与具体实体无关,店铺、店铺类型等任何实体都可能需要,但方法里写死了 Shop、getById
  2. Service 越来越臃肿:加锁、反序列化、缓存空值等基础设施代码混在业务代码里

把缓存操作抽成独立的 @Component 工具类 CacheClint(讲义中命名为 CacheClient),通过构造器注入 StringRedisTemplate,Service 只保留业务调用。

6.2 工具类提供的方法

方法 用途
set(key, value, time, unit) 普通写入,带物理 TTL
setWithLogicalExpire(key, value, time, unit) 写入逻辑过期数据(用于热点数据预热)
queryWithPassThrough(prefix, id, type, dbFallback, time, unit) 解决缓存穿透的通用查询
queryWithMutex(prefix, lockKeyPrefix, id, type, dbFallback, time, unit) 互斥锁解决缓存击穿的通用查询
queryWithLogicalExpire(prefix, lockKeyPrefix, id, type, dbFallback, time, unit) 逻辑过期解决缓存击穿的通用查询

6.3 泛型方法签名解析

以 queryWithPassThrough 为例(其余两个查询方法同理):

1
2
3
4
5
6
public <R, ID> R queryWithPassThrough(
String prefix, ID id, Class<R> type,
Function<ID, R> dbFallback, Long time, TimeUnit unit
) {
...
}

逐个拆开看:

组成 含义
<R, ID> 方法级别的泛型声明,放在修饰符之后、返回值之前,作用域只在本方法内
R 返回值类型,也用于反序列化的目标类型,由调用方在调用时确定(如 Shop)
ID 主键类型(如 Long),缓存 key 拼接、传给查询函数都用它
prefix 缓存 key 前缀,如 cache:shop:,工具类内部用 prefix + id 拼出完整 key
type Class<R>,因为泛型在运行期会被擦除,必须显式传入 Class 才能完成 JSONUtil.toBean(json, type) 反序列化
dbFallback 兜底查数据库的回调函数,类型是 Function<ID, R>
time / unit 过期时间与单位,写入缓存与空值缓存时使用

6.4 Function<ID,R>:函数也能当参数

Function 是什么

java.util.function.Function<T, R> 是 JDK 8 提供的函数式接口,语义是”接收一个 T,返回一个 R“,只声明了一个抽象方法:

1
R apply(T t);

为什么函数能当参数传

Java 中”函数”不能像 C 语言函数指针那样直接传递,但函数式接口只有一个抽象方法,因此可以用Lambda 表达式或方法引用为它提供实现,把一个”行为”打包成对象后作为参数传递。这在 Java 8 之后成为一等公民式的写法,称为行为参数化。

dbFallback 表达的行为

1
Function<ID, R> dbFallback

含义是:调用方传进来一段”根据 ID 去数据库查数据并返回 R”的逻辑。工具类不知道也不关心数据库怎么查,只约定好”你给我一个函数,需要时我调用它”。

调用方怎么传函数:方法引用

ShopServiceImpl 中调用:

1
2
3
Shop shop = cacheClint.queryWithPassThrough(
CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES
);

this::getById 是方法引用,等价于 Lambda id -> getById(id)。因为 MyBatis-Plus 提供的 getById(Long id) 接收一个 Long、返回实体对象,签名正好和 Function<Long, Shop> 匹配,所以方法本身可以直接作为参数传入。

工具类内部如何调用传入的函数

工具类不知道方法名,只通过函数式接口的 apply 方法触发执行:

1
2
// 缓存未命中,调用传入的查询函数去数据库查
R r = dbFallback.apply(id);

dbFallback.apply(id) 这一句执行时,实际调用的是 ShopServiceImpl.getById(id),工具类拿到的只是结果 r。

6.5 工具类完整代码(queryWithPassThrough)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Component
public class CacheClint {

private final StringRedisTemplate stringRedisTemplate;

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

/**
* 通过 id 查询并缓存,解决缓存穿透
* @param dbFallback 回调函数,调用方传入"根据 id 查数据库"的逻辑
*/
public <R, ID> R queryWithPassThrough(
String prefix, ID id, Class<R> type,
Function<ID, R> dbFallback, Long time, TimeUnit unit
) {
String key = prefix + id;
// 1. 查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2. 命中且不为空,直接返回
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type);
}
// 3. 命中但为空值(之前缓存的空对象),返回 null
if (json != null) {
return null;
}
// 4. 未命中,调用传入的查询函数查数据库
R r = dbFallback.apply(id);
// 5. 数据库不存在,缓存空值并返回 null
if (r == null) {
stringRedisTemplate.opsForValue().set(key, "", time, unit);
return null;
}
// 6. 查到数据,写入缓存并返回
set(key, r, time, unit);
return r;
}
}

queryWithMutex、queryWithLogicalExpire 只是在此基础上叠加锁、逻辑过期判断,结构相同,同样用 dbFallback.apply(id) 触发查库。

6.6 封装后的调用与收益

封装后 ShopServiceImpl 中原本几十行的缓存逻辑收敛为一次调用(项目当前启用的是互斥锁版本):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Autowired
private CacheClint cacheClint;

public Result queryShopById(Long id) {
// 互斥锁方案:实体类型按项目实际情况传入(此处为 R.class)
R r = cacheClint.queryWithMutex(
CACHE_SHOP_KEY, LOCK_SHOP_KEY, id, R.class,
this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES
);
if (r == null) {
return Result.fail("商铺不存在");
}
return Result.ok(r);
}

收益总结:

  • 复用:店铺、店铺类型等任意实体调用同一个工具方法即可获得缓存穿透、击穿处理能力

  • 解耦:工具类只依赖 StringRedisTemplate 和泛型参数,不依赖任何 Service、实体

  • 行为参数化:方法参数里传入的不是数据而是行为(查数据库的逻辑),这是函数式接口与泛型配合的典型用法

6.7 注意事项

  • 泛型声明 <R, ID> 必须写在修饰符 public 之后、返回值 R 之前;类型参数的作用域只限当前方法,不能在其他方法中使用

  • 泛型在运行期会被擦除,工具类内不能直接写 R.class 或 new R(),所以方法额外接收 Class<R> type 用于 JSON 反序列化

  • 方法引用 this::getById 与被引用的函数式接口要签名兼容:参数个数、类型、返回值需匹配,否则编译报错

  • dbFallback.apply(id) 由工具类在缓存未命中时才调用,调用方传的 Lambda 里可以有任意查库逻辑,不限于 getById

  • 项目中的工具类名为 CacheClint,讲义示例为 CacheClient,属同一类,注意区分导入路径


今日总结

模块 核心要点
缓存击穿 热点 key 过期 + 高并发 + 重建耗时 → 大量请求直达数据库
互斥锁方案 用 setIfAbsent 实现互斥,未抢到锁的线程休眠重试,保证只有一个线程重建缓存
互斥锁要点 锁必须设 TTL 防止死锁;释放锁放在 finally 中;递归重试等待缓存重建
逻辑过期方案 过期时间存在 value 中,key 永不过期;过期后异步重建,始终返回旧数据
逻辑过期要点 使用 RedisData 封装数据和过期时间;需要提前预热热点数据;用线程池做异步重建
方案选择 普通数据用互斥锁(强一致、需等待);热点数据用逻辑过期(高性能、可接受短暂不一致)
设计思考 逻辑过期未命中直接返回 null 是故意设计,强制要求热点数据预热;实际项目中多种方案并存
工具类封装 缓存穿透/击穿逻辑抽为通用 CacheClint,任意实体可复用
函数式参数 Function<ID,R> dbFallback 让函数作为参数传递,方法引用 this::getById 传入,工具类用 apply(id) 触发查库
泛型方法 <R,ID> 声明方法级泛型;配合 Class<R> 解决泛型擦除后的反序列化问题