4、缓存击穿:定义与危害、热点key失效场景、解决方案
4.1 定义与危害
缓存击穿 是指一个被 高并发访问 并且 缓存重建业务较复杂 的 热点 key,在缓存失效的瞬间,大量请求同时穿透缓存直接访问数据库,导致数据库负载瞬间飙升甚至宕机。
核心特征: 单点热点 key 失效 + 超高并发读请求 = 数据库压力雪崩。
危害:
- 数据库瞬时过载: 大量请求同时涌入数据库,CPU/IO 瞬间打满,导致查询超时或连接池耗尽。
- 级联故障风险: 数据库响应变慢后,应用线程阻塞,可能引发上游服务超时重试,形成雪崩效应。
- 数据不一致窗口: 并发重建缓存时,多个线程同时写缓存,可能导致旧数据覆盖新数据。
| 对比维度 | 缓存穿透 | 缓存击穿 | 缓存雪崩 |
|---|---|---|---|
| 问题本质 | 查询不存在的数据 | 热点 key 失效 | 大量 key 同时失效 |
| 并发特征 | 少量恶意/异常请求 | 超高并发读 | 大规模并发读 |
| 影响范围 | 单个 key 无影响 | 单个热点 key | 多个 key 或分片 |
| 数据库压力 | 持续少量穿透 | 瞬时峰值冲击 | 持续高负载 |
4.2 热点 key 失效场景
热点 key 失效通常发生在以下典型场景中:
- 定时过期策略: 业务代码为热点数据设置了固定的 TTL(如 1 小时),到期瞬间所有请求同时发现缓存缺失。
- Redis 内存淘汰: 内存达到 maxmemory 阈值时,LRU/LFU 算法可能淘汰掉热点 key,导致后续请求击穿。
- 缓存预热失效: 系统重启或发布后,热点 key 未被及时预热,首次大量请求直接打到数据库。
- 数据更新后主动删除: 为保证最终一致性,更新数据库后主动删除缓存,若删除后瞬间有大量读请求,则触发击穿。
避坑提示: 热点 key 的 TTL 不要设置成整点过期(如 00:00:00),应加上随机偏移量(如 3600 + random(0,300) 秒),避免多个热点 key 同时失效。
4.3 解决方案
业界主流方案有三种:互斥锁、逻辑过期、永不过期 + 异步更新。下表对比其核心差异:
| 方案 | 核心思想 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 互斥锁 | 只允许一个线程重建缓存 | 强一致 | 低 | 缓存重建耗时短、并发极高 |
| 逻辑过期 | 缓存永不过期,通过逻辑过期时间判断 | 最终一致 | 中 | 缓存重建耗时长、允许短暂不一致 |
| 永不过期+异步更新 | 物理永不过期,后台定时刷新 | 最终一致 | 高 | 数据变化不频繁、可接受延迟 |
4.3.1 互斥锁方案
利用 Redis 的 SETNX 或 Redisson 分布式锁,保证同一时刻只有一个线程去数据库加载数据并重建缓存,其他线程等待或快速返回。
// 伪代码:互斥锁防止缓存击穿
public String getWithMutex(String key) {
String value = redis.get(key);
if (value != null) {
return value;
}
// 尝试获取分布式锁
String lockKey = "lock:" + key;
boolean locked = redis.setnx(lockKey, "1", 30, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查:获取锁后再次检查缓存
value = redis.get(key);
if (value != null) {
return value;
}
// 从数据库加载
value = db.query(key);
redis.set(key, value, 3600);
return value;
} finally {
redis.del(lockKey);
}
} else {
// 未获取到锁,等待后重试
Thread.sleep(50);
return getWithMutex(key);
}
}
优化点: 使用 Redisson 的
tryLock 并设置等待超时,避免线程无限阻塞;锁的自动过期时间应大于缓存重建耗时,防止死锁。
4.3.2 逻辑过期方案
缓存中不设置 TTL,而是存储一个 逻辑过期时间。读取时判断是否过期,若过期则尝试获取锁,由单个线程异步重建缓存,其他线程直接返回旧数据。
// 伪代码:逻辑过期方案
public String getWithLogicalExpire(String key) {
CacheData cacheData = redis.get(key);
long now = System.currentTimeMillis();
// 未过期,直接返回
if (cacheData.getExpireTime() > now) {
return cacheData.getValue();
}
// 已过期,尝试获取锁
String lockKey = "lock:" + key;
boolean locked = redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
// 异步线程重建缓存
executor.submit(() -> {
String newValue = db.query(key);
CacheData newData = new CacheData(newValue, now + 3600 * 1000);
redis.set(key, newData);
redis.del(lockKey);
});
}
// 返回旧数据(允许短暂不一致)
return cacheData.getValue();
}
注意: 逻辑过期方案会返回过期数据,适用于对一致性要求不高的场景(如排行榜、资讯列表)。需要配合 MQ 或定时任务保证最终一致性。
4.3.3 永不过期 + 异步更新方案
缓存物理上永不过期,由后台守护线程或定时任务定期刷新热点 key 的数据。读取时直接返回缓存数据,不存在击穿窗口。
// 伪代码:永不过期 + 异步更新
@Component
public class HotKeyRefresher {
@PostConstruct
public void init() {
// 定时刷新热点 key,每 30 秒执行一次
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
scheduler.scheduleAtFixedRate(() -> {
for (String hotKey : hotKeyList) {
String newValue = db.query(hotKey);
redis.set(hotKey, newValue); // 不设置 TTL
}
}, 0, 30, TimeUnit.SECONDS);
}
}
// 读取时直接返回,无击穿风险
public String get(String key) {
return redis.get(key);
}
适用场景: 配置信息、基础数据字典、商品详情等变化频率低、数据量小、可接受秒级延迟的业务。
4.4 方案选型对比
4.5 最佳实践总结
- 优先使用互斥锁: 对于一致性要求高、缓存重建快的场景(如用户会话、订单状态),互斥锁方案最可靠。
- 逻辑过期用于高并发读: 当缓存重建耗时超过 200ms 且允许短暂不一致时(如首页推荐、排行榜),逻辑过期可避免锁竞争。
- 永不过期 + 异步更新用于基础数据: 配置类、字典类数据,变化频率低,后台定时刷新即可。
- 热点 key 发现: 结合 Redis 的
hotkey监控或客户端本地统计,提前识别热点 key 并做保护。 - 降级兜底: 所有方案都应配合本地缓存(如 Caffeine)做二级缓存,即使 Redis 击穿,本地缓存仍可提供短暂保护。
生产建议: 不要只依赖一种方案。通常采用 互斥锁 + 本地缓存 的组合,既保证一致性,又通过本地缓存降低锁竞争概率。