4. 缓存击穿:热点Key失效、互斥锁方案、逻辑过期方案、永不过期方案
缓存击穿是指某个热点 Key 在缓存失效的瞬间,大量并发请求同时穿透缓存,直接打到后端数据库,导致数据库负载瞬间飙升甚至宕机。与缓存雪崩不同,击穿针对的是单个热点 Key,而非大批 Key 同时失效。
核心矛盾:热点 Key 承载高并发读,一旦失效,所有请求在重建缓存前全部穿透,数据库成为唯一防线。
4.1 热点Key失效场景分析
热点 Key 失效通常由以下原因触发:
- Key 到达 TTL 过期时间:缓存自然淘汰,最常见。
- 缓存服务重启或内存淘汰:Redis 因内存压力主动驱逐 Key。
- 手动删除或更新:业务主动删除 Key 后未及时重建。
失效瞬间的并发模型如下:
4.2 互斥锁方案(Mutex Lock)
互斥锁方案的核心思想:当缓存失效时,只允许一个线程去重建缓存,其他线程等待或快速返回。通过分布式锁(如 Redis SETNX)保证同一时刻只有一个请求访问数据库。
实现步骤
- 从缓存获取数据,若命中则直接返回。
- 若未命中,尝试获取分布式锁(SETNX key_lock 1 EX 10)。
- 获取锁成功的线程查询数据库,重建缓存,释放锁。
- 获取锁失败的线程:自旋等待(重试)或快速返回(降级)。
// 伪代码:互斥锁方案
public Object getData(String key) {
Object data = redis.get(key);
if (data != null) return data;
// 尝试获取锁
String lockKey = "lock:" + key;
boolean locked = redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查:防止等待期间其他线程已重建
data = redis.get(key);
if (data != null) return data;
data = db.query(key);
redis.set(key, data, 300, TimeUnit.SECONDS);
return data;
} finally {
redis.del(lockKey);
}
} else {
// 未获取锁:自旋等待或快速降级
Thread.sleep(50);
return getData(key); // 递归重试
}
}
适用场景:对数据一致性要求高,允许少量等待延迟。不适合极端高并发且对延迟敏感的场景。
4.3 逻辑过期方案(Logical Expiration)
逻辑过期方案不依赖 Redis 的 TTL 淘汰,而是在缓存数据中嵌入过期时间字段,由业务代码判断是否过期。过期后,当前线程返回旧数据,同时异步线程去更新缓存。
数据结构
| 字段 | 说明 | 示例 |
|---|---|---|
| data | 实际业务数据 | {"id": 1001, "name": "商品A"} |
| expireTime | 逻辑过期时间戳(毫秒) | 1718000000000 |
实现流程
- 从缓存获取数据,解析 expireTime。
- 若当前时间 < expireTime,数据有效,直接返回。
- 若当前时间 >= expireTime,数据逻辑过期:
- 尝试获取互斥锁(防止多个线程同时更新)。
- 获取锁成功的线程:异步线程池执行缓存更新,当前线程返回旧数据。
- 获取锁失败的线程:直接返回旧数据,不等待。
// 伪代码:逻辑过期方案
public Object getDataLogical(String key) {
String cacheStr = redis.get(key);
if (cacheStr == null) return db.query(key); // 缓存未命中,降级
CacheWrapper wrapper = JSON.parse(cacheStr, CacheWrapper.class);
if (System.currentTimeMillis() < wrapper.getExpireTime()) {
return wrapper.getData(); // 未过期,直接返回
}
// 逻辑过期:尝试获取锁,异步更新
String lockKey = "lock:" + key;
boolean locked = redis.setnx(lockKey, "1", 5, TimeUnit.SECONDS);
if (locked) {
threadPool.execute(() -> {
Object newData = db.query(key);
CacheWrapper newWrapper = new CacheWrapper(newData, futureExpireTime);
redis.set(key, JSON.toJSONString(newWrapper));
redis.del(lockKey);
});
}
return wrapper.getData(); // 返回旧数据
}
优势:读请求无阻塞,始终返回数据(即使旧数据),适合读多写少、允许短暂不一致的场景。
4.4 永不过期方案(No Expiration)
永不过期方案即缓存 Key 不设置 TTL,由后台任务或消息队列主动更新缓存数据。Redis 中 Key 永远存在,不会因过期触发击穿。
实现方式
- 定时任务更新:如每 5 分钟全量刷新热点 Key。
- 数据库变更通知:监听 binlog 或业务事件,变更时立即更新缓存。
- 双写一致性:写数据库时同步写缓存,保证数据新鲜。
注意:永不过期方案需要解决数据一致性问题。若更新失败,缓存会长期持有旧数据。建议配合版本号或校验机制。
4.5 三种方案对比
| 方案 | 一致性 | 延迟影响 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 互斥锁 | 强一致 | 少量等待 | 中 | 数据一致性要求高,并发适中 |
| 逻辑过期 | 最终一致 | 无等待 | 高 | 高并发读,允许短暂不一致 |
| 永不过期 | 依赖更新机制 | 无等待 | 低 | 数据更新可预测,后台可控 |
4.6 选型建议
- 互斥锁方案:适合金融、库存等强一致场景,但需控制锁粒度与超时时间,避免死锁。
- 逻辑过期方案:适合首页推荐、排行榜等允许短暂不一致的高并发场景,需额外维护异步线程池。
- 永不过期方案:适合数据更新频率可控、后台任务成熟的环境,需解决首次加载与一致性问题。
最佳实践:实际生产环境常组合使用——热点 Key 采用逻辑过期 + 异步更新,非热点 Key 使用互斥锁兜底,同时监控缓存命中率与数据库负载。