前言: 在高并发系统中,缓存是提升性能的神兵利器。然而,当一个热点 Key(比如头部主播的直播间、秒杀商品的详情)突然过期,数以万计的并发请求会在瞬间击穿缓存,直接打到数据库上,导致数据库瞬间宕机。这就是著名的“缓存击穿”问题。
本文将基于“黑马点评”项目,深入复盘解决该问题的两种终极方案:互斥锁(Mutex Lock) 与 逻辑过期(Logical Expiration),并从一致性、可用性等维度进行深度对比。
一、问题的本质与技术选型
缓存击穿不同于缓存穿透。它的核心在于“热点 Key” + “过期”。 当一个高并发访问的 Key(如头部主播直播间、秒杀商品详情)失效时,如果 1000 个线程同时涌入:
二、 方案一:互斥锁 (Mutex Lock) —— 强一致性的坚守
2.1 核心原理 与 Double Check
互斥锁的核心思想是:“只许一人重建,他人排队等待”。

当发现缓存失效时,不是所有线程都去查数据库,而是大家一起去抢一个“锁”。
- 抢到锁的人:负责查询数据库、写入缓存。
- 没抢到锁的人:休眠一会儿(Thread.sleep),醒来后再次查询缓存(此时缓存可能已经重建好了)。
思考:为什么拿到锁后,还需要再查一次缓存?
假设线程 A 和线程 B 同时发现缓存过期。A 抢到了锁,开始查库建缓存。 B 休眠 50ms。 等 A 建好缓存释放锁, B 醒来再次抢到锁,如果 B 不做 Double Check,它会再次查库、写缓存。在高并发场景下,这会导致不必要的数据库查询,锁的意义大大折扣。
2.2 代码实战
在 queryWithMutex 方法中,我们利用 Redis 的 setnx 特性实现了互斥锁。

关键代码解析:
Java
public Shop queryWithMutex(Long id) {
// 1.从 redis 查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.如果命中 – 直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// TODO Redis 空对象
if (shopJson != null) {
return null;
} // 防止缓存穿透
// 4. 实现缓存重构 解决 缓存击穿问题
// 4.1 尝试获取互斥锁
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 判断获取是否成功
if(!isLock){
// 4.3 失败:休眠重试
Thread.sleep(50);
return queryWithMutex(id);
}
// 4.4 double check
String shopJson2 = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
if (StrUtil.isNotBlank(shopJson2)) {
return JSONUtil.toBean(shopJson2, Shop.class);
}
if (shopJson2 != null) {
return null;
}
// double check 失败 :根据 id 查询数据库
shop = getById(id);
// 模拟重建的延时
Thread.sleep(200);
// 5. 不存在,返回错误
if(shop == null){
// 将空值写入 redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "" , CACHE_NULL_TTL , TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6. 写入 redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL , TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7. 释放互斥锁
unlock(lockKey);
}
return shop;
}
2.3 优缺点分析
- 优点:
-
- 强一致性:保证了用户查到的数据一定是最新的。
- 内存占用小:不需要多余的字段。
- 缺点:
-
- 性能受损:大量线程被阻塞、排队,系统吞吐量下降。
- 死锁风险:如果有复杂的锁依赖,可能导致死锁。
2.4 踩坑记录
❌ 踩坑现场:Hutool 的 BeanUtil 误用 起初,因为不了解,我在反序列化时使用了 BeanUtil.toBean()。
Java
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 错误写法:BeanUtil 并不适合将 JSON 字符串直接转为对象
Shop shop = BeanUtil.toBean(shopJson, Shop.class);
后果:代码运行不报错,但返回的 shop 对象属性全空,或者直接抛出类型转换异常。 修正:存入 Redis 时既然用了 JSONUtil.toJsonStr,取出时必须成对使用 JSONUtil.toBean。
三、 方案二:逻辑过期 (Logical Expiration) —— 极致的高可用
3.1 核心原理
互斥锁保证了一致性,但牺牲了可用性。对于“商铺详情”这种对实时性要求没那么高的场景,我们追求高可用性。
逻辑过期的核心思想是:“永不过期,异步重建,零等待”。

我们不给 Redis 的 Key 设置 TTL(物理过期),而是把过期时间 expireTime 封装在数据内部。
- 查询时:发现 expireTime 已过期,立刻返回旧数据(不管它是不是旧的,先返回再说,保证不卡顿)。
- 后台:开启一个独立线程去抢锁。抢到锁的线程负责去查数据库、更新缓存、重置过期时间。
需要注意的是逻辑过期在工程上几乎一定要配合缓存预热来使用。
而在本项目中,缓存预热是必须的,请看下面这一段代码:
// 1. 查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 如果未命中,直接返回 null
if (StrUtil.isBlank(shopJson)) {
return null; // <— 罪魁祸首在这里
}
不做缓存预热,等同于手动废除了逻辑过期的核心机制。 一旦 Redis 中缺失数据,代码会在入口处直接‘短路’返回 null,导致异步重建线程成为摆设,系统彻底丧失了从无到有的自我修复能力。 对于本就是为了应对高并发而生的热点业务来说,这不仅仅是查不到数据的问题,而是核心业务线的灾难性停摆。
3.2 代码实战
我们需要引入 RedisData 包装类和线程池 CACHE_REBUILD_EXECUTOR。

// RedisData
package com.hmdp.utils;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
// 缓存预热 / 重建缓存
public void saveShop2Redis(Long id, Long expireSeconds){
// 1. 查询店铺数据
Shop shop = getById(id);
// 2. 封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3. 写入 Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id , JSONUtil.toJsonStr(redisData));
}
// ShopServiceImpl
// 线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicExpire(Long id){
String key = CACHE_SHOP_KEY + id;
// 1.从 redis 查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2.判断是否存在
if (StrUtil.isBlank(shopJson)) {
// 3.如果存在 – 直接返回
return null;
}
// 4.命中,JSON 反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5. 判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){
// 5.1 未过期:直接返回店铺信息
return shop;
}
// 5.2 已过期:缓存重建
// 6. 缓存重建
// 6.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
// 6.2 判断是否获取锁成功
boolean isLock = tryLock(lockKey);
if(isLock){
CACHE_REBUILD_EXECUTOR.execute(() -> {
try{
// 重建缓存
this.saveShop2Redis(id , 20L);
} catch(Exception e){
e.printStackTrace();
} finally {
unlock(lockKey);
}
});
}
// 6.4 返回过期的商铺信息
return shop;
}
四、 如何选择?
在项目中不能“一刀切”地使用某一种方案,而是需要根据业务场景进行划分。这体现了技术选型中的Trade-off(权衡)。
|
维度 |
互斥锁 (Mutex) |
逻辑过期 (Logical Exp) |
深度思考 |
|
一致性 |
强一致 |
最终一致 |
涉及金钱(如优惠券库存、余额)必须用互斥锁,不能让用户看到错误的库存。 |
|
可用性 |
差(存在阻塞/等待) |
优(零等待) |
涉及浏览体验(如商铺信息、资讯详情)首选逻辑过期,用户不在乎晚几秒看到新标题,但在乎页面能不能秒开。 |
|
实现复杂度 |
中等 |
较高 |
逻辑过期需要额外的线程池管理,且必须配合“缓存预热”机制,否则新 Key 上线时会有问题。 |
|
内存开销 |
低 |
略高 |
逻辑过期需要存储 expireTime 字段,且 Key 不会被自动清理,需配合定期清理策略。 |
五、 结语:架构设计的“不完美”艺术
通过对“互斥锁”与“逻辑过期”的深度复盘,我们可以发现,没有任何一种缓存方案是万能的“银弹”:
1. 从阻塞到非阻塞的进化:
互斥锁代表了传统的同步阻塞思维,它简单、可靠,但在极端并发下显得笨重。而逻辑过期则体现了异步非阻塞的现代架构思想,通过牺牲短暂的数据一致性,换取了系统近乎无损的可用性。
2. 防御性编程的深度:
我们在代码中加入的 Double Check(双重检查) 以及对 Hutool 序列化陷阱的规避,本质上是在不确定的并发环境下,通过工程手段构建确定性的防御。
3. 架构师的权衡哲学:
一个优秀的开发人员不应只看“快不快”,而应看“稳不稳定”。在处理核心金融数据时,我们要有“守门员”式的严谨(互斥锁);在处理海量 C 端流量时,我们要有“太极”式的化解之道(逻辑过期)。
缓存的设计不仅仅是为了快,更是为了在流量洪峰涌入时,给脆弱的数据库筑起一道可以柔性缩放的屏障。





