3. 缓存击穿:热点Key失效问题,互斥锁与逻辑过期方案详解
大家好,我是老张。今天咱们聊聊缓存击穿。
说实话,这个坑我踩过不止一次。有一次线上大促,某个热点商品的详情页突然全部打不开,数据库连接池瞬间被打满。查了半天,发现就是一个热点Key过期了,所有请求直接穿透到数据库。嗯,这就是典型的缓存击穿。
3.1 什么是缓存击穿?
先搞清楚概念。缓存击穿,说白了就是:某个热点Key突然失效,大量并发请求同时打到数据库。
你想想看,平时缓存扛着99%的流量,数据库岁月静好。突然这个热点Key过期了,所有请求就像没头苍蝇一样,全往数据库冲。数据库哪受得了这个?
核心特征:
- 针对的是热点Key,不是所有Key
- 发生在Key过期的瞬间
- 后果是数据库压力暴增,可能引发雪崩
这里要区分一下缓存雪崩。雪崩是大量Key同时过期,击穿是单个热点Key过期。虽然都是缓存失效,但处理思路完全不同。
3.2 互斥锁方案
互斥锁,是我个人最常用的方案。思路很简单:谁先拿到锁,谁去查数据库;其他人等着,拿锁的人把缓存写回去,大家直接读缓存。
我在项目中遇到过这样一个场景:某个秒杀商品的库存信息,每秒几万次查询。如果不用互斥锁,Key一过期,数据库直接被打爆。
3.2.1 实现原理
核心逻辑就三步:
- 查缓存,有数据直接返回
- 缓存没有,尝试获取分布式锁
- 拿到锁的去查数据库,没拿到的等待重试
// 伪代码示例
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", 3, TimeUnit.SECONDS);
if (locked) {
try {
// 3. 拿到锁,查数据库
value = db.query(key);
redis.set(key, value, 60, TimeUnit.SECONDS);
return value;
} finally {
redis.del(lockKey); // 释放锁
}
} else {
// 4. 没拿到锁,等待重试
Thread.sleep(50);
return getData(key); // 递归重试
}
}
避坑指南:我曾经犯过一个错误——锁忘记设置过期时间。结果某个线程拿到锁后挂了,锁永远不释放,所有请求都卡死。后来我养成了习惯:setnx一定要带过期时间。
3.2.2 优缺点分析
| 维度 | 说明 |
|---|---|
| 优点 | 实现简单,数据一致性高,不会读到过期数据 |
| 缺点 | 有锁竞争,高并发下性能下降;存在死锁风险 |
| 适用场景 | 对一致性要求高,并发量不是特别极端的场景 |
3.3 逻辑过期方案
互斥锁有个问题:它会让请求排队。如果并发量特别大,排队等待的时间可能比查数据库还长。
这时候,逻辑过期方案就派上用场了。说白了就是:缓存永不过期,但数据里带一个逻辑过期时间。后台异步刷新,前台读到过期数据也能先用着。
3.3.1 实现原理
我习惯这么设计:
- 缓存里存的是
数据 + 过期时间戳 - 每次读取时,判断时间戳是否过期
- 没过期,直接返回
- 过期了,先返回旧数据,再异步去刷新缓存
// 逻辑过期实现
public class CacheItem {
private String data; // 实际数据
private long expireTime; // 逻辑过期时间戳
}
public String getData(String key) {
// 1. 读缓存
CacheItem item = redis.get(key);
// 2. 判断是否逻辑过期
if (item.getExpireTime() > System.currentTimeMillis()) {
// 没过期,直接返回
return item.getData();
}
// 3. 过期了,尝试加锁刷新
String lockKey = "refresh:" + key;
boolean locked = redis.setnx(lockKey, "1", 1, TimeUnit.SECONDS);
if (locked) {
// 4. 拿到锁,异步刷新缓存
executor.submit(() -> {
String newData = db.query(key);
CacheItem newItem = new CacheItem(newData, System.currentTimeMillis() + 60000);
redis.set(key, newItem);
redis.del(lockKey);
});
}
// 5. 返回旧数据(虽然过期了,但还能用)
return item.getData();
}
注意:逻辑过期方案返回的是旧数据。如果你的业务场景要求绝对一致,比如金融交易,那这个方案就不合适。我一般用在商品详情、资讯列表这类场景。
3.3.2 优缺点分析
| 维度 | 说明 |
|---|---|
| 优点 | 无锁等待,性能极高;用户体验好,不会卡住 |
| 缺点 | 数据短暂不一致;实现稍复杂;需要异步线程池 |
| 适用场景 | 高并发、对一致性要求不苛刻的场景 |
3.4 方案对比与选型
这两个方案怎么选?我个人的经验是:
- 互斥锁:适合数据一致性要求高、并发量在万级以下的场景。比如订单状态、库存数量。
- 逻辑过期:适合超高并发、可以接受短暂不一致的场景。比如首页推荐、商品列表。
还有一种折中方案:互斥锁 + 本地缓存。先用本地缓存扛住大部分请求,只有本地缓存失效时才去抢分布式锁。这个我在后面的章节会详细讲。
3.5 核心流程图
下面这张图,是我自己总结的缓存击穿处理流程。你看一眼就明白了:
3.6 实战建议
最后,分享几个我在实战中总结的经验:
经验一:热点Key的过期时间不要设得太短。我一般设10-30分钟,配合逻辑过期方案,让后台慢慢刷新。
经验二:如果用了互斥锁,一定要设置合理的超时时间。太短容易频繁查库,太长会导致请求排队。我习惯设1-3秒。
经验三:我曾经遇到过一个坑——热点Key的并发量太大,互斥锁本身成了瓶颈。后来我改用分段锁,把同一个Key拆成多个子Key,每个子Key独立加锁。效果立竿见影。
好了,缓存击穿的核心内容就这些。记住一句话:热点Key是系统的命脉,保护好了,系统就稳了。