4. 缓存击穿原理与防护:互斥锁、逻辑过期、热点数据预加载、分布式锁
4.1 缓存击穿:定义与成因
缓存击穿(Cache Breakdown)是指一个热点 Key 在缓存中过期失效的瞬间,大量并发请求同时穿透缓存,直接打到后端数据库,导致数据库负载瞬间飙升甚至宕机的现象。它与缓存雪崩的区别在于:雪崩是大量 Key 同时失效,而击穿是单个热点 Key 失效引发的连锁反应。
核心特征:热点 Key + 高并发 + 缓存失效瞬间 = 数据库压力尖峰。
| 对比维度 | 缓存穿透 | 缓存击穿 | 缓存雪崩 |
|---|---|---|---|
| 失效对象 | 不存在的数据(Key 无效) | 单个热点 Key | 大量 Key 同时过期 |
| 并发特征 | 持续攻击 | 瞬时高并发 | 批量失效 |
| 数据库压力 | 持续空查 | 瞬间尖峰 | 持续高负载 |
| 典型场景 | 恶意请求不存在的 ID | 秒杀商品详情页 | 缓存服务重启 |
4.2 防护策略一:互斥锁(Mutex Lock)
互斥锁是最直接的防护手段:当缓存失效后,只允许一个线程去数据库加载数据,其他线程等待该线程完成加载并更新缓存后,再从缓存中获取数据。
实现要点
- 锁粒度:必须针对具体的 Key 加锁,避免锁住整个缓存。
- 锁超时:设置合理的锁超时时间,防止线程异常退出导致死锁。
- 重试机制:等待线程需要自旋或阻塞等待,避免无限等待。
// 伪代码:互斥锁实现缓存击穿防护
public String getData(String key) {
String value = cache.get(key);
if (value != null) {
return value;
}
// 尝试获取分布式锁(以 Redis SETNX 为例)
String lockKey = "lock:" + key;
boolean locked = redis.setnx(lockKey, "1", 5, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查:防止在等待锁期间其他线程已更新缓存
value = cache.get(key);
if (value != null) {
return value;
}
// 从数据库加载数据
value = db.query(key);
cache.set(key, value, 300, TimeUnit.SECONDS);
return value;
} finally {
redis.del(lockKey); // 释放锁
}
} else {
// 未获取到锁的线程等待后重试
Thread.sleep(50);
return getData(key); // 递归重试
}
}
适用场景:对数据一致性要求高、并发量中等(QPS < 10万)的场景。缺点在于等待线程会阻塞,增加响应延迟。
4.3 防护策略二:逻辑过期(Logical Expiration)
逻辑过期方案不设置物理 TTL,而是在缓存数据中嵌入一个逻辑过期时间字段。当读取缓存时,检查逻辑时间是否过期:若未过期,直接返回;若已过期,则异步触发缓存更新,同时返回旧数据给调用方。
核心数据结构
// 缓存值包装类
public class CacheWrapper<T> {
private T data; // 实际数据
private long expireTime; // 逻辑过期时间戳(毫秒)
private long version; // 版本号(可选,用于防并发更新)
}
执行流程
- 从缓存获取
CacheWrapper对象。 - 判断当前时间是否超过
expireTime。 - 若未过期:直接返回
data。 - 若已过期:尝试获取分布式锁(或使用 CAS 更新版本号)。
- 获取锁成功的线程异步执行数据库查询并更新缓存。
- 获取锁失败的线程直接返回旧数据,不等待。
注意:逻辑过期方案返回的是“脏数据”,适用于对短暂数据不一致容忍度较高的场景(如排行榜、非关键配置)。必须配合异步补偿机制保证最终一致性。
4.4 防护策略三:热点数据预加载(Preload)
热点数据预加载是一种主动防御策略:在缓存失效之前,提前将热点数据加载到缓存中,避免失效瞬间的并发冲击。
实现方式
| 方式 | 原理 | 适用场景 |
|---|---|---|
| 定时任务预加载 | 通过 Cron 或调度框架,在缓存 TTL 过期前 1/3 时间刷新缓存 | 数据更新频率可预测、有固定刷新窗口 |
| 延迟双删 + 预加载 | 数据库更新后,先删除缓存,延迟一段时间后再将新数据写入缓存 | 写多读少、允许短暂不一致 |
| 热点探测 + 自动预热 | 实时监控 Key 访问频率,当达到阈值时自动触发预加载 | 流量突发、热点动态变化 |
预加载策略对比
- 固定预加载:简单可靠,但可能浪费缓存空间(预加载了不常用的数据)。
- 动态预加载:基于滑动窗口统计访问频次,精准识别热点,资源利用率高。
- 预热脚本:系统启动或大促前,通过脚本批量加载热点数据到缓存。
最佳实践:预加载 + 互斥锁组合使用。预加载处理大部分正常流量,互斥锁作为兜底,防止预加载失败或时间偏差导致的击穿。
4.5 防护策略四:分布式锁(Distributed Lock)
在微服务架构下,多个服务实例同时访问缓存,必须使用分布式锁来保证只有一个实例去加载数据。分布式锁是互斥锁在分布式环境下的扩展。
主流实现方案
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| Redis SETNX | SET key value NX EX 5 |
性能高、实现简单 | 锁超时可能导致并发问题 |
| Redisson | 看门狗机制自动续期 | 解决锁超时问题 | 依赖 Redis 主从一致性 |
| ZooKeeper | 临时顺序节点 + Watch | 强一致性、无超时问题 | 性能较低、依赖 ZK 集群 |
| Etcd | Lease + TTL + Revision | 强一致、云原生友好 | 运维复杂度较高 |
分布式锁在缓存击穿中的关键设计
- 锁粒度:必须按 Key 粒度加锁,锁 Key 格式建议
lock:cache:{originalKey}。 - 锁超时与续期:使用 Redisson 看门狗或手动续期,避免业务执行时间超过锁 TTL。
- 可重入性:同一线程多次获取同一把锁时不会死锁。
- 容错性:Redis 主从切换时,使用 Redlock 算法保证锁的安全性。
// 使用 Redisson 实现分布式锁防护
public String getDataWithRedisson(String key) {
String value = cache.get(key);
if (value != null) {
return value;
}
RLock lock = redissonClient.getLock("lock:cache:" + key);
try {
// 尝试加锁,最多等待 100ms,锁自动释放时间 10s
if (lock.tryLock(100, 10, TimeUnit.SECONDS)) {
try {
// 双重检查
value = cache.get(key);
if (value != null) {
return value;
}
value = db.query(key);
cache.set(key, value, 300, TimeUnit.SECONDS);
return value;
} finally {
lock.unlock();
}
} else {
// 等待后重试
Thread.sleep(50);
return getDataWithRedisson(key);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return db.query(key); // 降级:直接查库
}
}
4.6 四种策略对比与选型
| 策略 | 一致性 | 性能影响 | 实现复杂度 | 适用并发量 | 脏数据风险 |
|---|---|---|---|---|---|
| 互斥锁 | 强一致 | 中等(等待阻塞) | 低 | 中(QPS < 10万) | 无 |
| 逻辑过期 | 最终一致 | 低(无阻塞) | 中 | 高(QPS > 10万) | 有(短暂脏读) |
| 热点预加载 | 强一致(配合锁) | 低(提前加载) | 中高 | 高 | 无(预加载成功时) |
| 分布式锁 | 强一致 | 中等(网络开销) | 高 | 中高 | 无(锁机制完善时) |
选型建议:
- 金融、交易等强一致场景:互斥锁 + 分布式锁。
- 高并发、可接受短暂不一致:逻辑过期 + 异步更新。
- 大促、秒杀等可预知热点:热点预加载 + 互斥锁兜底。
- 微服务多实例部署:分布式锁(Redisson/Redlock)。
4.7 缓存击穿防护架构总览
下图展示了四种防护策略在缓存击穿场景下的协同工作流程:
4.8 实战避坑指南
常见陷阱:
- 锁未设置超时:线程崩溃导致锁永久占用,所有请求阻塞。必须设置 TTL 并实现续期机制。
- 单机锁用于分布式:
synchronized或ReentrantLock在多实例下无效,必须使用分布式锁。 - 预加载时间偏差:预加载任务执行时间过长,导致缓存实际已过期但新数据未写入。建议预加载 TTL 设为缓存 TTL 的 1/3。
- 逻辑过期未做版本控制:多个线程同时检测到过期并触发更新,导致数据库压力翻倍。需配合 CAS 或分布式锁。
- 锁粒度太大:对整个缓存服务加锁,导致所有 Key 的读取串行化。必须按 Key 粒度加锁。
生产环境推荐组合:
- Redis 缓存 + Redisson 分布式锁 + 热点探测预加载。
- 对于超高并发场景(QPS > 50万),使用逻辑过期 + 本地缓存(Caffeine)二级缓存架构。
- 所有方案必须配合降级策略:当锁获取失败或数据库异常时,返回缓存中的旧数据或默认值。