认识缓存
什么是缓存?
缓存是一种具备高效读写能力的数据暂存区域,用于临时存储高频访问的数据,以提升访问效率。
缓存的作用
- 降低后端负载:减少对数据库等底层存储的直接访问压力,避免高并发场景下后端服务被打垮。
- 提高服务读写响应速度:将数据从低速存储(如数据库)迁移到高速缓存(如Redis),显著缩短接口响应时间,提升用户体验。
缓存的成本
- 开发成本:需要额外编写缓存读写、更新、失效等逻辑,增加代码复杂度。
- 运维成本:需要部署、监控缓存服务(如Redis集群),保障其高可用与稳定性。
- 一致性问题:缓存与数据库数据可能存在短暂不一致,需要设计合理的更新策略(如缓存更新模式、过期时间)来保证数据最终一致。
一、缓存穿透代码解析与疑问解答
思路


解决方案(被动防御)
- 缓存null:
这样查到缓存,就不会打到数据库了 - 布隆过滤器:
通过二进制存储数据库中某些信息的hash值判断是否存在, 但这种准确度不确定
原始代码
java
//缓存穿透
private Shop queryWithPassThrough(Long id) {
//从Redis查询店铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//1.缓存命中
if(StrUtil.isNotBlank(shopJson)) {
//1.1 缓存中的是店铺信息
return JSONUtil.toBean(shopJson, Shop.class);
}
//1.2命中为空
if(shopJson != null) {
return null;
}
//2.未命中
//2.1查询数据库
Shop shop = getById(id);
//2.2结果不存在
if (shop == null) {
//2.2.1返回空值到redis,避免穿透
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
}
//2.3能查到,写入缓存
stringRedisTemplate.opsForValue(0.set(key, JOSNUtil.toJsonStr(shop), CACHE_SHOP_TTL,TimeUnit.MINUTES);
//3.返回店铺信息
return shop;
}
核心疑问解答
疑问1:1.2 命中为空 if(shopJson != null) 能否改成 == ""
前置说明
Hutool 工具类 StrUtil.isBlank() 判定规则:
包含空字符"" 、空白字符串(length > 0)" " 、null 三种情况均返回 true。所以只要判断了 !=null, 那就只能等于空字符串了。
直接写 == "" 的问题
Java 中字符串相等判断不能用 ==:
==比较的是对象内存地址,而非字符串内容;equals()方法才是用于比较字符串内容是否相等的正确方式。
推荐写法
java
if ("".equals(shopJson)) {
// 用""调用equals,避免shopJson为null时触发空指针异常
return null;
}
疑问2:2.3 写入缓存时为什么要设置过期时间
给缓存设置过期时间(TTL)是缓存设计的核心原则,主要解决以下问题:
(1)防止缓存与数据库数据不一致(缓存脏数据)
店铺信息(如价格、营业状态)可能在数据库中更新,若缓存永久有效,更新后缓存中的旧数据会持续返回,导致用户看到错误信息。设置过期时间后,缓存到期自动失效,下次请求会从数据库加载最新数据并重新缓存。
(2)防止 Redis 内存溢出
若所有缓存永久保存,Redis 内存会持续增长,最终触发内存淘汰策略(可能删除重要缓存)或直接内存溢出。设置过期时间可自动清理不再使用的缓存,控制 Redis 内存占用。
(3)兼容缓存击穿的后续防护(可选)
过期时间是实现"缓存击穿"防护(如互斥锁、逻辑过期)的基础,即使当前代码仅处理缓存穿透,设置过期时间也为后续扩展防护策略预留空间。
总结
if(shopJson != null)不建议直接改== "",推荐使用"" .equals(shopJson)避免空指针且精准匹配空字符串;- 缓存设置过期时间核心目的是防止数据不一致、控制 Redis 内存占用,同时兼容后续缓存击穿防护扩展。
二、缓存雪崩解析
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
三、缓存击穿代码解析与疑问解答


思路
互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响
逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦
| 解决方案 | 优点 | 缺点 |
|---|---|---|
| 互斥锁 | - 没有额外的内存消耗 - 保证一致性 - 实现简单 | - 线程需要等待,性能受影响 - 可能有死锁风险 |
| 逻辑过期 | - 线程无需等待,性能较好 | - 不保证一致性 - 有额外内存消耗 - 实现复杂 |
1.互斥锁解决

原始代码
java
private boolean tryGetLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
java
//互斥锁解决击穿
private Shop queryWithMutex(Long id) {
//1.从redis中获取店铺缓存
String key = CHCAHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断缓存是否命中
//2.1缓存命中
if (StrUtil.isNotBlank(shopJson)) [
return JSONUtil.toBean(shopJson, Shop.class);
]
//2.2命中的为空值(防穿透)
if (shopJson != null) {
reutrn null;
}
//3.缓存未命中,尝试获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean lock = false;
Shop shop = null;
try {
lock = tryGetLock(lockKey);
//3.1获取失败, 休眠一会重试
if (!lock) {
Thread.sleep(200);
// 苏醒后先查缓存,这时可能其他线程做完了,避免重复查库
String cacheShop = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(cacheShop)) {
return JSONUtil.toBean(cacheShop, Shop.class);
}
return querWithMutex(id); //重试
}
//3.2获取成功,查询数据库
shop = getById(id);
//3.3.1数据库没查到
if (shop == null) {
//防穿透,null写入redis
stringRedisTemplate.opForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//3.3.2查到了,写入缓存
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
}catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//释放锁
unlock(lockKey);
//4.返回店铺信息
return shop;
}
}
核心问题解答
1. 获取不到锁休眠苏醒后,获取到锁需注意的事项
休眠苏醒后重新竞争锁并成功,需重点关注以下3点:
1.1 再次检查缓存(核心)
休眠期间可能已有其他线程完成"查库+写缓存"操作,此时无需重复查询数据库,直接返回缓存数据即可:
java
if (!lock) {
Thread.sleep(200);
// 苏醒后优先查缓存,避免重复查库
String cacheShop = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(cacheShop)) {
return JSONUtil.toBean(cacheShop, Shop.class);
}
return queryWithMutex(id); // 未命中缓存再重试
}
1.2 控制重试次数/超时时间
避免无限递归重试导致栈溢出,建议增加重试次数限制:
java
// 重载方法,增加重试次数参数
private Shop queryWithMutex(Long id, int retryCount) {
if (retryCount >= 3) { // 最多重试3次
throw new RuntimeException("获取锁超时,请稍后重试");
}
// ... 核心逻辑
if (!lock) {
Thread.sleep(200);
return queryWithMutex(id, retryCount + 1); // 重试次数+1
}
}
1.3 锁的过期时间兜底
锁的10秒过期时间是死锁的兜底方案,但需保证业务执行时间 < 锁过期时间(否则可能释放其他线程的锁):
- 若业务逻辑执行超过10秒,当前线程的锁已自动过期,此时释放锁可能误删其他线程持有的锁;
- 建议根据实际业务耗时调整锁的过期时间(如调整为30秒)。
2. stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS) 详解
2.1 函数作用
该方法等价于Redis原生命令 SET NX + EX,是实现分布式锁的核心原子操作:
- 仅当
key不存在 时,才会设置key的值(保证锁的唯一性); - 同时为
key设置过期时间(10秒),避免业务异常导致死锁; - 整个操作是原子性的(判断key是否存在 + 设置值 + 设置过期时间一步完成),无并发安全问题。
2.2 参数说明
| 参数 | 含义 |
|---|---|
key |
分布式锁的唯一标识(如 lock:shop:1),区分不同资源的锁 |
"1" |
锁的value值,仅作为占位符(无实际业务意义),可替换为任意字符串 |
10 |
过期时间数值,此处为10 |
TimeUnit.SECONDS |
过期时间单位,此处为秒,即锁的有效期为10秒 |
2.3 返回值
true:key不存在,设置成功(获取锁成功);false:key已存在,设置失败(获取锁失败)。
2.4 扩展:value值优化(避免误释放锁)
"1" 是无意义占位符,可替换为UUID(保证每个线程的value唯一),释放锁时校验value,避免误删其他线程的锁:
java
// 获取锁时存储唯一UUID
String uuid = UUID.randomUUID().toString();
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, uuid, 10, TimeUnit.SECONDS);
// 释放锁时校验value是否匹配
private void unlock(String key, String uuid) {
String value = stringRedisTemplate.opsForValue().get(key);
if (uuid.equals(value)) { // 仅释放当前线程持有的锁
stringRedisTemplate.delete(key);
}
}
1.逻辑过期解决

java
private boolean tryGetLock(String lockKey) {
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); // 锁超时10秒,防止死锁
return Boolean.TRUE.equals(flag);
}
//释放互斥锁
private void unlock(String lockKey) {
stringRedisTemplate.delete(lockKey);
}
java
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
java
//逻辑过期解决击穿
private static final ExecutorService CACHE_REBUILD_EXECTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis缓存
String json = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
//2.1未命中,直接返回
if (StrUtil.isBlank(json)) {
return null;
}
//3.命中,判断缓存是否过期
//需要把json反序列化成对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
JSONObject data = (JSONObject)redisData.getData(); //店铺信息
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//4.判断是否过期
//4.1没过期
if (expireTime.isAfter(LocalDateTime.now())) {
return shop;
}
//4.2过期, 重建缓存
//5.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = false;
isLock = tryGetLock(lockKey);
//6.判断是否获取锁成功
//6.1成功
if (isLock) {
//开启独立线程, 实现缓存重建
//注意获取锁成功后,应该再次检查缓存是否过期, 存在则无序重建
String newJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(newjson)) {
return null;
}
RedisData newRedisData = JSONUtil.toBean(newJson, RedisData.class);
if (expireTime.isAfter(LocalDateTime.now()) {
// 如果新缓存未过期,说明已被其他线程重建,直接返回即可
JSONObject newData = (JSONObject) newRedisData.getData(); //店铺信息
Shop newShop = JSONUtil.toBean(newData, Shop.class);
return newShop;
}
//确认缓存过期,异步重建缓存
CACHE_REBUILD_EXECTOR.submit(() ->{
try {
//重建缓存
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unlock(lockKey);
}
});
}
//6.3失败,返回过期信息
return shop;
}
核心问题解答
JSONObject data = (JSONObject)redisData.getData();为什么用JSONObject做强转?redisData.getData()获取的是什么类型?
1.1 强转JSONObject的原因
RedisData类中data字段定义为Object类型,而Redis中存储的是JSON格式的字符串,反序列化后默认会被JSONUtil.toBean()解析为JSONObject对象(而非直接的Shop对象),因此需要强转后再二次解析为Shop对象:
- 第一步:把Redis中整个JSON字符串反序列化为
RedisData对象(包含expireTime和data); - 第二步:
redisData.getData()拿到的是RedisData中data字段对应的JSON子串,类型为JSONObject; - 第三步:通过
JSONUtil.toBean(data, Shop.class)将JSONObject转为具体的Shop实体类。
1.2 redisData.getData()的实际类型
redisData.getData()的声明类型 是Object(因为RedisData类中data字段定义为Object),但实际运行类型 是JSONObject(由JSONUtil.toBean()的反序列化规则决定)。
若直接用Shop shop = (Shop)redisData.getData()强转会报类型转换异常,必须先转JSONObject再解析。
boolean isLock = false; isLock = tryGetLock(lockKey);为何不能直接isLock = tryGetLock(lockKey);?
2.1 语法层面:可以直接赋值
从语法上,boolean isLock = tryGetLock(lockKey);完全可行,原代码先定义isLock = false再赋值属于冗余写法 ,并非必须。
2.2 代码设计层面的潜在考量
原代码可能是为了:
- 可读性:显式初始化变量,让新手更容易理解变量的初始状态;
- 容错性:若
tryGetLock()返回null(理论上不会,因为方法返回boolean),避免NullPointerException;
但实际开发中,更简洁的写法是直接赋值:boolean isLock = tryGetLock(lockKey);,无需额外初始化。
- 为何获取锁后要再次检查缓存是否过期?不是已经检查过了?
3.1 核心原因:"检查过期"到"获取锁"存在时间窗口
第一次检查缓存过期(expireTime.isAfter(LocalDateTime.now()))和获取锁(tryGetLock(lockKey))之间,可能发生以下情况:
- 线程A:检查到缓存过期 → 准备获取锁(耗时10ms);
- 线程B:同时检查到缓存过期 → 先于线程A获取锁 → 完成缓存重建(更新了Redis中缓存的过期时间);
- 线程A:10ms后获取到锁,若不再次检查,会重复执行"查库+写缓存",造成资源浪费。
3.2 举例说明
第一次检查:线程A判断缓存过期(时间T1);
获取锁耗时:线程A竞争锁耗时50ms(时间T1→T2);
线程B:在T1-T2期间已完成缓存重建,Redis中缓存的过期时间已更新为未过期;
线程A:获取锁后若不再次检查,会重复重建缓存,违背"缓存重建只执行一次"的设计目标。
if(isLock)中直接复用json = stringRedisTemplate.opsForValue().get(key)(不定义新变量newJson)有何问题?
4.1 核心差异:变量的语义性 与数据时效性
-
不定义新变量(直接复用
json):java// 原写法:复用已有变量 json = stringRedisTemplate.opsForValue().get(key); -
定义新变量
newJson:java// 推荐写法:定义新变量 String newJson = stringRedisTemplate.opsForValue().get(key);
4.2 直接复用json的核心问题
(1)语义混淆,可读性差
- 方法开头的
json变量:代表第一次查询缓存的旧数据(时间点T1),语义是"初始缓存数据"; - 获取锁后重新赋值的
json:代表最新查询缓存的新数据 (时间点T2),语义是"校验用缓存数据";
两次赋值的语义完全不同,复用同一个变量会导致代码阅读者无法区分"初始数据"和"最新数据",增加维护成本。
(2)丢失初始数据,存在逻辑风险
若后续代码需要用到"第一次查询的缓存数据"(比如返回过期缓存),复用json会覆盖原有值,导致初始数据丢失:
java
// 错误示例:复用json导致初始数据丢失
String json = stringRedisTemplate.opsForValue().get(key); // T1:初始数据
if (expireTime.isBefore(LocalDateTime.now())) { // 缓存过期
if (isLock) {
json = stringRedisTemplate.opsForValue().get(key); // T2:覆盖为最新数据
// 后续若需要返回过期缓存(原逻辑return shop),但shop是基于初始json解析的,若误用到新json会出问题
}
}
return shop; // shop基于初始json解析,但若代码误写为基于新json,会返回错误数据
(3)无任何收益,违背编码规范
复用变量仅"节省一个变量定义",但牺牲了代码的可读性和可维护性,不符合"一个变量对应一个语义"的编码规范。
4.3 结论
不建议直接复用json变量 ,必须定义新变量(如newJson):
- 明确区分"初始缓存数据"和"获取锁后最新缓存数据"的语义;
- 保留初始数据,避免后续逻辑误用;
- 降低代码理解成本,符合"见名知意"的编码原则。
总结
- 获取锁后重新查询缓存不能复用原有
json变量,核心是保证语义清晰、避免数据覆盖; - 定义新变量(如
newJson)是遵循"一个变量对应一个语义"的编码规范,无额外性能损耗但大幅提升可读性; - 复用变量会丢失初始缓存数据,存在后续逻辑误用的风险。
