4. 缓存击穿问题:定义、热点key失效场景、互斥锁与逻辑过期方案
4.1 缓存击穿的定义与危害
缓存击穿(Cache Breakdown)是指当某个热点 key 在缓存中过期失效的瞬间,大量并发请求同时穿透缓存,直接打到后端数据库,导致数据库负载瞬间飙升甚至宕机的现象。
⚠️ 核心区别:缓存击穿与缓存雪崩不同。雪崩是大量 key 同时失效,而击穿是单个热点 key 在失效瞬间被高并发访问。虽然范围小,但热点 key 的流量往往占整体请求的 30%~70%,破坏力极强。
4.2 热点key失效的典型场景
| 场景 | 热点key示例 | 并发量级 | 失效后果 |
|---|---|---|---|
| 秒杀商品详情 | product:1001:detail |
10万+/秒 | 商品页白屏、数据库连接池打满 |
| 微博热搜榜单 | hot:weibo:top50 |
5万+/秒 | 榜单接口超时、首页加载失败 |
| 大V粉丝数 | user:9527:fans_count |
3万+/秒 | 用户主页接口雪崩 |
| 系统配置缓存 | config:global:switch |
2万+/秒 | 全站配置失效、功能异常 |
4.3 解决方案一:互斥锁(Mutex Lock)
互斥锁方案的核心思想是:当缓存失效时,只允许一个线程去数据库加载数据,其他线程等待该线程完成加载后,直接从缓存读取。
4.3.1 实现流程
- 请求到达,从缓存获取数据。
- 缓存未命中,尝试获取分布式锁(如 Redis SETNX)。
- 获取锁成功的线程,查询数据库,回写缓存,释放锁。
- 获取锁失败的线程,休眠重试(或自旋等待),直到缓存中有数据。
public String getProductDetail(Long productId) {
String cacheKey = "product:" + productId + ":detail";
String cacheData = redisTemplate.opsForValue().get(cacheKey);
if (cacheData != null) {
return cacheData;
}
// 尝试获取分布式锁
String lockKey = "lock:" + cacheKey;
String lockValue = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 双重检查:可能其他线程已经加载完成
cacheData = redisTemplate.opsForValue().get(cacheKey);
if (cacheData != null) {
return cacheData;
}
// 查询数据库
cacheData = productMapper.getDetail(productId);
// 回写缓存,设置过期时间
redisTemplate.opsForValue()
.set(cacheKey, cacheData, 30, TimeUnit.MINUTES);
return cacheData;
} finally {
// 释放锁(使用Lua脚本保证原子性)
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(lockKey), lockValue);
}
} else {
// 获取锁失败,休眠重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductDetail(productId); // 递归重试
}
}
✅ 优点:实现简单,数据一致性高,不会出现缓存与数据库不一致的情况。
⚠️ 缺点:存在线程阻塞等待,高并发下会累积请求延迟;需要处理锁超时和死锁问题。
⚠️ 缺点:存在线程阻塞等待,高并发下会累积请求延迟;需要处理锁超时和死锁问题。
4.4 解决方案二:逻辑过期(Logical Expiration)
逻辑过期方案不设置物理过期时间,而是在缓存数据中嵌入一个逻辑过期时间戳。当发现逻辑过期时,由当前线程触发异步更新,同时返回旧数据给调用方。
4.4.1 数据模型
// 缓存数据结构
public class CacheData<T> {
private T data; // 实际业务数据
private Long expireTime; // 逻辑过期时间戳(毫秒)
private Long createTime; // 数据创建时间
public boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
}
4.4.2 实现流程
- 请求到达,从缓存获取数据(包含逻辑过期时间)。
- 判断逻辑是否过期:未过期则直接返回数据。
- 已过期:尝试获取互斥锁。
- 获取锁成功:开启异步线程更新缓存,当前线程返回旧数据。
- 获取锁失败:直接返回旧数据,不等待。
public String getProductDetailV2(Long productId) {
String cacheKey = "product:" + productId + ":detail_v2";
CacheData<String> cacheData = redisTemplate.opsForValue().get(cacheKey);
// 1. 缓存不存在,直接查库并初始化
if (cacheData == null) {
String dbData = productMapper.getDetail(productId);
CacheData<String> newCache = new CacheData<>();
newCache.setData(dbData);
newCache.setCreateTime(System.currentTimeMillis());
newCache.setExpireTime(System.currentTimeMillis() + 30 * 60 * 1000);
redisTemplate.opsForValue().set(cacheKey, newCache);
return dbData;
}
// 2. 逻辑未过期,直接返回
if (!cacheData.isExpired()) {
return cacheData.getData();
}
// 3. 逻辑已过期,尝试获取锁
String lockKey = "lock:renew:" + cacheKey;
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 4. 获取锁成功,异步更新缓存
CompletableFuture.runAsync(() -> {
try {
String dbData = productMapper.getDetail(productId);
CacheData<String> newCache = new CacheData<>();
newCache.setData(dbData);
newCache.setCreateTime(System.currentTimeMillis());
newCache.setExpireTime(
System.currentTimeMillis() + 30 * 60 * 1000);
redisTemplate.opsForValue().set(cacheKey, newCache);
} finally {
redisTemplate.delete(lockKey);
}
});
}
// 5. 无论是否获取到锁,都返回旧数据
return cacheData.getData();
}
💡 核心优势:逻辑过期方案实现了非阻塞读取,所有请求都能立即得到响应(返回旧数据),用户体验远优于互斥锁方案。适用于允许短暂数据不一致的场景。
4.5 两种方案对比
| 对比维度 | 互斥锁方案 | 逻辑过期方案 |
|---|---|---|
| 数据一致性 | 强一致(所有请求拿到最新数据) | 最终一致(过期瞬间返回旧数据) |
| 请求延迟 | 部分请求阻塞等待(50~200ms) | 零等待(立即返回) |
| 数据库压力 | 仅1个线程查库 | 仅1个线程查库(异步) |
| 实现复杂度 | 低(需处理锁超时) | 中(需管理异步线程池) |
| 适用场景 | 对数据一致性要求高的场景(如支付、库存) | 允许短暂不一致的场景(如榜单、商品详情) |
4.6 缓存击穿解决方案架构图
4.7 实践建议与避坑指南
📌 选型建议:
- 金融、支付、库存类:优先使用互斥锁方案,保证数据强一致。
- 内容、榜单、商品详情类:优先使用逻辑过期方案,提升吞吐量。
- 混合使用:核心数据用互斥锁,非核心数据用逻辑过期,同一系统内可并存。
⚠️ 常见坑点:
- 锁超时时间设置过短:数据库查询慢时,锁提前释放导致多个线程同时查库。
- 递归重试导致栈溢出:互斥锁方案中,重试次数应有限制(如最多重试3次)。
- 异步线程池未隔离:逻辑过期方案的异步更新应使用独立线程池,避免影响业务线程。
- 逻辑过期时间与缓存淘汰策略冲突:Redis 内存淘汰可能提前删除未过期的逻辑数据,需配合主动刷新。