day33 缓存击穿方案与缓存工具类封装
一、缓存击穿概述
1.1 什么是缓存击穿
缓存击穿,也叫热点 Key 问题,是指一个被高并发访问且缓存重建业务较复杂的 key 突然失效了,无数请求会在瞬间同时打到数据库,带来巨大冲击。
与缓存穿透的区别:
| 问题 | 原因 | 特点 |
|---|---|---|
| 缓存穿透 | 数据在缓存和数据库中都不存在 | 请求的数据本身就不存在 |
| 缓存击穿 | 热点 key 过期,缓存重建期间大量请求到达 | 数据存在,只是缓存恰好失效了 |
1.2 产生原因
缓存击穿的产生需要同时满足三个条件:
- 高并发访问:该 key 的访问量很大
- 缓存重建耗时较长:查询数据库 + 组装数据的过程不是瞬间完成的
- 缓存恰好失效:TTL 到期,缓存被清除
在缓存重建的这段时间窗口内,所有请求都无法从缓存中获取数据,只能直接查询数据库,导致数据库压力骤增。
二、互斥锁方案
2.1 核心思路
为”查询数据库 + 重建缓存”的过程加上互斥锁,确保同一时刻只有一个线程去查数据库并重建缓存,其他线程等待缓存重建完成后再从缓存中读取数据。
1 | 线程1: 缓存未命中 → 获取锁 → 查数据库 → 重建缓存 → 释放锁 → 返回 |
2.2 Redis 实现互斥锁
项目中使用 Redis 的 setIfAbsent(即 SETNX)命令实现互斥锁:
1 | private boolean tryLock(String lockKey) { |
要点说明:
setIfAbsent:只有 key 不存在时才设置成功,返回true;key 已存在则返回false。利用这一特性实现互斥设置过期时间:防止锁因异常无法释放(如业务代码抛异常、服务宕机),导致死锁
BooleanUtil.isTrue(flag):避免自动拆箱时空指针,因为setIfAbsent返回Boolean包装类型
锁的 key 格式为 lock:shop:{id},每个商铺独立一把锁,互不影响。
2.3 完整实现
ShopServiceImpl.queryWithMutex 方法:
1 | private Shop queryWithMutex(Long id) { |
关键细节:
递归重试:获取锁失败后休眠 50ms 再递归调用,直到获取到锁或缓存命中
finally释放锁:无论业务是否异常,都要释放锁兼容缓存穿透:空值缓存逻辑仍然保留
2.4 优缺点
| 优点 | 缺点 |
|---|---|
| 实现简单,逻辑清晰 | 线程需要等待,有性能损耗 |
| 无额外内存占用(除了锁 key) | 极端情况下可能出现死锁(靠 TTL 兜底) |
| 保证数据一致性,重建完成后读到的都是新数据 | 高并发下大量线程自旋重试,消耗 CPU |
三、逻辑过期方案
3.1 核心思路
逻辑过期方案不依赖 Redis 的物理 TTL,而是将过期时间作为数据的一部分存储在 value 中。key 永不过期,请求始终能命中缓存。当发现数据逻辑过期时,获取互斥锁并开启独立线程异步重建缓存,当前线程直接返回旧数据。
1 | 线程1: 命中缓存 → 发现逻辑过期 → 获取锁 → 开启新线程重建缓存 → 返回旧数据 |
3.2 RedisData 封装类
使用 RedisData 封装业务数据和逻辑过期时间,不侵入原始实体类:
1 |
|
缓存中存储的是 RedisData 的 JSON 字符串,结构如下:
1 | { |
3.3 缓存预热
逻辑过期方案要求热点数据提前预热到 Redis 中。项目通过 saveShop2Redis 方法写入:
1 | public void saveShop2Redis(Long id, Long expireSeconds) { |
可以通过单元测试或启动时调用该方法,将热点商铺数据预先写入 Redis。
3.4 完整实现
ShopServiceImpl.queryWithLogicalExpire 方法:
1 | // 缓存重建线程池 |
关键细节:
缓存未命中直接返回 null:这是故意设计的,逻辑过期方案只适用于已预热的热点数据
异步重建:获取锁的线程通过线程池提交重建任务,自身不等待,直接返回旧数据
线程池:使用固定大小的线程池控制并发重建的线程数量
数据类型转换:
redisData.getData()返回的是Object,实际是JSONObject,需要强转后再反序列化为Shop
3.5 优缺点
| 优点 | 缺点 |
|---|---|
| 线程无需等待,性能好 | 实现复杂,需要额外的 RedisData 封装 |
| 始终返回数据,用户体验好 | 返回的可能是过期数据,不保证强一致性 |
| 只有一个线程做缓存重建,数据库压力小 | 需要提前预热热点数据,不适合普通数据 |
| key 不设物理 TTL,永不过期 | 额外占用内存存储过期时间字段 |
四、两种方案对比
| 对比维度 | 互斥锁 | 逻辑过期 |
|---|---|---|
| 数据一致性 | 强一致,重建完成后才返回新数据 | 弱一致,过期期间返回旧数据 |
| 性能 | 线程需要等待,有阻塞 | 始终快速返回,无阻塞 |
| 实现复杂度 | 简单 | 较复杂(线程池、RedisData、预热) |
| 内存占用 | 几乎无额外占用 | 额外存储过期时间字段 |
| 适用场景 | 普通数据,对一致性要求较高 | 热点数据,对响应速度要求高、可接受短暂不一致 |
| 数据预热 | 不需要,缓存未命中自动查库 | 必须提前预热 |
| 死锁风险 | 有(靠 TTL 兜底) | 有(靠 TTL 兜底) |
五、逻辑过期方案的设计思考
5.1 为什么缓存未命中直接返回 null
逻辑过期方案的核心假设是”缓存始终存在“——因为 key 不设物理过期时间,只要预热过就一直在 Redis 中。
如果缓存未命中时去查数据库,会产生两个问题:
- 首次访问即击穿:高并发场景下,第一次访问某个 key 时,大量请求同时打到数据库,本质上就是缓存击穿问题本身
- 违背设计目标:逻辑过期方案的目标是”请求不等待、快速返回”,如果要查数据库就需要等待,违背了这个目标
所以逻辑过期方案故意在缓存未命中时返回 null,强制要求热点数据必须提前预热。
5.2 实际项目中的使用方式
实际项目中不会只用逻辑过期一种方案,而是多种方案并存,根据数据类型选择不同策略:
| 数据类型 | 适用方案 | 说明 |
|---|---|---|
| 普通数据 | 互斥锁 + 缓存穿透(queryWithMutex) |
正常缓存查询,未命中查数据库,用互斥锁防止击穿 |
| 热点数据 | 逻辑过期(queryWithLogicalExpire) |
提前预热到 Redis,过期后异步重建,始终返回旧数据 |
六、缓存工具类封装:把函数当参数
把第二、三章中写死在 ShopServiceImpl 里的缓存逻辑抽取成独立的工具类后,发现方法参数里出现了一个”函数”,这是今天第一次接触的写法,单独整理。
6.1 为什么要封装成工具类
第二、三章的 queryWithPassThrough、queryWithMutex、queryWithLogicalExpire 都写在 ShopServiceImpl 中,存在两个问题:
- 逻辑无法复用:缓存穿透、缓存击穿的处理思路与具体实体无关,店铺、店铺类型等任何实体都可能需要,但方法里写死了
Shop、getById - 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 | public <R, ID> R queryWithPassThrough( |
逐个拆开看:
| 组成 | 含义 |
|---|---|
<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 | Shop shop = cacheClint.queryWithPassThrough( |
this::getById 是方法引用,等价于 Lambda id -> getById(id)。因为 MyBatis-Plus 提供的 getById(Long id) 接收一个 Long、返回实体对象,签名正好和 Function<Long, Shop> 匹配,所以方法本身可以直接作为参数传入。
工具类内部如何调用传入的函数
工具类不知道方法名,只通过函数式接口的 apply 方法触发执行:
1 | // 缓存未命中,调用传入的查询函数去数据库查 |
dbFallback.apply(id) 这一句执行时,实际调用的是 ShopServiceImpl.getById(id),工具类拿到的只是结果 r。
6.5 工具类完整代码(queryWithPassThrough)
1 |
|
queryWithMutex、queryWithLogicalExpire 只是在此基础上叠加锁、逻辑过期判断,结构相同,同样用 dbFallback.apply(id) 触发查库。
6.6 封装后的调用与收益
封装后 ShopServiceImpl 中原本几十行的缓存逻辑收敛为一次调用(项目当前启用的是互斥锁版本):
1 |
|
收益总结:
复用:店铺、店铺类型等任意实体调用同一个工具方法即可获得缓存穿透、击穿处理能力
解耦:工具类只依赖
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> 解决泛型擦除后的反序列化问题 |