缓存击穿是高并发场景下最经典的问题之一。当某个访问量非常大的热点key在Redis中过期的那一瞬间,成千上万个并发请求同时发现缓存未命中,随后全部涌向数据库执行查询,数据库连接池瞬间被打满,接口响应变慢甚至超时,进而拖垮整个服务。解决这个问题的思路有很多,其中互斥锁方案和逻辑过期(也就是常说的永不过期)方案是面试和实际工作中讨论最多的两种,下面我们结合代码逐一拆解。

一、先搞清楚缓存击穿的触发条件
缓存击穿和缓存穿透、缓存雪崩经常被放在一起比较,很多人容易混淆。缓存穿透指的是查询一个数据库中根本不存在的数据,缓存和数据库都查不到,请求每次都穿透到数据库;缓存雪崩指的是大量key在同一时间集中过期,导致数据库压力骤增;而缓存击穿针对的是单个热点key,它确实是数据库中存在的真实数据,只是访问频率极高,一旦过期,并发压力会在极短时间内集中爆发。
举个具体的例子:某电商平台的爆款商品详情页,每秒有上万的读请求,缓存过期时间设置为30分钟。当这个key过期时,假设同一秒内有5000个请求到达,这5000个请求在缓存中都查不到数据,于是全部执行数据库查询,而这条SQL可能是一个涉及多表关联的复杂查询,单次执行需要200毫秒。数据库最大连接数如果是500,那么后面排队的请求只能等待,连接超时后服务直接报错,这就是一次完整的缓存击穿事故。
理解了触发条件,就能明白两种方案的共同目标:让数据库在缓存失效的瞬间只被访问一次(互斥锁的思路),或者让缓存根本不失效、由后台异步刷新(逻辑过期的思路)。
二、互斥锁方案:同一时刻只放一个请求去查库
互斥锁方案的核心逻辑是:当请求发现缓存未命中时,先尝试获取一把分布式锁,只有抢到锁的线程才有资格去查询数据库并回写缓存,其他线程要么短暂等待后重试读取缓存,要么直接返回一个兜底数据。锁本身可以利用Redis的SETNX命令实现,即只有key不存在时才能设置成功。
下面是一段基于Java实现的示例代码,展示完整的加锁、查库、回写流程:
public String getDataWithMutex(String key) {
String value = redis.get(key);
if (value != null) {
return value; // 缓存命中,直接返回
}
// 缓存未命中,尝试获取互斥锁
String lockKey = "lock:" + key;
String lockValue = UUID.randomUUID().toString();
// setnx + 过期时间必须用原子命令,避免死锁
boolean locked = redis.set(lockKey, lockValue, "NX", "EX", 10);
try {
if (!locked) {
// 没抢到锁,休眠后重试读缓存
Thread.sleep(50);
return getDataWithMutex(key); // 递归重试
}
// 双重检查:可能别的线程已经把缓存写好了
value = redis.get(key);
if (value != null) {
return value;
}
// 只有持锁线程才查数据库
value = queryFromDatabase(key);
redis.set(key, value, 30, TimeUnit.MINUTES);
return value;
} finally {
// 释放锁前校验是自己的锁,防止误删
releaseLock(lockKey, lockValue);
}
}
这段代码有几个容易被忽略的细节。第一,加锁和设置过期时间必须是一条原子命令,如果先SETNX再单独EXPIRE,程序在两条命令之间崩溃就会产生死锁。第二,抢到锁之后要再次检查缓存(双重检查),因为从第一次查缓存失败到真正拿到锁之间可能隔了几十毫秒,前面的线程可能已经完成了回写。第三,释放锁时要校验锁的value是不是自己设置的,否则可能出现线程A的锁过期后,线程B拿到了新锁,线程A回来把B的锁删掉的错误场景,这个校验和删除操作 ideally 应该用Lua脚本保证原子性。
互斥锁方案的优点是保证了一致性,数据库最多被查询一次,实现思路直观。缺点也很明显:等待锁的线程会阻塞,线程资源被占用,重试机制还会增加请求的整体响应时间;如果持锁线程在查库过程中卡住,其他请求会一直空转;分布式锁本身也引入了额外的复杂度和故障点。因此它适合对数据一致性要求较高、并发量中等、能接受少量延迟的场景。
三、逻辑过期方案:缓存永不失效,数据异步刷新
逻辑过期方案换了一个角度思考问题:与其让key物理过期,不如干脆不设置TTL,把过期时间作为数据的一部分存进value里。请求读到数据后先判断逻辑时间是否过期,如果过期了,当前请求返回旧数据的同时,异步开启一个线程去重建缓存。这样一来,所有请求永远都能从缓存拿到数据,数据库的压力被彻底隔离。
具体实现时,需要把缓存的数据结构包一层,包含数据和逻辑过期时间两个字段:
public class RedisData {
private LocalDateTime expireTime; // 逻辑过期时间
private Object data; // 真实业务数据
}
public String getDataWithLogicalExpire(String key) {
String json = redis.get(key); // 这个key没有物理TTL,永远存在
if (json == null) {
return null; // 需要提前预热加载
}
RedisData redisData = JSON.parseObject(json, RedisData.class);
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
// 逻辑上未过期,直接返回数据
return redisData.getData().toString();
}
// 逻辑已过期,尝试获取重建锁
String lockKey = "lock:rebuild:" + key;
boolean locked = tryLock(lockKey);
if (locked) {
// 开启异步线程重建缓存,当前线程不等待
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
String newData = queryFromDatabase(key);
RedisData fresh = new RedisData();
fresh.setData(newData);
fresh.setExpireTime(LocalDateTime.now().plusMinutes(30));
redis.set(key, JSON.toJSONString(fresh));
} finally {
unlock(lockKey);
}
});
}
// 无论是否触发重建,都返回旧数据兜底
return redisData.getData().toString();
}
注意这个方案里的锁和互斥锁方案中的锁作用不同:这里抢不到锁的线程不会等待重试,而是直接返回旧数据,只有抢到锁的那个线程负责触发异步重建。所以锁的竞争开销极小,请求的响应时间也不会因为重建而变长。
这个方案有一个前提条件必须强调:由于key永不物理过期,缓存中的数据在系统上线或首次访问前必须提前预热加载好,否则第一次查询时缓存和数据库都没有数据。另外,它牺牲了强一致性,在重建完成之前的这段时间内,所有请求拿到的都是旧数据。如果业务能容忍短时间的数据陈旧,比如商品点赞数、热榜排名这类数据,逻辑过期是非常优雅的方案;但对于库存、余额这类对准确性敏感的数据,就不能用了。
四、两种方案怎么选:从一致性、可用性、复杂度三个维度对比
把两种方案的差异整理成表格更直观:
| 对比维度 | 互斥锁方案 | 逻辑过期方案 |
|---|---|---|
| 一致性 | 强,等待期间可能拿到最新数据 | 弱,重建期间返回旧数据 |
| 可用性 | 较低,线程阻塞等待,可能超时 | 高,请求永不阻塞,始终有数据返回 |
| 实现复杂度 | 中等,需要处理锁的原子性和误删问题 | 较高,需要预热机制和异步线程池 |
| 数据库压力 | 极小,仅一次查询 | 极小,异步线程单次查询 |
| 适用场景 | 一致性要求高,可接受短延迟 | 容忍陈旧数据,追求高可用 |
从CAP的角度看,互斥锁方案偏向保证一致性,逻辑过期方案偏向保证可用性。实际选型时可以先问自己两个问题:第一,这份数据在几秒到几十秒的延迟窗口内出现过期值,业务能否接受?如果能接受,优先考虑逻辑过期,因为它对用户体验最友好,接口响应时间稳定;如果不能接受,比如支付、下单链路上的数据,就用互斥锁。
第二个问题是并发量级。互斥锁方案在超高并发下,大量线程自旋重试会消耗CPU和线程资源,极端情况下等待队列过长导致接口超时,反而不如逻辑过期稳。而逻辑过期方案的代价是需要维护预热流程和异步重建线程池,系统复杂度上升,出问题时排查链路也更长。
此外还有一些工程层面的补充手段可以配合使用:对于读多写少的热点数据,可以结合缓存过期时间的随机偏移避免多个key同时失效;在数据库层面前面加一道限流或熔断,作为击穿发生时的最后防线;如果是单体应用且数据只在单机内使用,用JVM本地的synchronized或ReentrantLock就够了,没必要引入Redis分布式锁增加网络开销。技术方案没有绝对的好坏,理解每种方案背后的取舍逻辑,结合自己业务的一致性和可用性要求做选择,才是解决缓存击穿问题的正确姿势。