4. 缓存击穿:热点Key失效问题与解决方案
缓存击穿是指一个热点 Key 在缓存中恰好过期失效,此时大量并发请求同时穿透到数据库,导致数据库负载瞬间飙升甚至崩溃。与缓存雪崩不同,击穿针对的是单个热点 Key,而非大批 Key 同时失效。
4.1 热点Key失效问题分析
热点 Key 通常具有以下特征:
- 高并发访问:每秒数万甚至数十万次读取
- 写少读多:数据更新频率低,但读取量极大
- 失效影响面广:一旦失效,所有请求直接打到数据库
核心矛盾:缓存失效瞬间,N 个并发请求同时发现缓存不存在,同时回源数据库。数据库连接池瞬间被占满,响应延迟飙升,引发级联故障。
| 场景 | 并发量 | 数据库承受压力 | 典型后果 |
|---|---|---|---|
| 正常缓存命中 | 10,000 QPS | 0(缓存直接返回) | 无 |
| 热点 Key 失效 | 10,000 QPS | 10,000 QPS 穿透 | 数据库连接池耗尽 |
| 数据库超时 | 10,000 QPS | 持续重试 + 新请求 | 服务雪崩 |
4.2 互斥锁解决方案
互斥锁(Mutex Lock)是最直接的解决方案:当缓存失效时,只允许一个线程去数据库加载数据,其他线程等待该线程完成后再从缓存读取。
实现原理
- 请求到达,检查缓存是否存在
- 缓存不存在,尝试获取分布式锁(如 Redis SETNX)
- 获取锁成功的线程,查询数据库并回写缓存
- 获取锁失败的线程,等待后重试从缓存读取
// 互斥锁方案伪代码(Java + Redis)
public String getData(String key) {
String value = redis.get(key);
if (value != null) {
return value; // 缓存命中
}
// 缓存未命中,尝试获取锁
String lockKey = "lock:" + key;
boolean locked = redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查:防止等待锁期间其他线程已更新缓存
value = redis.get(key);
if (value != null) {
return value;
}
// 查询数据库
value = db.query(key);
redis.setex(key, 300, value);
return value;
} finally {
redis.del(lockKey); // 释放锁
}
} else {
// 未获取到锁,等待后重试
Thread.sleep(50);
return getData(key); // 递归重试
}
}
适用场景:对数据一致性要求高,可以接受少量请求延迟的场景。互斥锁能严格保证只有一个线程回源数据库,但会引入等待延迟和锁开销。
优缺点对比
| 维度 | 说明 |
|---|---|
| 优点 | 实现简单,数据强一致,不会出现缓存与数据库不一致 |
| 缺点 | 引入锁等待延迟,高并发下可能造成线程阻塞;锁超时需谨慎设置 |
| 风险点 | 锁未正确释放会导致死锁;锁超时时间过短会导致多次回源 |
4.3 逻辑过期方案
逻辑过期方案不依赖 Redis 的 TTL 过期机制,而是在缓存 value 中额外存储一个过期时间字段。读取时判断逻辑时间是否过期,过期则触发异步更新。
实现步骤
- 缓存中存储数据 + 逻辑过期时间(如 JSON 封装)
- 读取时检查逻辑时间是否过期
- 未过期:直接返回数据
- 已过期:尝试获取分布式锁,获取成功的线程异步更新缓存
- 获取锁失败的线程直接返回旧数据(不等待)
// 逻辑过期方案伪代码
public class CacheWrapper<T> {
private T data; // 实际数据
private long expireTime; // 逻辑过期时间戳
}
public String getData(String key) {
CacheWrapper<String> wrapper = redis.get(key);
if (wrapper == null) {
return null; // 缓存不存在,走降级
}
if (wrapper.getExpireTime() > System.currentTimeMillis()) {
return wrapper.getData(); // 逻辑未过期,直接返回
}
// 逻辑已过期,尝试获取锁进行异步更新
String lockKey = "lock:" + key;
boolean locked = redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS);
if (locked) {
// 异步线程更新缓存
executor.submit(() -> {
try {
String newData = db.query(key);
CacheWrapper<String> newWrapper = new CacheWrapper<>();
newWrapper.setData(newData);
newWrapper.setExpireTime(System.currentTimeMillis() + 300_000);
redis.set(key, newWrapper);
} finally {
redis.del(lockKey);
}
});
}
// 返回旧数据(允许短暂不一致)
return wrapper.getData();
}
注意:逻辑过期方案的核心思想是“用短暂的不一致换取可用性”。在缓存更新完成前,所有请求都读取旧数据。适用于对实时性要求不高的场景,如热门文章列表、商品详情页。
4.4 永不过期 + 异步更新策略
永不过期策略是指缓存 Key 不设置 TTL,永远存在于缓存中。数据更新通过后台异步任务定期刷新,彻底避免 Key 失效导致的击穿问题。
实现方式
- 缓存 Key 不设 TTL:Redis 中 Key 永不过期
- 后台定时任务:每隔固定时间(如 5 分钟)刷新缓存数据
- 主动更新触发:数据变更时,通过消息队列通知缓存更新
- 兜底策略:若缓存数据为空(如 Redis 重启),使用互斥锁回源
// 永不过期 + 异步更新策略
// 1. 写入缓存时不设置过期时间
public void writeCache(String key, String value) {
redis.set(key, value); // 不调用 expire()
}
// 2. 后台定时刷新任务(每5分钟执行一次)
@Scheduled(fixedRate = 300_000)
public void refreshHotKey() {
List<String> hotKeys = getHotKeyList();
for (String key : hotKeys) {
String newData = db.query(key);
redis.set(key, newData);
}
}
// 3. 数据变更时主动推送更新
public void onDataChange(String key) {
String newData = db.query(key);
redis.set(key, newData);
}
// 4. 读取时兜底处理
public String getData(String key) {
String value = redis.get(key);
if (value == null) {
// Redis 重启或数据丢失,使用互斥锁回源
return loadDataWithLock(key);
}
return value;
}
4.5 方案对比与选型建议
| 方案 | 数据一致性 | 请求延迟 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 互斥锁 | 强一致 | 有等待延迟 | 低 | 金融、交易等一致性敏感场景 |
| 逻辑过期 | 最终一致 | 无等待延迟 | 中 | 内容详情页、商品展示等读多写少场景 |
| 永不过期 + 异步更新 | 最终一致 | 无延迟 | 高 | 极端热点 Key、首页推荐等需要极致性能场景 |
实践建议:对于真正的热点 Key,推荐采用“永不过期 + 异步更新”为主策略,配合“逻辑过期”作为兜底。当异步更新失败或延迟时,逻辑过期机制能保证系统仍然返回旧数据,避免缓存穿透。互斥锁方案更适合对一致性要求极高且并发量可控的场景。