3、缓存击穿问题:定义与危害、解决方案(互斥锁、逻辑过期)、实战代码示例
3.1 缓存击穿到底是什么?
先说说缓存击穿。很多人容易把它和缓存穿透搞混,我刚开始也犯过这个错。
缓存击穿,说白了就是:某个热点 key 突然过期了,然后一瞬间大量请求直接打到数据库上。
注意几个关键词:
- 热点 key:访问量极高的数据,比如秒杀商品、热搜榜单
- 突然过期:不是慢慢失效,是瞬间失效
- 大量请求:并发量很大,可能成千上万
你想想看,平时这些请求都从缓存拿数据,缓存扛得住。突然缓存里没了,所有请求就像没头苍蝇一样全冲进数据库。数据库哪受得了这个?
核心区别一句话总结:
- 缓存穿透:查的数据本来就不存在
- 缓存击穿:数据存在,但缓存刚好过期
- 缓存雪崩:大量 key 同时过期
3.2 危害有多大?
我在项目中遇到过两次缓存击穿,印象特别深。
第一次是某个电商大促活动,商品详情页的缓存设置了 1 小时过期。结果整点的时候,所有商品缓存同时到期,数据库连接池瞬间被打满,接口响应时间从 5ms 飙升到 3 秒多。用户端直接看到页面加载失败。
第二次更惨,某个热点新闻的缓存过期后,大量用户刷新,数据库 CPU 直接飙到 100%,最后不得不重启数据库。
所以危害其实很明确:
- 数据库压力暴增:瞬间 QPS 可能翻几十倍
- 响应变慢甚至超时:用户体验极差
- 可能引发雪崩:数据库挂了,整个系统都受影响
我曾经踩过的坑:
以为热点 key 设置永不过期就没事了。结果数据更新后,缓存和数据库不一致,用户看到的是旧数据。嗯,这里要注意,缓存击穿和一致性是两回事,不能为了防击穿就放弃一致性。
3.3 解决方案一:互斥锁
互斥锁的思路很简单:只让一个请求去查数据库,其他请求等着。
流程是这样的:
- 请求来了,先查缓存
- 缓存没命中,尝试获取互斥锁
- 拿到锁的请求去查数据库,然后回写缓存
- 没拿到锁的请求,等待一段时间后重试
- 缓存有了,直接返回
我个人习惯用 Redis 的 SETNX 命令来实现互斥锁。代码示例如下:
// 伪代码 - 互斥锁方案
public String getData(String key) {
// 1. 先查缓存
String value = redis.get(key);
if (value != null) {
return value;
}
// 2. 缓存没命中,尝试获取锁
String lockKey = "lock:" + key;
boolean locked = redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
try {
// 3. 拿到锁,查数据库
value = db.query(key);
// 4. 回写缓存,设置过期时间
redis.set(key, value, 60, TimeUnit.SECONDS);
return value;
} finally {
// 5. 释放锁
redis.del(lockKey);
}
} else {
// 6. 没拿到锁,等待后重试
Thread.sleep(100);
return getData(key); // 递归重试
}
}
避坑指南:
我曾经犯过一个错误:锁的过期时间设得太短。数据库查询慢一点,锁就自动释放了,其他请求又冲进来。建议锁的过期时间设成数据库查询时间的 3-5 倍。
3.4 解决方案二:逻辑过期
互斥锁有个问题:会阻塞请求。如果数据库查询很慢,所有请求都得等着。
逻辑过期方案就不一样了。它的核心思想是:缓存永不过期,但数据里带一个过期时间。
流程是这样的:
- 缓存里存的是数据 + 过期时间
- 请求来了,检查过期时间
- 没过期,直接返回
- 过期了,先返回旧数据,再异步去更新缓存
说白了就是:用户永远能拿到数据,只是可能拿到的是旧数据。
// 伪代码 - 逻辑过期方案
public String getData(String key) {
// 1. 查缓存
String cacheData = redis.get(key);
if (cacheData == null) {
return null; // 缓存都没了,走兜底逻辑
}
// 2. 解析数据
CacheData data = JSON.parse(cacheData);
// 3. 检查逻辑过期时间
if (data.getExpireTime() > System.currentTimeMillis()) {
// 没过期,直接返回
return data.getValue();
}
// 4. 过期了,尝试获取互斥锁
String lockKey = "lock:" + key;
boolean locked = redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
// 5. 拿到锁,异步更新缓存
executor.submit(() -> {
String newValue = db.query(key);
CacheData newData = new CacheData(newValue,
System.currentTimeMillis() + 60000);
redis.set(key, JSON.toJSON(newData));
redis.del(lockKey);
});
}
// 6. 返回旧数据
return data.getValue();
}
两种方案对比:
| 对比项 | 互斥锁 | 逻辑过期 |
|---|---|---|
| 数据一致性 | 强一致 | 弱一致(可能读到旧数据) |
| 请求延迟 | 可能阻塞 | 无阻塞 |
| 实现复杂度 | 简单 | 中等 |
| 适用场景 | 对一致性要求高 | 对可用性要求高 |
3.5 实战经验总结
我个人习惯这样选:
- 数据一致性要求高(比如订单状态):用互斥锁
- 数据一致性要求不高(比如商品详情):用逻辑过期
- 热点 key 特别多:考虑用本地缓存做二级缓存,减少 Redis 压力
我记得有一次做直播带货系统,商品库存数据用了互斥锁。结果主播一喊「上链接」,瞬间几十万请求进来,锁竞争特别激烈。后来改成逻辑过期 + 异步更新,用户体验好多了。
嗯,这里还要提醒一点:不管用哪种方案,都要做好降级和熔断。万一缓存和数据库都挂了,得有兜底方案。
我的建议:
刚开始做缓存击穿防护时,先用互斥锁。它简单、容易理解、不容易出错。等系统稳定了,再考虑优化成逻辑过期方案。别一上来就搞复杂的,容易翻车。