缓存击穿:一场针对热点数据的“精准打击”
好,咱们接着聊缓存击穿。说实话,我刚开始接触这个概念时,总觉得它跟穿透长得太像了,容易搞混。但实际在线上踩过坑之后,你会发现——这俩完全不是一回事。
什么是缓存击穿?
缓存击穿,说白了就是:某个热点 key 突然过期了,然后一瞬间大量请求直接怼到数据库上。
你想想看,平时大家都从缓存里拿数据,数据库岁月静好。突然缓存里那个最热门的 key 没了,所有请求就像没头苍蝇一样,全跑去查数据库。数据库哪扛得住这种流量洪峰?
核心特征:
- 针对的是单个热点 key,不是一堆不存在的 key
- 发生在 key 过期瞬间,不是一直不存在
- 后果是数据库瞬间被打爆,服务雪崩
击穿 vs 穿透:到底差在哪?
我记得有一次团队里新同学问我:“这两个不都是打到数据库了吗?” 嗯,这里要注意,区别其实很明显:
| 对比维度 | 缓存穿透 | 缓存击穿 |
|---|---|---|
| 目标数据 | 数据库中也不存在的数据 | 数据库中真实存在的热点数据 |
| 发生时机 | 持续发生,跟过期无关 | key 过期瞬间 |
| 影响范围 | 可能涉及多个 key | 通常单个热点 key |
| 解决方案 | 布隆过滤器、空值缓存 | 互斥锁、逻辑过期 |
简单来说:穿透是“查无此人”,击穿是“人刚走,茶还没凉透,大家就冲进来了”。
互斥锁方案(SETNX):最直接的“挡箭牌”
我个人习惯用 SETNX 来解决击穿问题。原理很简单:当缓存失效后,让第一个请求去拿锁,然后查数据库重建缓存。其他请求拿不到锁,就等着或者直接返回旧数据。
来看一段我常用的代码:
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", 30, TimeUnit.SECONDS);
if (locked) {
try {
// 拿到锁,查数据库
value = db.query(key);
redis.set(key, value, 300, TimeUnit.SECONDS);
return value;
} finally {
redis.del(lockKey); // 释放锁
}
} else {
// 没拿到锁,等一会儿再试
Thread.sleep(100);
return getData(key); // 递归重试
}
}
避坑指南:我曾经在释放锁时忘记加 finally,结果锁一直没释放,导致后续所有请求都卡死了。嗯,血的教训——锁的释放一定要放在 finally 里。
逻辑过期方案:更优雅的“软过期”
互斥锁虽然好用,但有个问题:如果数据库查询很慢,所有请求都得等着。这时候我更喜欢用逻辑过期方案。
什么叫逻辑过期?就是缓存里存的数据不过期,但额外存一个过期时间字段。读取时判断是否过期,过期了就异步去更新。
// 缓存数据结构
{
"data": "实际业务数据",
"expireTime": 1700000000 // 逻辑过期时间戳
}
public String getData(String key) {
String cacheData = redis.get(key);
if (cacheData == null) {
return null; // 缓存都没了,走降级
}
CacheObject obj = JSON.parse(cacheData);
if (obj.expireTime > System.currentTimeMillis()) {
// 没过期,直接返回
return obj.data;
}
// 逻辑过期了,尝试获取锁去更新
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS)) {
// 异步线程去更新缓存
executor.submit(() -> {
String newData = db.query(key);
CacheObject newObj = new CacheObject(newData, System.currentTimeMillis() + 300000);
redis.set(key, JSON.toJSON(newObj));
redis.del(lockKey);
});
}
// 返回旧数据,不阻塞
return obj.data;
}
你看,这种方式的好处是:用户永远不用等。哪怕缓存过期了,也先返回旧数据,后台悄悄更新。用户体验好,数据库压力也小。
热点数据识别与预热:把问题扼杀在摇篮里
说实话,上面两种方案都是“亡羊补牢”。我更推荐的做法是:提前识别热点,主动预热。
我在项目中用过这么几种方式:
- 访问频率统计:在业务层记录每个 key 的访问次数,超过阈值就标记为热点
- 人工配置:运营活动开始前,手动把相关数据预热到缓存
- 定时任务扫描:每隔一段时间,扫描最近访问量高的 key,提前续期
预热的核心逻辑:
// 启动时加载热点数据
@PostConstruct
public void preheat() {
List<String> hotKeys = getHotKeysFromConfig();
for (String key : hotKeys) {
String value = db.query(key);
redis.set(key, value, 300, TimeUnit.SECONDS);
}
}
击穿场景的降级策略:留好“后手”
不管你怎么预防,总有意外发生。我经历过一次线上事故:某个大促活动,热点 key 的过期时间设置得太短,结果互斥锁也没扛住,数据库直接挂了。
从那以后,我养成了一个习惯:给每个热点 key 都准备降级方案。
常用的降级策略有:
- 返回默认值:缓存和数据库都挂了,直接返回一个预设的默认数据
- 限流熔断:对热点 key 的请求做限流,超过阈值直接拒绝
- 本地缓存兜底:在应用层维护一个小的本地缓存,作为最后一道防线
注意:降级不是万能的。我曾经见过一个团队,降级策略写得太随意,结果返回的默认数据导致前端页面大面积报错。降级数据一定要经过测试,确保业务能接受。
最后说一句:缓存击穿这个问题,预防永远比补救重要。热点数据预热 + 合理的过期时间 + 互斥锁兜底,这三板斧用好了,大部分击穿问题都能避免。当然,真遇到极端情况,降级策略就是你的“救命稻草”。