热点商品缓存击穿:过期瞬间 1000 QPS 打到数据库怎么办
目录
这是一个经典的缓存击穿问题。核心矛盾是:热点 key 过期的瞬间,大量并发请求同时穿透到数据库。 下面按实用程度排几个常见方案:
- 互斥锁(Mutex Lock)—— 最常见的回答 缓存失效时,不是所有请求都去查库,而是只让一个请求去重建缓存,其余等待。 思路:用 Redis 的 SET key NX EX 抢一把分布式锁。抢到锁的线程查库并回填缓存;没抢到的线程短暂 sleep 后重试读缓存。这样 1000 个请求最终只有 1 个打到数据库。 优点是实现简单,缺点是其余 999 个请求要等待,有一定延迟。
- 逻辑过期(Logical Expiration)—— 追求高可用时首选 Redis 里的 key 实际上永不过期,但在 value 中额外存一个逻辑过期时间。读取时发现逻辑时间已过期,就开一个异步线程去重建缓存,当前请求直接返回旧数据。 这样做的好处是请求永远不会被阻塞,用户体感很快;代价是短暂的数据不一致——在异步重建完成前,所有人拿到的都是过期数据。对于商品信息这种场景,通常可以接受。
- 提前续期 / 主动刷新 不要等缓存过期才行动。比如缓存 10 分钟,在第 7-8 分钟时用后台定时任务或消息队列主动刷新。热点 key 始终有效,根本不给击穿的机会。 适合能明确识别热点商品的场景(比如秒杀商品、运营推荐位)。
- 多级缓存 在 Redis 前面再加一层本地缓存(如 Caffeine / Guava Cache)。即使 Redis 过期,本地缓存还能扛一波。两层缓存同时过期的概率极低。
面试官追问,对于分布式锁的情况,1000个线程中只有一个抢到了锁,当这个线程执行完毕后,剩下 999 个线程还要继续去抢锁吗
不需要再抢锁了。因为抢到锁的那个线程已经把数据回填到缓存里了,剩下的线程直接读缓存就行。 具体流程是这样的: 第一个线程(抢到锁): 查缓存 → miss → 抢锁成功 → 查数据库 → 写回 Redis → 释放锁 → 返回数据 其余 999 个线程(没抢到锁): 查缓存 → miss → 抢锁失败 → sleep 一小段时间(比如 50-100ms)→ 重新查缓存 → 这时候缓存已经被回填了 → 命中缓存 → 直接返回 关键点在于,没抢到锁的线程不是去“继续抢锁”,而是回到读缓存那一步重试。伪代码大概长这样:
public Object getData(String key) {
// 1. 查缓存
Object value = redis.get(key);
if (value != null) {
return value;
}
// 2. 缓存未命中,尝试抢锁
String lockKey = "lock:" + key;
boolean locked = redis.set(lockKey, "1", "NX", "EX", 3);
if (locked) {
try {
// 3. 双重检查:拿到锁后再查一次缓存
value = redis.get(key);
if (value != null) {
return value;
}
// 4. 查数据库
value = db.query(key);
// 5. 回填缓存
redis.setex(key, 600, value);
return value;
} finally {
redis.del(lockKey);
}
} else {
// 6. 没抢到锁 → sleep → 重新读缓存(递归/循环)
Thread.sleep(50);
return getData(key); // 重试,大概率直接命中缓存
}
}
- 双重检查(Double Check):抢到锁之后、查库之前,再读一次缓存。因为可能在你等锁的过程中别人已经回填了,避免重复查库。
- 锁要设过期时间:万一抢到锁的线程挂了,锁会自动释放,不会死锁。
- 重试要有上限:递归不能无限套下去,设个最大重试次数(比如 3 次),超过就降级——直接查库或返回默认值,防止极端情况下线程一直空转。
逻辑过期和分布式锁需要一起使用的吧,单纯只用一个逻辑过期能实现吗
逻辑过期方案本身也需要配合锁(或类似的互斥机制),但它和“纯分布式锁方案”是两种不同的策略,核心区别在于没拿到锁的线程怎么处理。 对比一下就很清楚了: 方案一:分布式锁(同步重建)
没拿到锁的线程 → 等待 → 重试读缓存 特点:所有人最终拿到的都是新数据,但有等待延迟
方案二:逻辑过期(异步重建)
没拿到锁的线程 → 直接返回旧数据,不等待 特点:响应极快,零阻塞,但短暂返回过期数据
逻辑过期的伪代码大概是这样的:
public Object getData(String key) {
// 1. 缓存一定能查到(因为物理上永不过期)
CacheData cacheData = redis.get(key);
// 2. 判断逻辑时间是否过期
if (!cacheData.isExpired()) {
return cacheData.getValue(); // 没过期,直接返回
}
// 3. 逻辑过期了,尝试抢锁
String lockKey = "lock:" + key;
boolean locked = redis.set(lockKey, "1", "NX", "EX", 3);
if (locked) {
// 4. 抢到锁 → 开异步线程去重建
threadPool.submit(() -> {
try {
Object newValue = db.query(key);
CacheData newData = new CacheData(newValue,
System.currentTimeMillis() + 10 * 60 * 1000);
redis.set(key, newData); // 物理上不设过期时间
} finally {
redis.del(lockKey);
}
});
}
// 5. 不管有没有抢到锁,都直接返回旧数据
return cacheData.getValue();
}
注意第 5 步,这是两个方案最本质的区别——连抢到锁的那个线程自己都是先返回旧数据,重建是丢给异步线程做的。 所以回答你的问题: 单纯只用逻辑过期、不加锁行不行? 技术上能跑,但会退化成另一个问题——1000 个线程同时发现逻辑过期,全都去异步查库,还是 1000 次 DB 查询,等于没优化。所以锁是必要的,只是它在这里的作用是控制只开一个异步重建线程,而不是让其他线程排队等待。 总结一句话:两种方案都用锁,但用锁的目的不同。分布式锁方案里锁是让别人“等”,逻辑过期方案里锁是让别人“不用等,拿旧的先走”。
那么我还有一个问题,就是使用分布式锁的这块逻辑,需要业务一开始就要这样写吗,实际情况可能是我的业务很长时间并不会有这么大的并发量,可能平常也就 QPS 10 左右,也要这样加锁解决吗,就是偶尔会有 QPS 1000 左右
平时 QPS 10 的时候完全没必要加锁,过度防御反而增加复杂度和维护成本。关键是怎么在“需要的时候”才启用保护。 几个实际做法:
- 运营可预知的场景 → 提前介入 大促、秒杀、热点推荐这些场景是能提前预判的。上活动之前通过配置中心(比如 Nacos、Apollo)把热点商品的缓存策略切换成加锁模式或逻辑过期模式,活动结束再切回普通模式。平时代码走最简单的查缓存 → miss → 查库 → 回填,完全不用背负锁的开销。
- 不可预知的突发热点 → 自动检测 有些商品突然爆火是没法提前知道的,比如某个明星突然带货。这时候需要一个热点探测机制,思路是在应用层用滑动窗口统计每个 key 的访问频次,比如最近 10 秒内某个 key 被访问超过 500 次,就自动把它标记为热点 key,后续对这个 key 的缓存重建自动走加锁逻辑。京东有个开源的 hotkey 框架就是做这个事的。
- 最简单的折中 → 随机打散过期时间 如果不想引入额外组件,最低成本的做法是给缓存过期时间加一个随机偏移。比如基础 10 分钟,再加一个 0-60 秒的随机值。这样即使同类商品的缓存差不多时间写入,也不会在同一时刻集中过期。这个方案严格说是防缓存雪崩的,但对你这种“偶尔高并发”的场景也能有效削峰。 实际项目中比较合理的分层策略是这样的: 日常 QPS 低的时候,走最普通的缓存逻辑,加个随机过期时间就够了。能预判的大流量场景,提前通过配置开关切换到加锁或逻辑过期模式。不可预判的突发热点,靠热点探测自动升级保护策略。